diff --git a/knip.ts b/knip.ts index a98c0772e79..fb3ef278f92 100644 --- a/knip.ts +++ b/knip.ts @@ -33,7 +33,11 @@ const config: KnipConfig = { '@types/jest', 'husky', ], - ignore: ['src/utils/inputValidation.ts', 'src/utils/country.json'], + ignore: [ + 'src/utils/inputValidation.ts', + 'src/utils/country.json', + 'src/redux/reducersForSchemaGeneration.ts', + ], } export default config diff --git a/scripts/update_root_state_schema.sh b/scripts/update_root_state_schema.sh index 0a229ec6a44..b72a686d313 100755 --- a/scripts/update_root_state_schema.sh +++ b/scripts/update_root_state_schema.sh @@ -7,7 +7,7 @@ set -euo pipefail root_state_schema="test/RootStateSchema.json" -typescript-json-schema ./tsconfig.json RootState --include src/redux/reducers.ts --ignoreErrors --required --noExtraProps > "$root_state_schema" +typescript-json-schema ./tsconfig.json RootStateForSchemaGeneration --include src/redux/reducersForSchemaGeneration.ts --ignoreErrors --required --noExtraProps > "$root_state_schema" if git diff --exit-code "$root_state_schema"; then echo "$root_state_schema is up to date" diff --git a/src/redux/api.ts b/src/redux/api.ts new file mode 100644 index 00000000000..d1e185ebe44 --- /dev/null +++ b/src/redux/api.ts @@ -0,0 +1,9 @@ +import { fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import networkConfig from 'src/web3/networkConfig' + +export const baseQuery = fetchBaseQuery({ + baseUrl: networkConfig.blockchainApiUrl, + headers: { + Accept: 'application/json', + }, +}) diff --git a/src/redux/apiReducersList.ts b/src/redux/apiReducersList.ts new file mode 100644 index 00000000000..8c5126c9be0 --- /dev/null +++ b/src/redux/apiReducersList.ts @@ -0,0 +1,9 @@ +import { transactionFeedV2Api } from 'src/transactions/api' + +export type ApiReducersKeys = keyof typeof apiReducersList + +export const apiReducersList = { + [transactionFeedV2Api.reducerPath]: transactionFeedV2Api.reducer, +} as const + +export const apiMiddlewares = [transactionFeedV2Api.middleware] diff --git a/src/redux/migrations.ts b/src/redux/migrations.ts index 6b910d8f7eb..163f3e5441c 100644 --- a/src/redux/migrations.ts +++ b/src/redux/migrations.ts @@ -1916,4 +1916,5 @@ export const migrations = { ...state, app: _.omit(state.app, 'numberVerified'), }), + 233: (state: any) => state, } diff --git a/src/redux/reducers.ts b/src/redux/reducers.ts index f9520956ed8..0b7cb6ec9cd 100644 --- a/src/redux/reducers.ts +++ b/src/redux/reducers.ts @@ -1,61 +1,11 @@ -import { Action, combineReducers } from '@reduxjs/toolkit' -import { PersistState } from 'redux-persist' -import { Actions, ClearStoredAccountAction } from 'src/account/actions' -import { reducer as account } from 'src/account/reducer' -import { reducer as alert } from 'src/alert/reducer' -import { appReducer as app } from 'src/app/reducers' -import dappsReducer from 'src/dapps/slice' -import earnReducer from 'src/earn/slice' -import { reducer as fiatExchanges } from 'src/fiatExchanges/reducer' -import fiatConnectReducer from 'src/fiatconnect/slice' -import { homeReducer as home } from 'src/home/reducers' -import i18nReducer from 'src/i18n/slice' +import { type Action, combineReducers } from '@reduxjs/toolkit' +import { type PersistState } from 'redux-persist' +import { Actions, type ClearStoredAccountAction } from 'src/account/actions' import { reducer as identity } from 'src/identity/reducer' -import { reducer as imports } from 'src/import/reducer' -import jumpstartReducer from 'src/jumpstart/slice' -import keylessBackupReducer from 'src/keylessBackup/slice' -import { reducer as localCurrency } from 'src/localCurrency/reducer' -import { reducer as networkInfo } from 'src/networkInfo/reducer' -import nftsReducer from 'src/nfts/slice' -import pointsReducer from 'src/points/slice' -import positionsReducer from 'src/positions/slice' -import priceHistoryReducer from 'src/priceHistory/slice' -import { recipientsReducer as recipients } from 'src/recipients/reducer' -import { sendReducer as send } from 'src/send/reducers' -import swapReducer from 'src/swap/slice' -import tokenReducer from 'src/tokens/slice' -import { reducer as transactions } from 'src/transactions/reducer' -import { reducer as walletConnect } from 'src/walletConnect/reducer' -import { reducer as web3 } from 'src/web3/reducer' +import { apiReducersList } from 'src/redux/apiReducersList' +import { reducersList } from 'src/redux/reducersList' -const appReducer = combineReducers({ - app, - i18n: i18nReducer, - networkInfo, - alert, - send, - home, - transactions, - web3, - identity, - account, - recipients, - localCurrency, - imports, - fiatExchanges, - walletConnect, - tokens: tokenReducer, - dapps: dappsReducer, - fiatConnect: fiatConnectReducer, - swap: swapReducer, - positions: positionsReducer, - keylessBackup: keylessBackupReducer, - nfts: nftsReducer, - priceHistory: priceHistoryReducer, - jumpstart: jumpstartReducer, - points: pointsReducer, - earn: earnReducer, -}) +const appReducer = combineReducers({ ...reducersList, ...apiReducersList }) const rootReducer = (state: RootState | undefined, action: Action): RootState => { if (action.type === Actions.CLEAR_STORED_ACCOUNT && state) { diff --git a/src/redux/reducersForSchemaGeneration.ts b/src/redux/reducersForSchemaGeneration.ts new file mode 100644 index 00000000000..86ead9a14e2 --- /dev/null +++ b/src/redux/reducersForSchemaGeneration.ts @@ -0,0 +1,20 @@ +import { combineReducers } from 'redux' +import { PersistState } from 'redux-persist' +import { reducersList } from 'src/redux/reducersList' + +/** + * All the reducers up to this point were always manually maintained/changed by us. + * For this purpose, we have Redux migrations which are covered with tests to ensure + * compatibility between different versions of Redux state. With introduction of RTK-Query + * we introduce library-managed api reducers, which must not be manually tweaked as library + * can change how it stores its structure (api reducer is the cache for API endpoint). + * + * For this purpose, it is mandatory to omit api reducers completely from the flow of + * typescript-json-schema generation. This file only includes existing non-api reducers + * necessary for schema generation. Trying to generate the schema with api reducers throws + * an error of unsupported type from @reduxjs/tookit/query/react. + */ +const reducerForSchemaGeneration = combineReducers(reducersList) +export type RootStateForSchemaGeneration = ReturnType & { + _persist: PersistState +} diff --git a/src/redux/reducersList.ts b/src/redux/reducersList.ts new file mode 100644 index 00000000000..efea1b99b02 --- /dev/null +++ b/src/redux/reducersList.ts @@ -0,0 +1,55 @@ +import { reducer as account } from 'src/account/reducer' +import { reducer as alert } from 'src/alert/reducer' +import { appReducer as app } from 'src/app/reducers' +import dappsReducer from 'src/dapps/slice' +import earnReducer from 'src/earn/slice' +import { reducer as fiatExchanges } from 'src/fiatExchanges/reducer' +import fiatConnectReducer from 'src/fiatconnect/slice' +import { homeReducer as home } from 'src/home/reducers' +import i18nReducer from 'src/i18n/slice' +import { reducer as identity } from 'src/identity/reducer' +import { reducer as imports } from 'src/import/reducer' +import jumpstartReducer from 'src/jumpstart/slice' +import keylessBackupReducer from 'src/keylessBackup/slice' +import { reducer as localCurrency } from 'src/localCurrency/reducer' +import { reducer as networkInfo } from 'src/networkInfo/reducer' +import nftsReducer from 'src/nfts/slice' +import pointsReducer from 'src/points/slice' +import positionsReducer from 'src/positions/slice' +import priceHistoryReducer from 'src/priceHistory/slice' +import { recipientsReducer as recipients } from 'src/recipients/reducer' +import { sendReducer as send } from 'src/send/reducers' +import swapReducer from 'src/swap/slice' +import tokenReducer from 'src/tokens/slice' +import { reducer as transactions } from 'src/transactions/reducer' +import { reducer as walletConnect } from 'src/walletConnect/reducer' +import { reducer as web3 } from 'src/web3/reducer' + +export const reducersList = { + app, + i18n: i18nReducer, + networkInfo, + alert, + send, + home, + transactions, + web3, + identity, + account, + recipients, + localCurrency, + imports, + fiatExchanges, + walletConnect, + tokens: tokenReducer, + dapps: dappsReducer, + fiatConnect: fiatConnectReducer, + swap: swapReducer, + positions: positionsReducer, + keylessBackup: keylessBackupReducer, + nfts: nftsReducer, + priceHistory: priceHistoryReducer, + jumpstart: jumpstartReducer, + points: pointsReducer, + earn: earnReducer, +} as const diff --git a/src/redux/store.test.ts b/src/redux/store.test.ts index 04e47fc4450..716bc30634c 100644 --- a/src/redux/store.test.ts +++ b/src/redux/store.test.ts @@ -1,7 +1,9 @@ import Ajv from 'ajv' import { spawn, takeEvery } from 'redux-saga/effects' +import { ApiReducersKeys, apiReducersList } from 'src/redux/apiReducersList' import * as createMigrateModule from 'src/redux/createMigrate' import { migrations } from 'src/redux/migrations' +import { RootState } from 'src/redux/reducers' import { rootSaga } from 'src/redux/sagas' import { _persistConfig, setupStore } from 'src/redux/store' import * as accountCheckerModule from 'src/utils/accountChecker' @@ -25,6 +27,22 @@ const resetStateOnInvalidStoredAccount = jest.spyOn( const loggerErrorSpy = jest.spyOn(Logger, 'error') +function getNonApiReducers>(state: RootState): R { + const apiReducersKeys = Object.keys(apiReducersList) + const nonApiReducers = {} as R + + for (const [key, value] of Object.entries(state)) { + const isApiReducer = apiReducersKeys.includes(key) + + // api reducers are not persisted so skip them + if (isApiReducer) continue + + nonApiReducers[key as keyof R] = value as unknown as any + } + + return nonApiReducers +} + beforeEach(() => { jest.clearAllMocks() // For some reason createMigrate.mockRestore doesn't work, so instead we manually reset it to the original implementation @@ -81,9 +99,9 @@ describe('store state', () => { }) }) - const data = store.getState() + const data = getNonApiReducers(store.getState()) - const ajv = new Ajv({ allErrors: true }) + const ajv = new Ajv({ allErrors: true, allowUnionTypes: true }) const schema = require('test/RootStateSchema.json') const validate = ajv.compile(schema) const isValid = validate(data) @@ -98,7 +116,7 @@ describe('store state', () => { { "_persist": { "rehydrated": true, - "version": 232, + "version": 233, }, "account": { "acceptedTerms": false, diff --git a/src/redux/store.ts b/src/redux/store.ts index 84d37a8c124..866dfe24534 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,15 +1,18 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { configureStore, Middleware } from '@reduxjs/toolkit' +import { setupListeners } from '@reduxjs/toolkit/query' import { getStoredState, PersistConfig, persistReducer, persistStore } from 'redux-persist' import FSStorage from 'redux-persist-fs-storage' import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2' import createSagaMiddleware from 'redux-saga' import AppAnalytics from 'src/analytics/AppAnalytics' import { PerformanceEvents } from 'src/analytics/Events' +import { apiMiddlewares } from 'src/redux/apiReducersList' import { createMigrate } from 'src/redux/createMigrate' import { migrations } from 'src/redux/migrations' import rootReducer, { RootState as ReducersRootState } from 'src/redux/reducers' import { rootSaga } from 'src/redux/sagas' +import { transactionFeedV2Api } from 'src/transactions/api' import { resetStateOnInvalidStoredAccount } from 'src/utils/accountChecker' import Logger from 'src/utils/Logger' import { ONE_DAY_IN_MILLIS } from 'src/utils/time' @@ -21,10 +24,17 @@ const persistConfig: PersistConfig = { key: 'root', // default is -1, increment as we make migrations // See https://github.com/valora-inc/wallet/tree/main/WALLET.md#redux-state-migration - version: 232, + version: 233, keyPrefix: `reduxStore-`, // the redux-persist default is `persist:` which doesn't work with some file systems. storage: FSStorage(), - blacklist: ['networkInfo', 'alert', 'imports', 'keylessBackup', 'jumpstart'], + blacklist: [ + 'networkInfo', + 'alert', + 'imports', + 'keylessBackup', + 'jumpstart', + transactionFeedV2Api.reducerPath, + ], stateReconciler: autoMergeLevel2, migrate: async (...args) => { const migrate = createMigrate(migrations) @@ -103,7 +113,8 @@ export const setupStore = (initialState?: ReducersRootState, config = persistCon ) }, }) - const middlewares: Middleware[] = [sagaMiddleware] + + const middlewares: Middleware[] = [sagaMiddleware, ...apiMiddlewares] if (__DEV__ && !process.env.JEST_WORKER_ID) { const createDebugger = require('redux-flipper').default @@ -172,3 +183,5 @@ export { persistor, store } export type RootState = ReturnType export type AppDispatch = typeof store.dispatch + +setupListeners(store.dispatch) diff --git a/src/swap/SwapScreen.test.tsx b/src/swap/SwapScreen.test.tsx index 038fa02f8b6..31545a018f2 100644 --- a/src/swap/SwapScreen.test.tsx +++ b/src/swap/SwapScreen.test.tsx @@ -1328,6 +1328,9 @@ describe('SwapScreen', () => { status: 'started', }, }, + + // as per test/utils.ts, line 105 + transactionFeedV2Api: undefined, }) update( @@ -1373,6 +1376,9 @@ describe('SwapScreen', () => { status: 'error', }, }, + + // as per test/utils.ts, line 105 + transactionFeedV2Api: undefined, }) update( diff --git a/src/transactions/NoActivity.tsx b/src/transactions/NoActivity.tsx index 40431e5a607..02cf3e84333 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 new file mode 100644 index 00000000000..2f78d2662c3 --- /dev/null +++ b/src/transactions/api.ts @@ -0,0 +1,29 @@ +import { createApi } from '@reduxjs/toolkit/query/react' +import { baseQuery } from 'src/redux/api' +import type { TokenTransaction } from 'src/transactions/types' + +export type TransactionFeedV2Response = { + transactions: TokenTransaction[] + pageInfo: { + hasNextPage: boolean + } +} + +export const transactionFeedV2Api = createApi({ + reducerPath: 'transactionFeedV2Api', + baseQuery, + endpoints: (builder) => ({ + transactionFeedV2: builder.query< + TransactionFeedV2Response, + { address: string; endCursor: number } + >({ + query: ({ address, endCursor }) => { + const cursor = endCursor ? `?endCursor=${endCursor}` : '' + return `/wallet/${address}/transactions${cursor}` + }, + keepUnusedDataFor: 60, // 1 min + }), + }), +}) + +export const { useTransactionFeedV2Query } = transactionFeedV2Api diff --git a/src/transactions/apiTestHelpers.ts b/src/transactions/apiTestHelpers.ts new file mode 100644 index 00000000000..73f426ed16d --- /dev/null +++ b/src/transactions/apiTestHelpers.ts @@ -0,0 +1,52 @@ +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 { 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. + * 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: + * https://medium.com/@johnmcdowell0801/testing-rtk-query-with-jest-cdfa5aaf3dc1 + */ +export function setupApiStore< + A extends { + reducer: Reducer + reducerPath: string + middleware: Middleware + }, + Preloaded extends RecursivePartial>, + R extends Record> = Record, +>(api: A, preloadedState: Preloaded, extraReducers?: R) { + const getStore = () => + configureStore({ + preloadedState: getMockStoreData(preloadedState), + reducer: combineReducers({ + [api.reducerPath]: api.reducer, + ...extraReducers, + }), + middleware: (getDefaultMiddleware) => { + return getDefaultMiddleware({ + serializableCheck: false, + immutableCheck: false, + }).concat(api.middleware) + }, + }) + + type Store = { api: ReturnType } & { [K in keyof R]: ReturnType } + type StoreType = EnhancedStore< + Store, + 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 00000000000..04e746a6558 --- /dev/null +++ b/src/transactions/feed/TransactionFeedV2.test.tsx @@ -0,0 +1,323 @@ +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 STAND_BY_TRANSACTION_SUBTITLE_KEY = 'confirmingTransaction' +const mockFetch = fetch as FetchMock + +function 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), + } +} + +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('TransactionFeedV2', () => { + it('renders correctly when there is a response', async () => { + mockFetch.mockResponse(typedResponse({ transactions: [mockTransaction()] })) + const { store, ...tree } = renderScreen() + + 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('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() + + 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 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() + + 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 pages until 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 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() + 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() + }) + + 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 new file mode 100644 index 00000000000..23f536be1dd --- /dev/null +++ b/src/transactions/feed/TransactionFeedV2.tsx @@ -0,0 +1,325 @@ +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 { allStandbyTransactionsSelector } from 'src/transactions/reducer' +import { + TokenTransactionTypeV2, + TransactionStatus, + type NetworkId, + type NftTransfer, + type TokenApproval, + type TokenEarn, + type TokenExchange, + type TokenTransaction, + type TokenTransfer, +} from 'src/transactions/types' +import { + groupFeedItemsInSections, + standByTransactionToTokenTransaction, +} from 'src/transactions/utils' +import { walletAddressSelector } from 'src/web3/selectors' + +type PaginatedData = { + [timestamp: number]: TokenTransaction[] +} + +// Query poll interval +const POLL_INTERVAL_MS = 10000 // 10 sec +const FIRST_PAGE_TIMESTAMP = 0 + +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 }) { + 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 + } +} + +export default function TransactionFeedV2() { + const address = useSelector(walletAddressSelector) + const standByTransactions = useStandByTransactions() + const [endCursor, setEndCursor] = useState(FIRST_PAGE_TIMESTAMP) + const [paginatedData, setPaginatedData] = useState({ [FIRST_PAGE_TIMESTAMP]: [] }) + + /** + * 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, + }), + } + ) + + /** + * 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 } + ) + + 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. + */ + setPaginatedData((prev) => { + const isFirstPage = currentCursor === FIRST_PAGE_TIMESTAMP + const pageDataIsAbsent = + 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) { + const mergedTransactions = mergeStandByTransactionsInRange( + transactions, + standByTransactions.confirmed + ) + + return { ...prev, [currentCursor!]: mergedTransactions } + } + + return prev + }) + }, + [isFetching, data?.transactions, originalArgs?.endCursor, standByTransactions.confirmed] + ) + + const confirmedTransactions = useMemo(() => { + const flattenedPages = Object.values(paginatedData).flat() + const deduplicatedTransactions = deduplicateTransactions(flattenedPages) + const sortedTransactions = sortTransactions(deduplicatedTransactions) + return sortedTransactions + }, [paginatedData]) + + const sections = useMemo(() => { + 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 (nextCursor) { + setEndCursor(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, + }, +}) diff --git a/src/transactions/reducer.ts b/src/transactions/reducer.ts index b12ee4f6529..d09eb81e42e 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/types.ts b/src/transactions/types.ts index e75ea8debb9..f25f8a58567 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 diff --git a/src/transactions/utils.ts b/src/transactions/utils.ts index 221d2941fd1..9506759348f 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 + } +} diff --git a/test/schema.test.ts b/test/schema.test.ts index 22fb9b1ec84..8adf067a247 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -9,7 +9,7 @@ describe(getLatestSchema, () => { it('validates against the RootState schema', async () => { const data = getLatestSchema() - const ajv = new Ajv({ allErrors: true }) + const ajv = new Ajv({ allErrors: true, allowUnionTypes: true }) const schema = require('./RootStateSchema.json') const validate = ajv.compile(schema) const isValid = validate(data) diff --git a/test/schemas.ts b/test/schemas.ts index 6a8c65581e6..8d5e4dbef28 100644 --- a/test/schemas.ts +++ b/test/schemas.ts @@ -3533,6 +3533,14 @@ export const v232Schema = { app: _.omit(v231Schema.app, 'numberVerified'), } +export const v233Schema = { + ...v232Schema, + _persist: { + ...v232Schema._persist, + version: 233, + }, +} + export function getLatestSchema(): Partial { - return v232Schema as Partial + return v233Schema as Partial } diff --git a/test/utils.ts b/test/utils.ts index 2abeba1f497..a592261296b 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -101,6 +101,9 @@ export function getMockStoreData(overrides: RecursivePartial = {}): R ...defaultSchema, ...contactMappingData, ...recipientData, + + // ignore api reducers that are managed by RTK-Query library itself + transactionFeedV2Api: undefined, } // Apply overrides. Note: only merges one level deep