From 03bcdec4e7451da54082dff36528518fd291298d Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Sun, 6 Oct 2024 23:07:57 +0300 Subject: [PATCH 01/10] add initial implementation from Transaction Feed V2 --- src/transactions/api.ts | 3 + src/transactions/feed/TransactionFeedV2.tsx | 193 ++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 src/transactions/feed/TransactionFeedV2.tsx diff --git a/src/transactions/api.ts b/src/transactions/api.ts index 7bd0dca99c..2d147abb42 100644 --- a/src/transactions/api.ts +++ b/src/transactions/api.ts @@ -29,3 +29,6 @@ export const transactionFeedV2Api = createApi({ } }, }) + +const { useTransactionFeedV2Query } = transactionFeedV2Api +export { useTransactionFeedV2Query } diff --git a/src/transactions/feed/TransactionFeedV2.tsx b/src/transactions/feed/TransactionFeedV2.tsx new file mode 100644 index 0000000000..cf3359998c --- /dev/null +++ b/src/transactions/feed/TransactionFeedV2.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { ActivityIndicator, SectionList, StyleSheet, View } from 'react-native' +import SectionHead from 'src/components/SectionHead' +import GetStarted from 'src/home/GetStarted' +import { useSelector } from 'src/redux/hooks' +import { getFeatureGate, getMultichainFeatures } from 'src/statsig' +import { StatsigFeatureGates } from 'src/statsig/types' +import colors from 'src/styles/colors' +import { Spacing } from 'src/styles/styles' +import NoActivity from 'src/transactions/NoActivity' +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' +import TokenApprovalFeedItem from 'src/transactions/feed/TokenApprovalFeedItem' +import TransferFeedItem from 'src/transactions/feed/TransferFeedItem' +import { pendingStandbyTransactionsSelector } from 'src/transactions/reducer' +import { NetworkId, type TokenTransaction } from 'src/transactions/types' +import { groupFeedItemsInSections } from 'src/transactions/utils' +import { walletAddressSelector } from 'src/web3/selectors' + +type PaginatedData = { + [timestamp: number]: TokenTransaction[] +} + +// Query poll interval +const POLL_INTERVAL = 10000 // 10 secs +const FIRST_PAGE_TIMESTAMP = 0 + +function getAllowedNetworkIdsForTransfers() { + return getMultichainFeatures().showTransfers.join(',').split(',') as NetworkId[] +} + +function renderItem({ item: tx }: { item: TokenTransaction; index: number }) { + switch (tx.__typename) { + case 'TokenExchangeV3': + case 'CrossChainTokenExchange': + return + case 'TokenTransferV3': + return + case 'NftTransferV3': + return + case 'TokenApproval': + return + case 'EarnDeposit': + case 'EarnSwapDeposit': + case 'EarnWithdraw': + case 'EarnClaimReward': + return + } +} + +export default function TransactionFeedV2() { + const address = useSelector(walletAddressSelector) + const pendingStandByTransactions = useSelector(pendingStandbyTransactionsSelector) + const [endCursor, setEndCursor] = useState(0) + const [paginatedData, setPaginatedData] = useState({}) + const { pageData, isFetching, error } = useTransactionFeedV2Query( + { address: address!, endCursor }, + { + skip: !address, + refetchOnMountOrArgChange: true, + selectFromResult: (result) => { + return { + ...result, + // eslint-disable-next-line react-hooks/rules-of-hooks + pageData: useMemo( + () => ({ + currentCursor: result.originalArgs?.endCursor, // timestamp from the last transaction from the previous page. + nextCursor: result.data?.transactions.at(-1)?.timestamp, // timestamp from the last transaction from the current page + transactions: result.data?.transactions || [], + }), + [result] + ), + } + }, + } + ) + + // Poll the first page + useTransactionFeedV2Query( + { address: address!, endCursor: FIRST_PAGE_TIMESTAMP }, + { skip: !address, pollingInterval: POLL_INTERVAL } + ) + + useEffect(() => { + if (isFetching) return + + setPaginatedData((prev) => { + /** + * We are going to poll only the first page so if we already fetched other pages - + * just leave them as is. All new transactions are only gonna be added to the first page (at the top). + */ + if (pageData.currentCursor === FIRST_PAGE_TIMESTAMP && prev[pageData.currentCursor]) { + return prev + } + + /** + * undefined currentCursor means that we've received empty transactions which means this is + * the last page. + */ + if (pageData.currentCursor === undefined) { + return prev + } + + return { ...prev, [pageData.currentCursor]: pageData.transactions } + }) + }, [isFetching, pageData]) + + const pendingTransactions = useMemo(() => { + const allowedNetworks = getAllowedNetworkIdsForTransfers() + return pendingStandByTransactions.filter((tx) => { + return allowedNetworks.includes(tx.networkId) + }) + }, [pendingStandByTransactions]) + + const confirmedTransactions = useMemo(() => { + return Object.values(paginatedData) + .flat() + .reduce( + (acc, tx) => { + if (!acc.used[tx.transactionHash]) acc.list.push(tx) + return acc + }, + { + list: [] as TokenTransaction[], + used: {} as { [hash: string]: true }, + } + ) + .list.sort((a, b) => { + const diff = b.timestamp - a.timestamp + if (diff === 0) { + // if the timestamps are the same, most likely one of the transactions + // is an approval. on the feed we want to show the approval first. + return a.__typename === 'TokenApproval' ? 1 : b.__typename === 'TokenApproval' ? -1 : 0 + } + return diff + }) + }, [paginatedData]) + + const sections = useMemo(() => { + const noTransactions = pendingTransactions.length === 0 && confirmedTransactions.length === 0 + if (noTransactions) return [] + return groupFeedItemsInSections(pendingTransactions, confirmedTransactions) + }, [paginatedData]) + + if (!sections.length) { + return getFeatureGate(StatsigFeatureGates.SHOW_GET_STARTED) ? ( + + ) : ( + + ) + } + + function fetchMoreTransactions() { + if (pageData.nextCursor) { + setEndCursor(pageData.nextCursor) + } + } + + return ( + <> + } + sections={sections} + keyExtractor={(item) => `${item.transactionHash}-${item.timestamp.toString()}`} + keyboardShouldPersistTaps="always" + testID="TransactionList" + onEndReached={fetchMoreTransactions} + initialNumToRender={20} + /> + {isFetching && ( + + + + )} + + ) +} + +const styles = StyleSheet.create({ + loadingIcon: { + marginVertical: Spacing.Thick24, + height: 108, + width: 108, + }, + centerContainer: { + alignItems: 'center', + justifyContent: 'center', + flex: 1, + }, +}) From 4695304947600fdb8c70d1fd8e1d86c28996c71e Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Mon, 7 Oct 2024 15:52:28 +0300 Subject: [PATCH 02/10] Add tests for TransactionFeedV2 --- src/transactions/api.ts | 2 +- src/transactions/apiTestHelpers.tsx | 45 ++++ .../feed/TransactionFeedV2.test.tsx | 228 ++++++++++++++++++ src/transactions/feed/TransactionFeedV2.tsx | 2 +- 4 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 src/transactions/apiTestHelpers.tsx create mode 100644 src/transactions/feed/TransactionFeedV2.test.tsx diff --git a/src/transactions/api.ts b/src/transactions/api.ts index 2d147abb42..611a6191d0 100644 --- a/src/transactions/api.ts +++ b/src/transactions/api.ts @@ -2,7 +2,7 @@ import { createApi } from '@reduxjs/toolkit/query/react' import { baseQuery, isRehydrateAction } from 'src/redux/api' import type { TokenTransaction } from 'src/transactions/types' -type TransactionFeedV2Response = { +export type TransactionFeedV2Response = { transactions: TokenTransaction[] pageInfo: { hasNextPage: boolean diff --git a/src/transactions/apiTestHelpers.tsx b/src/transactions/apiTestHelpers.tsx new file mode 100644 index 0000000000..4ec9fa545d --- /dev/null +++ b/src/transactions/apiTestHelpers.tsx @@ -0,0 +1,45 @@ +import type { EnhancedStore, Middleware, Reducer, UnknownAction } from '@reduxjs/toolkit' +import { combineReducers, configureStore } from '@reduxjs/toolkit' +import { ApiReducersKeys } from 'src/redux/apiReducersList' +import { RootState } from 'src/redux/reducers' +import { RecursivePartial } from 'test/utils' + +// https://medium.com/@johnmcdowell0801/testing-rtk-query-with-jest-cdfa5aaf3dc1 +export function setupApiStore< + A extends { + reducer: Reducer + reducerPath: string + middleware: Middleware + util: { resetApiState(): any } + }, + Preloaded extends RecursivePartial>, + R extends Record> = Record, +>(api: A, preloadedState: Preloaded, extraReducers?: R) { + const getStore = () => + configureStore({ + preloadedState, + reducer: combineReducers({ + [api.reducerPath]: api.reducer, + ...extraReducers, + }), + middleware: (gdm) => + gdm({ serializableCheck: false, immutableCheck: false }).concat(api.middleware), + }) + + type StoreType = EnhancedStore< + { + api: ReturnType + } & { + [K in keyof R]: ReturnType + }, + UnknownAction, + ReturnType extends EnhancedStore ? M : never + > + + const initialStore = getStore() as StoreType + const refObj = { api, store: initialStore } + const store = getStore() as StoreType + refObj.store = store + + return refObj +} diff --git a/src/transactions/feed/TransactionFeedV2.test.tsx b/src/transactions/feed/TransactionFeedV2.test.tsx new file mode 100644 index 0000000000..1819fa7a3f --- /dev/null +++ b/src/transactions/feed/TransactionFeedV2.test.tsx @@ -0,0 +1,228 @@ +import { fireEvent, render, waitFor, within } from '@testing-library/react-native' +import { FetchMock } from 'jest-fetch-mock/types' +import React from 'react' +import { Provider } from 'react-redux' +import { ReactTestInstance } from 'react-test-renderer' +import { RootState } from 'src/redux/reducers' +import { reducersList } from 'src/redux/reducersList' +import { getDynamicConfigParams, getFeatureGate, getMultichainFeatures } from 'src/statsig' +import TransactionFeedV2 from 'src/transactions/feed/TransactionFeedV2' +import { + NetworkId, + TokenTransaction, + TokenTransactionTypeV2, + TransactionStatus, +} from 'src/transactions/types' +import { mockCusdAddress, mockCusdTokenId } from 'test/values' + +import { ApiReducersKeys } from 'src/redux/apiReducersList' +import { transactionFeedV2Api, type TransactionFeedV2Response } from 'src/transactions/api' +import { setupApiStore } from 'src/transactions/apiTestHelpers' +import { RecursivePartial } from 'test/utils' + +jest.mock('src/statsig') + +const mockTransaction = (data?: Partial): TokenTransaction => { + return { + __typename: 'TokenTransferV3', + networkId: NetworkId['celo-alfajores'], + address: '0xd68360cce1f1ff696d898f58f03e0f1252f2ea33', + amount: { + tokenId: mockCusdTokenId, + tokenAddress: mockCusdAddress, + value: '0.1', + }, + block: '8648978', + fees: [], + metadata: {}, + timestamp: 1542306118, + transactionHash: '0x544367eaf2b01622dd1c7b75a6b19bf278d72127aecfb2e5106424c40c268e8b2', + type: TokenTransactionTypeV2.Received, + status: TransactionStatus.Complete, + ...(data as any), + } +} + +const mockFetch = fetch as FetchMock + +function getNumTransactionItems(sectionList: ReactTestInstance) { + // data[0] is the first section in the section list - all mock transactions + // are for the same section / date + return sectionList.props.data[0].data.length +} + +const typedResponse = (response: Partial) => JSON.stringify(response) + +function renderScreen(storeOverrides: RecursivePartial> = {}) { + const state: typeof storeOverrides = { + web3: { account: '0x00' }, + ...storeOverrides, + } + const storeRef = setupApiStore(transactionFeedV2Api, state, reducersList) + + const tree = render( + + + + ) + + return { + ...tree, + store: storeRef.store, + } +} + +beforeEach(() => { + mockFetch.resetMocks() + jest.clearAllMocks() + jest.mocked(getMultichainFeatures).mockReturnValue({ + showCico: [NetworkId['celo-alfajores']], + showBalances: [NetworkId['celo-alfajores']], + showTransfers: [NetworkId['celo-alfajores']], + showApprovalTxsInHomefeed: [NetworkId['celo-alfajores']], + }) + jest.mocked(getDynamicConfigParams).mockReturnValue({ + jumpstartContracts: { + ['celo-alfajores']: { contractAddress: '0x7bf3fefe9881127553d23a8cd225a2c2442c438c' }, + }, + }) +}) + +describe('TransactionFeed', () => { + mockFetch.mockResponse(typedResponse({})) + it('renders correctly when there is a response', async () => { + mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) + + const { store, ...tree } = renderScreen() + + await store.dispatch( + transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 0 }) + ) + + await waitFor(() => expect(tree.getByTestId('TransactionList').props.data.length).toBe(1)) + expect(tree.queryByTestId('NoActivity/loading')).toBeNull() + expect(tree.queryByTestId('NoActivity/error')).toBeNull() + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(tree.getAllByTestId('TransferFeedItem').length).toBe(1) + expect( + within(tree.getByTestId('TransferFeedItem')).getByTestId('TransferFeedItem/title') + ).toHaveTextContent( + 'feedItemReceivedTitle, {"displayName":"feedItemAddress, {\\"address\\":\\"0xd683...ea33\\"}"}' + ) + }) + + it("doesn't render transfers for tokens that we don't know about", async () => { + mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) + + const { store, ...tree } = renderScreen() + + await store.dispatch( + transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 0 }) + ) + + await waitFor(() => tree.getByTestId('TransactionList')) + const items = tree.getAllByTestId('TransferFeedItem/title') + expect(items.length).toBe(1) + }) + + it('renders the loading indicator while it loads', async () => { + const tree = renderScreen() + expect(tree.getByTestId('NoActivity/loading')).toBeDefined() + expect(tree.queryByTestId('NoActivity/error')).toBeNull() + expect(tree.queryByTestId('TransactionList')).toBeNull() + }) + + it("renders an error screen if there's no cache and the query fails", async () => { + mockFetch.mockReject(new Error('Test error')) + + const tree = renderScreen() + await waitFor(() => tree.getByTestId('NoActivity/error')) + expect(tree.queryByTestId('NoActivity/loading')).toBeNull() + expect(tree.queryByTestId('TransactionList')).toBeNull() + }) + + it('renders correct status for a complete transaction', async () => { + mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) + + const tree = renderScreen() + + await waitFor(() => tree.getByTestId('TransactionList')) + expect(tree.getByText('feedItemReceivedInfo, {"context":"noComment"}')).toBeTruthy() + }) + + it('renders correct status for a failed transaction', async () => { + mockFetch.mockResponse( + typedResponse({ transactions: [mockTransaction({ status: TransactionStatus.Failed })] }) + ) + + const tree = renderScreen() + + await waitFor(() => tree.getByTestId('TransactionList')) + expect(tree.getByText('feedItemFailedTransaction')).toBeTruthy() + }) + + it('tries to fetch 20 transactions, unless the end is reached', async () => { + mockFetch + .mockResponseOnce( + typedResponse({ + transactions: [mockTransaction({ transactionHash: '0x01', timestamp: 10 })], + }) + ) + .mockResponseOnce( + typedResponse({ + transactions: [mockTransaction({ transactionHash: '0x02', timestamp: 20 })], + }) + ) + .mockResponseOnce(typedResponse({ transactions: [] })) + + const { store, ...tree } = renderScreen() + + await waitFor(() => tree.getByTestId('TransactionList')) + + 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()) + + 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(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(2) + }) + + it('tries to fetch 10 transactions, and stores empty pages', async () => { + mockFetch + .mockResponseOnce(typedResponse({ transactions: [mockTransaction()] })) + .mockResponseOnce(typedResponse({ transactions: [] })) + + const { store, ...tree } = renderScreen() + + await store.dispatch( + transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 0 }) + ) + await store.dispatch( + transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 123 }) + ) + + await waitFor(() => tree.getByTestId('TransactionList')) + + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)) + expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(1) + }) + + it('renders GetStarted if SHOW_GET_STARTED is enabled and transaction feed is empty', async () => { + jest.mocked(getFeatureGate).mockReturnValue(true) + const tree = renderScreen() + expect(tree.getByTestId('GetStarted')).toBeDefined() + }) + + it('renders NoActivity by default if transaction feed is empty', async () => { + jest.mocked(getFeatureGate).mockReturnValue(false) + const tree = renderScreen() + expect(tree.getByTestId('NoActivity/loading')).toBeDefined() + expect(tree.getByText('noTransactionActivity')).toBeTruthy() + }) +}) diff --git a/src/transactions/feed/TransactionFeedV2.tsx b/src/transactions/feed/TransactionFeedV2.tsx index cf3359998c..3757c722ee 100644 --- a/src/transactions/feed/TransactionFeedV2.tsx +++ b/src/transactions/feed/TransactionFeedV2.tsx @@ -171,7 +171,7 @@ export default function TransactionFeedV2() { initialNumToRender={20} /> {isFetching && ( - + )} From 6adf3edc053038af04744afe940fa994c6d895c4 Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Mon, 7 Oct 2024 15:56:30 +0300 Subject: [PATCH 03/10] remove unnecessary calls from tests --- src/transactions/feed/TransactionFeedV2.test.tsx | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/transactions/feed/TransactionFeedV2.test.tsx b/src/transactions/feed/TransactionFeedV2.test.tsx index 1819fa7a3f..06fa26eda0 100644 --- a/src/transactions/feed/TransactionFeedV2.test.tsx +++ b/src/transactions/feed/TransactionFeedV2.test.tsx @@ -92,13 +92,8 @@ describe('TransactionFeed', () => { mockFetch.mockResponse(typedResponse({})) it('renders correctly when there is a response', async () => { mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) - const { store, ...tree } = renderScreen() - await store.dispatch( - transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 0 }) - ) - await waitFor(() => expect(tree.getByTestId('TransactionList').props.data.length).toBe(1)) expect(tree.queryByTestId('NoActivity/loading')).toBeNull() expect(tree.queryByTestId('NoActivity/error')).toBeNull() @@ -113,13 +108,8 @@ describe('TransactionFeed', () => { it("doesn't render transfers for tokens that we don't know about", async () => { mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) - const { store, ...tree } = renderScreen() - await store.dispatch( - transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 0 }) - ) - await waitFor(() => tree.getByTestId('TransactionList')) const items = tree.getAllByTestId('TransferFeedItem/title') expect(items.length).toBe(1) @@ -134,8 +124,8 @@ describe('TransactionFeed', () => { it("renders an error screen if there's no cache and the query fails", async () => { mockFetch.mockReject(new Error('Test error')) - const tree = renderScreen() + await waitFor(() => tree.getByTestId('NoActivity/error')) expect(tree.queryByTestId('NoActivity/loading')).toBeNull() expect(tree.queryByTestId('TransactionList')).toBeNull() @@ -143,7 +133,6 @@ describe('TransactionFeed', () => { it('renders correct status for a complete transaction', async () => { mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) - const tree = renderScreen() await waitFor(() => tree.getByTestId('TransactionList')) From 7206027c96fd8fe4e16f1d978479d08b6607bdb8 Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Tue, 8 Oct 2024 14:05:28 +0300 Subject: [PATCH 04/10] remove redundant file --- src/transactions/apiTestHelpers.tsx | 45 ----------------------------- 1 file changed, 45 deletions(-) delete mode 100644 src/transactions/apiTestHelpers.tsx diff --git a/src/transactions/apiTestHelpers.tsx b/src/transactions/apiTestHelpers.tsx deleted file mode 100644 index 4ec9fa545d..0000000000 --- a/src/transactions/apiTestHelpers.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { EnhancedStore, Middleware, Reducer, UnknownAction } from '@reduxjs/toolkit' -import { combineReducers, configureStore } from '@reduxjs/toolkit' -import { ApiReducersKeys } from 'src/redux/apiReducersList' -import { RootState } from 'src/redux/reducers' -import { RecursivePartial } from 'test/utils' - -// https://medium.com/@johnmcdowell0801/testing-rtk-query-with-jest-cdfa5aaf3dc1 -export function setupApiStore< - A extends { - reducer: Reducer - reducerPath: string - middleware: Middleware - util: { resetApiState(): any } - }, - Preloaded extends RecursivePartial>, - R extends Record> = Record, ->(api: A, preloadedState: Preloaded, extraReducers?: R) { - const getStore = () => - configureStore({ - preloadedState, - reducer: combineReducers({ - [api.reducerPath]: api.reducer, - ...extraReducers, - }), - middleware: (gdm) => - gdm({ serializableCheck: false, immutableCheck: false }).concat(api.middleware), - }) - - type StoreType = EnhancedStore< - { - api: ReturnType - } & { - [K in keyof R]: ReturnType - }, - UnknownAction, - ReturnType extends EnhancedStore ? M : never - > - - const initialStore = getStore() as StoreType - const refObj = { api, store: initialStore } - const store = getStore() as StoreType - refObj.store = store - - return refObj -} From 78e7239e36613a4c1ebcef212a754310ef2fbe3c Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Tue, 8 Oct 2024 18:21:04 +0300 Subject: [PATCH 05/10] fix wrong useMemo dependencies for sections --- src/transactions/feed/TransactionFeedV2.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transactions/feed/TransactionFeedV2.tsx b/src/transactions/feed/TransactionFeedV2.tsx index 3757c722ee..a21b15f046 100644 --- a/src/transactions/feed/TransactionFeedV2.tsx +++ b/src/transactions/feed/TransactionFeedV2.tsx @@ -142,7 +142,7 @@ export default function TransactionFeedV2() { const noTransactions = pendingTransactions.length === 0 && confirmedTransactions.length === 0 if (noTransactions) return [] return groupFeedItemsInSections(pendingTransactions, confirmedTransactions) - }, [paginatedData]) + }, [pendingTransactions, confirmedTransactions]) if (!sections.length) { return getFeatureGate(StatsigFeatureGates.SHOW_GET_STARTED) ? ( From 97bbe921d64410f6dedb52b19a8b32399a9851bd Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Wed, 9 Oct 2024 11:24:31 +0300 Subject: [PATCH 06/10] revert knip fixes --- knip.ts | 1 - src/transactions/api.ts | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/knip.ts b/knip.ts index 1818558229..fb3ef278f9 100644 --- a/knip.ts +++ b/knip.ts @@ -37,7 +37,6 @@ const config: KnipConfig = { 'src/utils/inputValidation.ts', 'src/utils/country.json', 'src/redux/reducersForSchemaGeneration.ts', - 'src/transactions/apiTestHelpers.ts', ], } diff --git a/src/transactions/api.ts b/src/transactions/api.ts index 5e9e8d8d33..01e944d623 100644 --- a/src/transactions/api.ts +++ b/src/transactions/api.ts @@ -2,7 +2,7 @@ import { createApi } from '@reduxjs/toolkit/query/react' import { baseQuery } from 'src/redux/api' import type { TokenTransaction } from 'src/transactions/types' -type TransactionFeedV2Response = { +export type TransactionFeedV2Response = { transactions: TokenTransaction[] pageInfo: { hasNextPage: boolean @@ -21,3 +21,5 @@ export const transactionFeedV2Api = createApi({ }), }), }) + +export const { useTransactionFeedV2Query } = transactionFeedV2Api From f5ab01e9a851c9f1fb4439440488c092d58c7e05 Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Wed, 9 Oct 2024 12:44:45 +0300 Subject: [PATCH 07/10] refactor .reduce usage for readability --- src/transactions/feed/TransactionFeedV2.tsx | 45 +++++++++++---------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/src/transactions/feed/TransactionFeedV2.tsx b/src/transactions/feed/TransactionFeedV2.tsx index a21b15f046..e6306a7d2e 100644 --- a/src/transactions/feed/TransactionFeedV2.tsx +++ b/src/transactions/feed/TransactionFeedV2.tsx @@ -114,28 +114,31 @@ export default function TransactionFeedV2() { }) }, [pendingStandByTransactions]) + /** + * This function uses the same deduplication approach as "deduplicateTransactions" + * function from queryHelper.ts but only for a single flattened array instead of + * two separate arrays. + */ const confirmedTransactions = useMemo(() => { - return Object.values(paginatedData) - .flat() - .reduce( - (acc, tx) => { - if (!acc.used[tx.transactionHash]) acc.list.push(tx) - return acc - }, - { - list: [] as TokenTransaction[], - used: {} as { [hash: string]: true }, - } - ) - .list.sort((a, b) => { - const diff = b.timestamp - a.timestamp - if (diff === 0) { - // if the timestamps are the same, most likely one of the transactions - // is an approval. on the feed we want to show the approval first. - return a.__typename === 'TokenApproval' ? 1 : b.__typename === 'TokenApproval' ? -1 : 0 - } - return diff - }) + const flattenedPages = Object.values(paginatedData).flat() + const transactionMap: { [txHash: string]: TokenTransaction } = {} + + for (const tx of flattenedPages) { + transactionMap[tx.transactionHash] = tx + } + + const deduplicatedTransactions = Object.values(transactionMap) + const sortedTransactions = deduplicatedTransactions.sort((a, b) => { + const diff = b.timestamp - a.timestamp + if (diff === 0) { + // if the timestamps are the same, most likely one of the transactions + // is an approval. on the feed we want to show the approval first. + return a.__typename === 'TokenApproval' ? 1 : b.__typename === 'TokenApproval' ? -1 : 0 + } + return diff + }) + + return sortedTransactions }, [paginatedData]) const sections = useMemo(() => { From 7e747f3793db1345f150882933ea80762ac030fe Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Wed, 9 Oct 2024 18:28:28 +0300 Subject: [PATCH 08/10] remove unnecessary test --- .../feed/TransactionFeedV2.test.tsx | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/src/transactions/feed/TransactionFeedV2.test.tsx b/src/transactions/feed/TransactionFeedV2.test.tsx index 06fa26eda0..01cc00f52f 100644 --- a/src/transactions/feed/TransactionFeedV2.test.tsx +++ b/src/transactions/feed/TransactionFeedV2.test.tsx @@ -88,7 +88,7 @@ beforeEach(() => { }) }) -describe('TransactionFeed', () => { +describe('TransactionFeedV2', () => { mockFetch.mockResponse(typedResponse({})) it('renders correctly when there is a response', async () => { mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) @@ -182,26 +182,6 @@ describe('TransactionFeed', () => { expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(2) }) - it('tries to fetch 10 transactions, and stores empty pages', async () => { - mockFetch - .mockResponseOnce(typedResponse({ transactions: [mockTransaction()] })) - .mockResponseOnce(typedResponse({ transactions: [] })) - - const { store, ...tree } = renderScreen() - - await store.dispatch( - transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 0 }) - ) - await store.dispatch( - transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 123 }) - ) - - await waitFor(() => tree.getByTestId('TransactionList')) - - await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)) - expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(1) - }) - it('renders GetStarted if SHOW_GET_STARTED is enabled and transaction feed is empty', async () => { jest.mocked(getFeatureGate).mockReturnValue(true) const tree = renderScreen() From bb11791a74c748925964e5841e23e3047fc827ce Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Wed, 9 Oct 2024 18:29:16 +0300 Subject: [PATCH 09/10] fix polling data was not updating, fix as per review comments --- src/transactions/feed/TransactionFeedV2.tsx | 100 ++++++++++++-------- src/transactions/types.ts | 2 + 2 files changed, 61 insertions(+), 41 deletions(-) diff --git a/src/transactions/feed/TransactionFeedV2.tsx b/src/transactions/feed/TransactionFeedV2.tsx index e6306a7d2e..7eb048fdca 100644 --- a/src/transactions/feed/TransactionFeedV2.tsx +++ b/src/transactions/feed/TransactionFeedV2.tsx @@ -15,7 +15,16 @@ import SwapFeedItem from 'src/transactions/feed/SwapFeedItem' import TokenApprovalFeedItem from 'src/transactions/feed/TokenApprovalFeedItem' import TransferFeedItem from 'src/transactions/feed/TransferFeedItem' import { pendingStandbyTransactionsSelector } from 'src/transactions/reducer' -import { NetworkId, type TokenTransaction } from 'src/transactions/types' +import { + type NetworkId, + type NftTransfer, + type TokenApproval, + type TokenEarn, + type TokenExchange, + type TokenTransaction, + TokenTransactionTypeV2, + type TokenTransfer, +} from 'src/transactions/types' import { groupFeedItemsInSections } from 'src/transactions/utils' import { walletAddressSelector } from 'src/web3/selectors' @@ -24,7 +33,7 @@ type PaginatedData = { } // Query poll interval -const POLL_INTERVAL = 10000 // 10 secs +const POLL_INTERVAL_MS = 10000 // 10 sec const FIRST_PAGE_TIMESTAMP = 0 function getAllowedNetworkIdsForTransfers() { @@ -32,21 +41,24 @@ function getAllowedNetworkIdsForTransfers() { } function renderItem({ item: tx }: { item: TokenTransaction; index: number }) { - switch (tx.__typename) { - case 'TokenExchangeV3': - case 'CrossChainTokenExchange': - return - case 'TokenTransferV3': - return - case 'NftTransferV3': - return - case 'TokenApproval': - return - case 'EarnDeposit': - case 'EarnSwapDeposit': - case 'EarnWithdraw': - case 'EarnClaimReward': - return + switch (tx.type) { + case TokenTransactionTypeV2.Exchange: + case TokenTransactionTypeV2.SwapTransaction: + case TokenTransactionTypeV2.CrossChainSwapTransaction: + return + case TokenTransactionTypeV2.Sent: + case TokenTransactionTypeV2.Received: + return + case TokenTransactionTypeV2.NftSent: + case TokenTransactionTypeV2.NftReceived: + return + case TokenTransactionTypeV2.Approval: + return + case TokenTransactionTypeV2.EarnDeposit: + case TokenTransactionTypeV2.EarnSwapDeposit: + case TokenTransactionTypeV2.EarnWithdraw: + case TokenTransactionTypeV2.EarnClaimReward: + return } } @@ -54,7 +66,7 @@ export default function TransactionFeedV2() { const address = useSelector(walletAddressSelector) const pendingStandByTransactions = useSelector(pendingStandbyTransactionsSelector) const [endCursor, setEndCursor] = useState(0) - const [paginatedData, setPaginatedData] = useState({}) + const [paginatedData, setPaginatedData] = useState({ [FIRST_PAGE_TIMESTAMP]: [] }) const { pageData, isFetching, error } = useTransactionFeedV2Query( { address: address!, endCursor }, { @@ -80,32 +92,34 @@ export default function TransactionFeedV2() { // Poll the first page useTransactionFeedV2Query( { address: address!, endCursor: FIRST_PAGE_TIMESTAMP }, - { skip: !address, pollingInterval: POLL_INTERVAL } + { skip: !address, pollingInterval: POLL_INTERVAL_MS } ) - useEffect(() => { - if (isFetching) return - - setPaginatedData((prev) => { - /** - * We are going to poll only the first page so if we already fetched other pages - - * just leave them as is. All new transactions are only gonna be added to the first page (at the top). - */ - if (pageData.currentCursor === FIRST_PAGE_TIMESTAMP && prev[pageData.currentCursor]) { - return prev - } + useEffect( + function updatePaginatedData() { + if (isFetching) return + + setPaginatedData((prev) => { + /** + * Only update pagination data in the following scenarios: + * - if it's a first page (which is polling every POLL_INTERVAL) + * - if it's a page, that wasn't fetched yet + */ + const isFirstPage = pageData.currentCursor === FIRST_PAGE_TIMESTAMP + const pageDataIsAbsent = + pageData.currentCursor !== FIRST_PAGE_TIMESTAMP && // not the first page + pageData.currentCursor !== undefined && // it is SOME page + prev[pageData.currentCursor] === undefined // data for this page wasn't fetched yet + + if (isFirstPage || pageDataIsAbsent) { + return { ...prev, [pageData.currentCursor!]: pageData.transactions } + } - /** - * undefined currentCursor means that we've received empty transactions which means this is - * the last page. - */ - if (pageData.currentCursor === undefined) { return prev - } - - return { ...prev, [pageData.currentCursor]: pageData.transactions } - }) - }, [isFetching, pageData]) + }) + }, + [isFetching, pageData] + ) const pendingTransactions = useMemo(() => { const allowedNetworks = getAllowedNetworkIdsForTransfers() @@ -133,7 +147,11 @@ export default function TransactionFeedV2() { if (diff === 0) { // if the timestamps are the same, most likely one of the transactions // is an approval. on the feed we want to show the approval first. - return a.__typename === 'TokenApproval' ? 1 : b.__typename === 'TokenApproval' ? -1 : 0 + return a.type === TokenTransactionTypeV2.Approval + ? 1 + : b.type === TokenTransactionTypeV2.Approval + ? -1 + : 0 } return diff }) diff --git a/src/transactions/types.ts b/src/transactions/types.ts index e75ea8debb..f25f8a5856 100644 --- a/src/transactions/types.ts +++ b/src/transactions/types.ts @@ -285,3 +285,5 @@ export interface TrackedTx { txHash: Hash | undefined txReceipt: TransactionReceipt | undefined } + +export type TokenEarn = EarnWithdraw | EarnDeposit | EarnClaimReward | EarnSwapDeposit From 1226b038bda5b0eb5e9fc7d41e6d27f6bcdc490e Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Fri, 11 Oct 2024 10:12:29 +0300 Subject: [PATCH 10/10] feat(feed): Merge stand by transactions into the new feed v2 (#6136) ### Description 3rd PR for RET-1207. It merges existing confirmed (failed/completed) transaction from the `standByTransactions` reducer into each paginated data. ### Test plan WIP ### 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) --- src/transactions/NoActivity.tsx | 6 +- src/transactions/api.ts | 6 +- src/transactions/apiTestHelpers.ts | 12 +- .../feed/TransactionFeedV2.test.tsx | 136 ++++++++- src/transactions/feed/TransactionFeedV2.tsx | 261 +++++++++++++----- src/transactions/reducer.ts | 3 +- src/transactions/utils.ts | 11 +- 7 files changed, 342 insertions(+), 93 deletions(-) diff --git a/src/transactions/NoActivity.tsx b/src/transactions/NoActivity.tsx index 40431e5a60..02cf3e8433 100644 --- a/src/transactions/NoActivity.tsx +++ b/src/transactions/NoActivity.tsx @@ -1,12 +1,14 @@ +import { type SerializedError } from '@reduxjs/toolkit' +import { type FetchBaseQueryError } from '@reduxjs/toolkit/query' import * as React from 'react' -import { WithTranslation } from 'react-i18next' +import { type WithTranslation } from 'react-i18next' import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' import { withTranslation } from 'src/i18n' import colors from 'src/styles/colors' import { typeScale } from 'src/styles/fonts' interface OwnProps { loading: boolean - error: Error | undefined + error: Error | FetchBaseQueryError | SerializedError | undefined } type Props = OwnProps & WithTranslation diff --git a/src/transactions/api.ts b/src/transactions/api.ts index 01e944d623..2f78d2662c 100644 --- a/src/transactions/api.ts +++ b/src/transactions/api.ts @@ -17,7 +17,11 @@ export const transactionFeedV2Api = createApi({ TransactionFeedV2Response, { address: string; endCursor: number } >({ - query: ({ address, endCursor }) => `/wallet/${address}/transactions?endCursor=${endCursor}`, + query: ({ address, endCursor }) => { + const cursor = endCursor ? `?endCursor=${endCursor}` : '' + return `/wallet/${address}/transactions${cursor}` + }, + keepUnusedDataFor: 60, // 1 min }), }), }) diff --git a/src/transactions/apiTestHelpers.ts b/src/transactions/apiTestHelpers.ts index 6940ed7bfa..73f426ed16 100644 --- a/src/transactions/apiTestHelpers.ts +++ b/src/transactions/apiTestHelpers.ts @@ -5,8 +5,8 @@ import { RootState } from 'src/redux/reducers' import { getMockStoreData, RecursivePartial } from 'test/utils' /** - * This function is taken from the Redux team. It creates a testable store that is compatible with RTK-Query. - * It is slightly modified to also include the preloaded state. + * This function is taken from the Redux team. It creates a testable store that is compatible + * with RTK-Query. It is slightly modified to also include the preloaded state. * https://github.com/reduxjs/redux-toolkit/blob/e7540a5594b0d880037f2ff41a83a32c629d3117/packages/toolkit/src/tests/utils/helpers.tsx#L186 * * For more info on why this is needed and how it works - here's an article that answers some of the questions: @@ -17,7 +17,6 @@ export function setupApiStore< reducer: Reducer reducerPath: string middleware: Middleware - util: { resetApiState(): any } }, Preloaded extends RecursivePartial>, R extends Record> = Record, @@ -37,12 +36,9 @@ export function setupApiStore< }, }) + type Store = { api: ReturnType } & { [K in keyof R]: ReturnType } type StoreType = EnhancedStore< - { - api: ReturnType - } & { - [K in keyof R]: ReturnType - }, + Store, UnknownAction, ReturnType extends EnhancedStore ? M : never > diff --git a/src/transactions/feed/TransactionFeedV2.test.tsx b/src/transactions/feed/TransactionFeedV2.test.tsx index 01cc00f52f..04e746a655 100644 --- a/src/transactions/feed/TransactionFeedV2.test.tsx +++ b/src/transactions/feed/TransactionFeedV2.test.tsx @@ -22,7 +22,10 @@ import { RecursivePartial } from 'test/utils' jest.mock('src/statsig') -const mockTransaction = (data?: Partial): TokenTransaction => { +const STAND_BY_TRANSACTION_SUBTITLE_KEY = 'confirmingTransaction' +const mockFetch = fetch as FetchMock + +function mockTransaction(data?: Partial): TokenTransaction { return { __typename: 'TokenTransferV3', networkId: NetworkId['celo-alfajores'], @@ -43,8 +46,6 @@ const mockTransaction = (data?: Partial): TokenTransaction => } } -const mockFetch = fetch as FetchMock - function getNumTransactionItems(sectionList: ReactTestInstance) { // data[0] is the first section in the section list - all mock transactions // are for the same section / date @@ -89,7 +90,6 @@ beforeEach(() => { }) describe('TransactionFeedV2', () => { - mockFetch.mockResponse(typedResponse({})) it('renders correctly when there is a response', async () => { mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) const { store, ...tree } = renderScreen() @@ -106,6 +106,18 @@ describe('TransactionFeedV2', () => { ) }) + it('renders correctly with completed standby transactions', async () => { + mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) + + const tree = renderScreen({ + transactions: { + standbyTransactions: [mockTransaction({ transactionHash: '0x10' })], + }, + }) + + await waitFor(() => expect(tree.getAllByTestId('TransferFeedItem').length).toBe(2)) + }) + it("doesn't render transfers for tokens that we don't know about", async () => { mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) const { store, ...tree } = renderScreen() @@ -131,6 +143,30 @@ describe('TransactionFeedV2', () => { expect(tree.queryByTestId('TransactionList')).toBeNull() }) + it('renders correctly when there are confirmed transactions and stand by transactions', async () => { + mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) + + const tree = renderScreen({ + transactions: { + standbyTransactions: [ + mockTransaction({ transactionHash: '0x10', status: TransactionStatus.Pending }), + ], + }, + }) + + await waitFor(() => tree.getByTestId('TransactionList')) + + expect(tree.queryByTestId('NoActivity/loading')).toBeNull() + expect(tree.queryByTestId('NoActivity/error')).toBeNull() + + const subtitles = tree.queryAllByTestId('TransferFeedItem/subtitle') + + const pendingSubtitles = subtitles.filter((node) => + node.children.some((ch) => ch === STAND_BY_TRANSACTION_SUBTITLE_KEY) + ) + expect(pendingSubtitles.length).toBe(1) + }) + it('renders correct status for a complete transaction', async () => { mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) const tree = renderScreen() @@ -150,7 +186,7 @@ describe('TransactionFeedV2', () => { expect(tree.getByText('feedItemFailedTransaction')).toBeTruthy() }) - it('tries to fetch 20 transactions, unless the end is reached', async () => { + it('tries to fetch pages until the end is reached', async () => { mockFetch .mockResponseOnce( typedResponse({ @@ -182,6 +218,26 @@ describe('TransactionFeedV2', () => { expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(2) }) + it('tries to fetch a page of transactions, and stores empty pages', async () => { + mockFetch + .mockResponseOnce(typedResponse({ transactions: [mockTransaction()] })) + .mockResponseOnce(typedResponse({ transactions: [] })) + + const { store, ...tree } = renderScreen() + + await store.dispatch( + transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 0 }) + ) + await store.dispatch( + transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 123 }) + ) + + await waitFor(() => tree.getByTestId('TransactionList')) + + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)) + expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(1) + }) + it('renders GetStarted if SHOW_GET_STARTED is enabled and transaction feed is empty', async () => { jest.mocked(getFeatureGate).mockReturnValue(true) const tree = renderScreen() @@ -194,4 +250,74 @@ describe('TransactionFeedV2', () => { expect(tree.getByTestId('NoActivity/loading')).toBeDefined() expect(tree.getByText('noTransactionActivity')).toBeTruthy() }) + + it('useStandByTransactions properly splits pending/confirmed transactions', async () => { + mockFetch.mockResponse( + typedResponse({ + transactions: [ + mockTransaction({ transactionHash: '0x4000000' }), // confirmed + mockTransaction({ transactionHash: '0x3000000' }), // confirmed + mockTransaction({ transactionHash: '0x2000000' }), // confirmed + mockTransaction({ transactionHash: '0x1000000' }), // confirmed + ], + }) + ) + + const { store, ...tree } = renderScreen({ + transactions: { + standbyTransactions: [ + mockTransaction({ transactionHash: '0x10', status: TransactionStatus.Complete }), // confirmed + mockTransaction({ transactionHash: '0x20', status: TransactionStatus.Complete }), // confirmed + mockTransaction({ transactionHash: '0x30', status: TransactionStatus.Pending }), // pending + mockTransaction({ transactionHash: '0x40', status: TransactionStatus.Pending }), // pending + mockTransaction({ transactionHash: '0x50', status: TransactionStatus.Failed }), // confirmed + ], + }, + }) + + await store.dispatch( + transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 0 }) + ) + + await waitFor(() => { + expect(tree.getByTestId('TransactionList').props.data.length).toBe(2) + }) + + // from total of 9 transactions there should be 2 pending in a "recent" section + expect(tree.getByTestId('TransactionList').props.data[0].data.length).toBe(2) + // from total of 9 transactions there should be 7 confirmed in a "general" section + expect(tree.getByTestId('TransactionList').props.data[1].data.length).toBe(7) + }) + + it('merges only those stand by transactions that fit the timeline between min/max timestamps of the page', async () => { + mockFetch.mockResponse( + typedResponse({ + transactions: [ + mockTransaction({ transactionHash: '0x4000000', timestamp: 49 }), // max + mockTransaction({ transactionHash: '0x3000000', timestamp: 47 }), + mockTransaction({ transactionHash: '0x2000000', timestamp: 25 }), + mockTransaction({ transactionHash: '0x1000000', timestamp: 21 }), // min + ], + }) + ) + + const { store, ...tree } = renderScreen({ + transactions: { + standbyTransactions: [ + mockTransaction({ transactionHash: '0x10', timestamp: 10 }), // not in scope + mockTransaction({ transactionHash: '0x20', timestamp: 20 }), // not in scope + mockTransaction({ transactionHash: '0x30', timestamp: 30 }), // in scope + mockTransaction({ transactionHash: '0x40', timestamp: 40 }), // in scope + mockTransaction({ transactionHash: '0x50', timestamp: 50 }), // not in scope + ], + }, + }) + + await store.dispatch( + transactionFeedV2Api.endpoints.transactionFeedV2.initiate({ address: '0x00', endCursor: 0 }) + ) + + await waitFor(() => tree.getByTestId('TransactionList')) + expect(getNumTransactionItems(tree.getByTestId('TransactionList'))).toBe(6) + }) }) diff --git a/src/transactions/feed/TransactionFeedV2.tsx b/src/transactions/feed/TransactionFeedV2.tsx index 7eb048fdca..23f536be1d 100644 --- a/src/transactions/feed/TransactionFeedV2.tsx +++ b/src/transactions/feed/TransactionFeedV2.tsx @@ -14,18 +14,22 @@ import NftFeedItem from 'src/transactions/feed/NftFeedItem' import SwapFeedItem from 'src/transactions/feed/SwapFeedItem' import TokenApprovalFeedItem from 'src/transactions/feed/TokenApprovalFeedItem' import TransferFeedItem from 'src/transactions/feed/TransferFeedItem' -import { pendingStandbyTransactionsSelector } from 'src/transactions/reducer' +import { allStandbyTransactionsSelector } from 'src/transactions/reducer' import { + TokenTransactionTypeV2, + TransactionStatus, type NetworkId, type NftTransfer, type TokenApproval, type TokenEarn, type TokenExchange, type TokenTransaction, - TokenTransactionTypeV2, type TokenTransfer, } from 'src/transactions/types' -import { groupFeedItemsInSections } from 'src/transactions/utils' +import { + groupFeedItemsInSections, + standByTransactionToTokenTransaction, +} from 'src/transactions/utils' import { walletAddressSelector } from 'src/web3/selectors' type PaginatedData = { @@ -36,11 +40,119 @@ type PaginatedData = { const POLL_INTERVAL_MS = 10000 // 10 sec const FIRST_PAGE_TIMESTAMP = 0 -function getAllowedNetworkIdsForTransfers() { - return getMultichainFeatures().showTransfers.join(',').split(',') as NetworkId[] +function getAllowedNetworksForTransfers() { + return getMultichainFeatures().showTransfers +} + +/** + * Join allowed networks into a string to help react memoization. + * N.B: This fetch-time filtering does not suffice to prevent non-Celo TXs from appearing + * on the home feed, since they get cached in Redux -- this is just a network optimization. + */ +function useAllowedNetworksForTransfers() { + const allowedNetworks = getAllowedNetworksForTransfers().join(',') + return useMemo(() => allowedNetworks.split(',') as NetworkId[], [allowedNetworks]) +} + +/** + * This function uses the same deduplication approach as "deduplicateTransactions" function from + * queryHelper but only for a single flattened array instead of two. + * Also, the queryHelper is going to be removed once we fully migrate to TransactionFeedV2, + * so this function would have needed to be moved from queryHelper anyway. + */ +function deduplicateTransactions(transactions: TokenTransaction[]): TokenTransaction[] { + const transactionMap: { [txHash: string]: TokenTransaction } = {} + + for (const tx of transactions) { + transactionMap[tx.transactionHash] = tx + } + + return Object.values(transactionMap) +} + +/** + * If the timestamps are the same, most likely one of the transactions is an approval. + * On the feed we want to show the approval first. + */ +function sortTransactions(transactions: TokenTransaction[]): TokenTransaction[] { + return transactions.sort((a, b) => { + const diff = b.timestamp - a.timestamp + + if (diff === 0) { + if (a.type === TokenTransactionTypeV2.Approval) return 1 + if (b.type === TokenTransactionTypeV2.Approval) return -1 + return 0 + } + + return diff + }) +} + +/** + * Every page of paginated data includes a limited amount of transactions within a certain period. + * In standByTransactions we might have transactions from months ago. Whenever we load a new page + * we only want to add those stand by transactions that are within the time period of the new page. + * Otherwise, if we merge all the stand by transactins into the page it will cause more late transactions + * that were already merged to be removed from the top of the list and move them to the bottom. + * This will cause the screen to "shift", which we're trying to avoid. + */ +function mergeStandByTransactionsInRange( + transactions: TokenTransaction[], + standBy: TokenTransaction[] +): TokenTransaction[] { + if (transactions.length === 0) return [] + + const allowedNetworks = getAllowedNetworksForTransfers() + const max = transactions[0].timestamp + const min = transactions.at(-1)!.timestamp + + const standByInRange = standBy.filter((tx) => tx.timestamp >= min && tx.timestamp <= max) + const deduplicatedTransactions = deduplicateTransactions([...transactions, ...standByInRange]) + const transactionsFromAllowedNetworks = deduplicatedTransactions.filter((tx) => + allowedNetworks.includes(tx.networkId) + ) + + return transactionsFromAllowedNetworks +} + +/** + * Current implementation of allStandbyTransactionsSelector contains function + * getSupportedNetworkIdsForApprovalTxsInHomefeed in its selectors list which triggers a lot of + * unnecessary re-renders. This can be avoided if we join it's result in a string and memoize it, + * similar to how it was done with useAllowedNetworkIdsForTransfers hook from queryHelpers.ts + * + * Not using existing selectors for pending/confirmed stand by transaction only cause they are + * dependant on the un-memoized standbyTransactionsSelector selector which will break the new + * pagination flow. + * + * Implementation of pending is identical to pendingStandbyTransactionsSelector. + * Implementation of confirmed is identical to confirmedStandbyTransactionsSelector. + */ +function useStandByTransactions() { + const standByTransactions = useSelector(allStandbyTransactionsSelector) + const allowedNetworkForTransfers = useAllowedNetworksForTransfers() + + return useMemo(() => { + const transactionsFromAllowedNetworks = standByTransactions.filter((tx) => + allowedNetworkForTransfers.includes(tx.networkId) + ) + + const pending: TokenTransaction[] = [] + const confirmed: TokenTransaction[] = [] + + for (const tx of transactionsFromAllowedNetworks) { + if (tx.status === TransactionStatus.Pending) { + pending.push(standByTransactionToTokenTransaction(tx)) + } else { + confirmed.push(tx) + } + } + + return { pending, confirmed } + }, [standByTransactions, allowedNetworkForTransfers]) } -function renderItem({ item: tx }: { item: TokenTransaction; index: number }) { +function renderItem({ item: tx }: { item: TokenTransaction }) { switch (tx.type) { case TokenTransactionTypeV2.Exchange: case TokenTransactionTypeV2.SwapTransaction: @@ -64,32 +176,44 @@ function renderItem({ item: tx }: { item: TokenTransaction; index: number }) { export default function TransactionFeedV2() { const address = useSelector(walletAddressSelector) - const pendingStandByTransactions = useSelector(pendingStandbyTransactionsSelector) - const [endCursor, setEndCursor] = useState(0) + const standByTransactions = useStandByTransactions() + const [endCursor, setEndCursor] = useState(FIRST_PAGE_TIMESTAMP) const [paginatedData, setPaginatedData] = useState({ [FIRST_PAGE_TIMESTAMP]: [] }) - const { pageData, isFetching, error } = useTransactionFeedV2Query( + + /** + * 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) => { - return { - ...result, - // eslint-disable-next-line react-hooks/rules-of-hooks - pageData: useMemo( - () => ({ - currentCursor: result.originalArgs?.endCursor, // timestamp from the last transaction from the previous page. - nextCursor: result.data?.transactions.at(-1)?.timestamp, // timestamp from the last transaction from the current page - transactions: result.data?.transactions || [], - }), - [result] - ), - } - }, + selectFromResult: (result) => ({ + ...result, + nextCursor: result.data?.transactions.at(-1)?.timestamp, + }), } ) - // Poll the first page + /** + * 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. + */ useTransactionFeedV2Query( { address: address!, endCursor: FIRST_PAGE_TIMESTAMP }, { skip: !address, pollingInterval: POLL_INTERVAL_MS } @@ -99,83 +223,70 @@ export default function TransactionFeedV2() { 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. + */ setPaginatedData((prev) => { - /** - * Only update pagination data in the following scenarios: - * - if it's a first page (which is polling every POLL_INTERVAL) - * - if it's a page, that wasn't fetched yet - */ - const isFirstPage = pageData.currentCursor === FIRST_PAGE_TIMESTAMP + const isFirstPage = currentCursor === FIRST_PAGE_TIMESTAMP const pageDataIsAbsent = - pageData.currentCursor !== FIRST_PAGE_TIMESTAMP && // not the first page - pageData.currentCursor !== undefined && // it is SOME page - prev[pageData.currentCursor] === undefined // data for this page wasn't fetched yet + currentCursor !== FIRST_PAGE_TIMESTAMP && // 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) { - return { ...prev, [pageData.currentCursor!]: pageData.transactions } + const mergedTransactions = mergeStandByTransactionsInRange( + transactions, + standByTransactions.confirmed + ) + + return { ...prev, [currentCursor!]: mergedTransactions } } return prev }) }, - [isFetching, pageData] + [isFetching, data?.transactions, originalArgs?.endCursor, standByTransactions.confirmed] ) - const pendingTransactions = useMemo(() => { - const allowedNetworks = getAllowedNetworkIdsForTransfers() - return pendingStandByTransactions.filter((tx) => { - return allowedNetworks.includes(tx.networkId) - }) - }, [pendingStandByTransactions]) - - /** - * This function uses the same deduplication approach as "deduplicateTransactions" - * function from queryHelper.ts but only for a single flattened array instead of - * two separate arrays. - */ const confirmedTransactions = useMemo(() => { const flattenedPages = Object.values(paginatedData).flat() - const transactionMap: { [txHash: string]: TokenTransaction } = {} - - for (const tx of flattenedPages) { - transactionMap[tx.transactionHash] = tx - } - - const deduplicatedTransactions = Object.values(transactionMap) - const sortedTransactions = deduplicatedTransactions.sort((a, b) => { - const diff = b.timestamp - a.timestamp - if (diff === 0) { - // if the timestamps are the same, most likely one of the transactions - // is an approval. on the feed we want to show the approval first. - return a.type === TokenTransactionTypeV2.Approval - ? 1 - : b.type === TokenTransactionTypeV2.Approval - ? -1 - : 0 - } - return diff - }) - + const deduplicatedTransactions = deduplicateTransactions(flattenedPages) + const sortedTransactions = sortTransactions(deduplicatedTransactions) return sortedTransactions }, [paginatedData]) const sections = useMemo(() => { - const noTransactions = pendingTransactions.length === 0 && confirmedTransactions.length === 0 - if (noTransactions) return [] - return groupFeedItemsInSections(pendingTransactions, confirmedTransactions) - }, [pendingTransactions, confirmedTransactions]) + const noPendingTransactions = standByTransactions.pending.length === 0 + const noConfirmedTransactions = confirmedTransactions.length === 0 + if (noPendingTransactions && noConfirmedTransactions) return [] + return groupFeedItemsInSections(standByTransactions.pending, confirmedTransactions) + }, [standByTransactions.pending, confirmedTransactions]) if (!sections.length) { return getFeatureGate(StatsigFeatureGates.SHOW_GET_STARTED) ? ( ) : ( - + ) } function fetchMoreTransactions() { - if (pageData.nextCursor) { - setEndCursor(pageData.nextCursor) + if (nextCursor) { + setEndCursor(nextCursor) } } diff --git a/src/transactions/reducer.ts b/src/transactions/reducer.ts index b12ee4f652..d09eb81e42 100644 --- a/src/transactions/reducer.ts +++ b/src/transactions/reducer.ts @@ -167,7 +167,8 @@ export const reducer = ( } } -const allStandbyTransactionsSelector = (state: RootState) => state.transactions.standbyTransactions +export const allStandbyTransactionsSelector = (state: RootState) => + state.transactions.standbyTransactions const standbyTransactionsSelector = createSelector( [allStandbyTransactionsSelector, getSupportedNetworkIdsForApprovalTxsInHomefeed], (standbyTransactions, supportedNetworkIdsForApprovalTxs) => { diff --git a/src/transactions/utils.ts b/src/transactions/utils.ts index 221d2941fd..9506759348 100644 --- a/src/transactions/utils.ts +++ b/src/transactions/utils.ts @@ -2,7 +2,7 @@ import BigNumber from 'bignumber.js' import { PrefixedTxReceiptProperties, TxReceiptProperties } from 'src/analytics/Properties' import i18n from 'src/i18n' import { TokenBalances } from 'src/tokens/slice' -import { NetworkId, TrackedTx } from 'src/transactions/types' +import { NetworkId, StandbyTransaction, TokenTransaction, TrackedTx } from 'src/transactions/types' import { formatFeedSectionTitle, timeDeltaInDays } from 'src/utils/time' import { getEstimatedGasFee, @@ -120,3 +120,12 @@ export function getPrefixedTxAnalyticsProperties( } return prefixedProperties as Partial> } + +export function standByTransactionToTokenTransaction(tx: StandbyTransaction): TokenTransaction { + return { + fees: [], + block: '', + transactionHash: '', + ...tx, // in case the transaction already has the above (e.g. cross chain swaps), use the real values + } +}