Skip to content

Commit

Permalink
chore(TransactionFeedV2): Connect to new API (#6182)
Browse files Browse the repository at this point in the history
### Description
Last PR for RET-1207. Connects TransactionFeedV2 to new API with the
corresponding changes to the fetching process.

### Test plan
Fixed all existing tests to pass with the new cursor structure. One test
related to haptic feedback is disabled for now as some flaws in the
logic were revealed. It will be addresses as a separate change and it is
not urgently necessary for the MVP.

### Related issues

- Relates to RET-1207

### Backwards compatibility
Yes

### Network scalability

If a new NetworkId and/or Network are added in the future, the changes
in this PR will:

- [x] Continue to work without code changes, OR trigger a compilation
error (guaranteeing we find it when a new network is added)
  • Loading branch information
sviderock authored Oct 23, 2024
1 parent 2174e2f commit a128dbc
Show file tree
Hide file tree
Showing 6 changed files with 133 additions and 105 deletions.
9 changes: 0 additions & 9 deletions src/redux/api.ts

This file was deleted.

36 changes: 24 additions & 12 deletions src/transactions/api.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,39 @@
import { createApi } from '@reduxjs/toolkit/query/react'
import { baseQuery } from 'src/redux/api'
import type { TokenTransaction } from 'src/transactions/types'

export const FIRST_PAGE_TIMESTAMP = 0 // placeholder
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { type LocalCurrencyCode } from 'src/localCurrency/consts'
import {
TokenTransactionTypeV2,
type PageInfo,
type TokenTransaction,
} from 'src/transactions/types'
import networkConfig from 'src/web3/networkConfig'

export type TransactionFeedV2Response = {
transactions: TokenTransaction[]
pageInfo: {
hasNextPage: boolean
}
pageInfo: PageInfo
}

const baseQuery = fetchBaseQuery({
baseUrl: networkConfig.getWalletTransactionsUrl,
headers: { Accept: 'application/json' },
})

export const transactionFeedV2Api = createApi({
reducerPath: 'transactionFeedV2Api',
baseQuery,
endpoints: (builder) => ({
transactionFeedV2: builder.query<
TransactionFeedV2Response,
{ address: string; endCursor: number }
{
address: string
localCurrencyCode: LocalCurrencyCode
endCursor: PageInfo['endCursor'] | undefined
}
>({
query: ({ address, endCursor }) => {
const cursor = endCursor ? `?endCursor=${endCursor}` : ''
return `/wallet/${address}/transactions${cursor}`
query: ({ address, localCurrencyCode, endCursor }) => {
const networkIds = Object.values(networkConfig.networkToNetworkId).join('&networkIds[]=')
const includeTypes = Object.values(TokenTransactionTypeV2).join('&includeTypes[]=')
const cursor = endCursor === undefined ? '' : `&afterCursor=${endCursor}`
return `?networkIds[]=${networkIds}&includeTypes[]=${includeTypes}&address=${address}&localCurrencyCode=${localCurrencyCode}${cursor}`
},
keepUnusedDataFor: 60, // 1 min
}),
Expand Down
65 changes: 50 additions & 15 deletions src/transactions/feed/TransactionFeedV2.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,17 @@ function getNumTransactionItems(sectionList: ReactTestInstance) {
return sectionList.props.data[0].data.length
}

const typedResponse = (response: Partial<TransactionFeedV2Response>) => JSON.stringify(response)
const typedResponse = (response: Partial<TransactionFeedV2Response>) => {
return JSON.stringify({
pageInfo: {
startCursor: '1',
endCursor: '2',
hasPreviousPage: false,
hasNextPage: true,
},
...response,
} satisfies Partial<TransactionFeedV2Response>)
}

function getInitialStore(storeOverrides: RecursivePartial<Omit<RootState, ApiReducersKeys>> = {}) {
const state: typeof storeOverrides = {
Expand Down Expand Up @@ -209,6 +219,7 @@ describe('TransactionFeedV2', () => {
.mockResponseOnce(
typedResponse({
transactions: [mockTransaction({ transactionHash: '0x02', timestamp: 20 })],
pageInfo: { startCursor: '2', endCursor: '', hasNextPage: false, hasPreviousPage: true },
})
)
.mockResponseOnce(typedResponse({ transactions: [] }))
Expand All @@ -221,12 +232,7 @@ describe('TransactionFeedV2', () => {
await waitFor(() => expect(tree.getByTestId('TransactionList/loading')).toBeVisible())
await waitFor(() => expect(tree.queryByTestId('TransactionList/loading')).toBeFalsy())

fireEvent(tree.getByTestId('TransactionList'), 'onEndReached')
await waitFor(() => expect(mockFetch).toBeCalled())
await waitFor(() => expect(tree.getByTestId('TransactionList/loading')).toBeVisible())
await waitFor(() => expect(tree.queryByTestId('TransactionList/loading')).toBeFalsy())

expect(mockFetch).toHaveBeenCalledTimes(3)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(2)
})

Expand Down Expand Up @@ -361,7 +367,12 @@ describe('TransactionFeedV2', () => {
],
})
)
.mockResponseOnce(typedResponse({ transactions: [] }))
.mockResponseOnce(
typedResponse({
transactions: [],
pageInfo: { hasNextPage: false, hasPreviousPage: true, startCursor: '2', endCursor: '' },
})
)

const tree = renderScreen()

Expand Down Expand Up @@ -402,13 +413,32 @@ describe('TransactionFeedV2', () => {
await waitFor(() => expect(Toast.showWithGravity).not.toBeCalled())
})

it('should vibrate when there is a pending transaction that turned into completed', async () => {
// eslint-disable-next-line jest/no-disabled-tests
it.skip('should vibrate when there is a pending transaction that turned into completed', async () => {
const standByTransactionHash = '0x02' as string
mockFetch.mockResponseOnce(typedResponse({ transactions: [] })).mockResponseOnce(
typedResponse({
transactions: [mockTransaction({ transactionHash: standByTransactionHash })],
})
)
mockFetch
.mockResponseOnce(
typedResponse({
transactions: [],
pageInfo: {
startCursor: '1',
endCursor: '',
hasPreviousPage: false,
hasNextPage: false,
},
})
)
.mockResponseOnce(
typedResponse({
transactions: [mockTransaction({ transactionHash: standByTransactionHash })],
pageInfo: {
startCursor: '1',
endCursor: '',
hasPreviousPage: false,
hasNextPage: false,
},
})
)

const { store, ...tree } = renderScreen({
transactions: {
Expand Down Expand Up @@ -552,7 +582,12 @@ describe('TransactionFeedV2', () => {
})

it('should pre-populate persisted first page of the feed', async () => {
mockFetch.mockResponse(typedResponse({ transactions: [] }))
mockFetch.mockResponse(
typedResponse({
transactions: [],
pageInfo: { startCursor: '1', endCursor: '', hasPreviousPage: false, hasNextPage: false },
})
)
const tree = renderScreen({ transactions: { feedFirstPage: [mockTransaction()] } })
expect(tree.getByTestId('TransactionList').props.data[0].data.length).toBe(1)
expect(mockFetch).not.toBeCalled()
Expand Down
118 changes: 51 additions & 67 deletions src/transactions/feed/TransactionFeedV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SwapEvents } from 'src/analytics/Events'
import { ErrorMessages } from 'src/app/ErrorMessages'
import SectionHead from 'src/components/SectionHead'
import GetStarted from 'src/home/GetStarted'
import { getLocalCurrencyCode } from 'src/localCurrency/selectors'
import { useDispatch, useSelector } from 'src/redux/hooks'
import { store } from 'src/redux/store'
import { getFeatureGate, getMultichainFeatures } from 'src/statsig'
Expand All @@ -18,7 +19,7 @@ import { vibrateSuccess } from 'src/styles/hapticFeedback'
import { Spacing } from 'src/styles/styles'
import { tokensByIdSelector } from 'src/tokens/selectors'
import { getSupportedNetworkIdsForSwap } from 'src/tokens/utils'
import { FIRST_PAGE_TIMESTAMP, useTransactionFeedV2Query } from 'src/transactions/api'
import { useTransactionFeedV2Query } from 'src/transactions/api'
import EarnFeedItem from 'src/transactions/feed/EarnFeedItem'
import NftFeedItem from 'src/transactions/feed/NftFeedItem'
import SwapFeedItem from 'src/transactions/feed/SwapFeedItem'
Expand Down Expand Up @@ -46,11 +47,13 @@ import Logger from 'src/utils/Logger'
import { walletAddressSelector } from 'src/web3/selectors'

type PaginatedData = {
[timestamp: number]: TokenTransaction[]
[FIRST_PAGE_CURSOR]: TokenTransaction[]
[endCursor: string]: TokenTransaction[]
}

const FIRST_PAGE_CURSOR = 'FIRST_PAGE'
const MIN_NUM_TRANSACTIONS_NECESSARY_FOR_SCROLL = 10
const POLL_INTERVAL_MS = 10000 // 10 sec
const POLL_INTERVAL_MS = 10_000 // 10 sec
const TAG = 'transactions/feed/TransactionFeedV2'

function getAllowedNetworksForTransfers() {
Expand Down Expand Up @@ -171,15 +174,15 @@ function mergeStandByTransactionsInRange({
}: {
transactions: TokenTransaction[]
standByTransactions: TokenTransaction[]
currentCursor?: number
currentCursor?: keyof PaginatedData
}): TokenTransaction[] {
/**
* If the data from the first page is empty - there's no successful transactions in the wallet.
* Maybe the user executed a single transaction, it failed and now it's in the standByTransactions.
* In this case we need to show whatever we've got in standByTransactions, until we have some
* paginated data to merge it with.
*/
const isFirstPage = currentCursor === FIRST_PAGE_TIMESTAMP
const isFirstPage = currentCursor === FIRST_PAGE_CURSOR
if (isFirstPage && transactions.length === 0) {
return standByTransactions
}
Expand Down Expand Up @@ -245,7 +248,7 @@ function useStandByTransactions() {

/**
* In order to properly detect if any of the existing pending transactions turned into completed
* we need to listen to the updates of stand by transactions. Whenever we detect that a confirmed
* we need to listen to the updates of stand by transactions. Whenever we detect that a completed
* transaction was in pending status on previous render - we consider it a newly completed transaction.
*/
function useNewlyCompletedTransactions(
Expand Down Expand Up @@ -313,85 +316,64 @@ export default function TransactionFeedV2() {
const { t } = useTranslation()
const dispatch = useDispatch()
const address = useSelector(walletAddressSelector)
const localCurrencyCode = useSelector(getLocalCurrencyCode)
const standByTransactions = useStandByTransactions()
const feedFirstPage = useSelector(feedFirstPageSelector)
const { hasNewlyCompletedTransactions, newlyCompletedCrossChainSwaps } =
useNewlyCompletedTransactions(standByTransactions)
const [endCursor, setEndCursor] = useState(FIRST_PAGE_TIMESTAMP)
const [endCursor, setEndCursor] = useState<string | undefined>(undefined)
const [paginatedData, setPaginatedData] = useState<PaginatedData>({
[FIRST_PAGE_TIMESTAMP]: feedFirstPage,
[FIRST_PAGE_CURSOR]: feedFirstPage,
})

/**
* This hook automatically fetches the pagination data when (and only when) the endCursor changes
* (we can safely ignore wallet address change as it's impossible to get changed on the fly).
* When components mounts, it fetches data for the first page using FIRST_PAGE_TIMESTAMP for endCursor
* (which is ignored in the request only for the first page as it's just an endCursor placeholder).
* Once the data is returned – we process it with "selectFromResult" for convenience and return the
* data. It gets further processed within the "updatePaginatedData" useEffect.
*
* Cursor for the next page is the timestamp of the last transaction of the last fetched page.
* This hook doesn't refetch data for none of the pages, neither does it do any polling. It's
* intention is to only fetch the next page whenever endCursor changes. Polling is handled by
* calling the same hook below.
*/
const { data, originalArgs, nextCursor, isFetching, error } = useTransactionFeedV2Query(
{ address: address!, endCursor },
{
skip: !address,
refetchOnMountOrArgChange: true,
selectFromResult: (result) => ({
...result,
nextCursor: result.data?.transactions.at(-1)?.timestamp,
}),
}
const { data, isFetching, error } = useTransactionFeedV2Query(
{ address: address!, endCursor, localCurrencyCode },
{ skip: !address, refetchOnMountOrArgChange: true }
)

/**
* This is the same hook as above and it only triggers the fetch request. It's intention is to
* only poll the data for the first page of the feed, using the FIRST_PAGE_TIMESTAMP endCursor.
* Thanks to how RTK-Query stores the fetched data, we know that using "useTransactionFeedV2Query"
* with the same arguments in multiple places will always point to the same data. This means, that
* we can trigger fetch request here and once data arrives - the same hook above will re-run the
* "selectFromResult" function for FIRST_PAGE_TIMESTAMP endCursor and will trigger the data update
* flow for the first page.
* This is the same hook as above and it only polls the first page of the feed. Thanks to how
* RTK-Query stores the fetched data, we know that using "useTransactionFeedV2Query" with the
* same arguments in multiple places will always point to the same data. This means that we can
* trigger fetch request here and once data arrives - the same hook above will also get the same data.
*/
useTransactionFeedV2Query(
{ address: address!, endCursor: FIRST_PAGE_TIMESTAMP },
{ address: address!, localCurrencyCode, endCursor: undefined },
{ skip: !address, pollingInterval: POLL_INTERVAL_MS }
)

/**
* There are only 2 scenarios when we actually update the paginated data:
*
* 1. Always update the first page. First page will be polled every "POLL_INTERVAL"
* milliseconds. Whenever new data arrives - replace the existing first page data
* with the new data as it might contain some updated information about the transactions
* that are already present or new transactions. The first page should not contain an
* empty array, unless wallet doesn't have any transactions at all.
*
* 2. Data for every page after the first page is only set once. Considering the big enough
* page size (currently 100 transactions per page) all the pending transactions are supposed
* to arrive in the first page so everything after the first page can be considered confirmed
* (completed/failed). For this reason, there's no point in updating the data as its very unlikely to update.
*/
useEffect(
function updatePaginatedData() {
if (isFetching) return

const currentCursor = originalArgs?.endCursor // timestamp from the last transaction from the previous page.
const transactions = data?.transactions || []

/**
* There are only 2 scenarios when we actually update the paginated data:
*
* 1. Always update the first page. First page will be polled every "POLL_INTERVAL"
* milliseconds. Whenever new data arrives - replace the existing first page data
* with the new data as it might contain some updated information about the transactions
* that are already present. The first page should not contain an empty array, unless
* wallet doesn't have any transactions at all.
*
* 2. Data for every page after the first page is only set once. All the pending transactions
* are supposed to arrive in the first page so everything after the first page can be
* considered confirmed (completed/failed). For this reason, there's no point in updating
* the data as its very unlikely to update.
*/
if (isFetching || !data) return

const currentCursor = data?.pageInfo.hasPreviousPage
? data.pageInfo.startCursor
: FIRST_PAGE_CURSOR

setPaginatedData((prev) => {
const isFirstPage = currentCursor === FIRST_PAGE_TIMESTAMP
const isFirstPage = currentCursor === FIRST_PAGE_CURSOR
const pageDataIsAbsent =
currentCursor !== FIRST_PAGE_TIMESTAMP && // not the first page
currentCursor !== FIRST_PAGE_CURSOR && // not the first page
currentCursor !== undefined && // it is a page after the first
prev[currentCursor] === undefined // data for this page wasn't stored yet

if (isFirstPage || pageDataIsAbsent) {
const mergedTransactions = mergeStandByTransactionsInRange({
transactions,
transactions: data?.transactions || [],
standByTransactions: standByTransactions.confirmed,
currentCursor,
})
Expand All @@ -402,7 +384,7 @@ export default function TransactionFeedV2() {
return prev
})
},
[isFetching, data?.transactions, originalArgs?.endCursor, standByTransactions.confirmed]
[isFetching, data, standByTransactions.confirmed]
)

useEffect(
Expand All @@ -417,12 +399,15 @@ export default function TransactionFeedV2() {

useEffect(
function vibrateForNewlyCompletedTransactions() {
const isFirstPage = originalArgs?.endCursor === FIRST_PAGE_TIMESTAMP
const isFirstPage = data?.pageInfo.hasPreviousPage
? data.pageInfo.startCursor
: FIRST_PAGE_CURSOR

if (isFirstPage && hasNewlyCompletedTransactions) {
vibrateSuccess()
}
},
[hasNewlyCompletedTransactions, originalArgs?.endCursor]
[hasNewlyCompletedTransactions, data?.pageInfo]
)

useEffect(
Expand Down Expand Up @@ -456,10 +441,9 @@ export default function TransactionFeedV2() {
)
}

// This logic will change once the real api is connected
function fetchMoreTransactions() {
if (nextCursor) {
setEndCursor(nextCursor)
if (data?.pageInfo.hasNextPage && data?.pageInfo.endCursor) {
setEndCursor(data.pageInfo.endCursor)
return
}

Expand Down
Loading

0 comments on commit a128dbc

Please sign in to comment.