From 6b574e92e87f58f044e4d8c6754377c20958cee2 Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Fri, 24 Oct 2025 09:20:44 -0700 Subject: [PATCH 1/8] Added full tokens page --- app/components/Nav/Main/MainNavigator.js | 16 +- .../UI/Tokens/TokenList/TokenListView.tsx | 59 ++++ app/components/UI/Tokens/TokenList/index.tsx | 8 +- .../TokenListControlBar.tsx | 4 + app/components/UI/Tokens/index.tsx | 1 + .../shared/BaseControlBar/BaseControlBar.tsx | 11 +- app/components/Views/AddAsset/AddAsset.tsx | 24 +- .../Views/TokensFullView/TokensFullView.tsx | 323 ++++++++++++++++++ app/components/Views/TokensFullView/index.ts | 1 + app/constants/navigation/Routes.ts | 1 + 10 files changed, 419 insertions(+), 29 deletions(-) create mode 100644 app/components/UI/Tokens/TokenList/TokenListView.tsx create mode 100644 app/components/Views/TokensFullView/TokensFullView.tsx create mode 100644 app/components/Views/TokensFullView/index.ts diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index a43daffd15e3..1746d61de6b4 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -26,6 +26,7 @@ import Asset from '../../Views/Asset'; import AssetDetails from '../../Views/AssetDetails'; import AddAsset from '../../Views/AddAsset'; import Collectible from '../../Views/Collectible'; +import TokensFullView from '../../Views/TokensFullView'; import SendLegacy from '../../Views/confirmations/legacy/Send'; import SendTo from '../../Views/confirmations/legacy/SendFlow/SendTo'; import { RevealPrivateCredential } from '../../Views/RevealPrivateCredential'; @@ -196,11 +197,6 @@ const WalletTabStackFlow = () => ( component={WalletModalFlow} options={{ headerShown: false }} /> - { }} /> + + {isRewardsEnabled && ( void; + setShowScamWarningModal: () => void; +} + +const TokenListView = ({ + tokenKeys, + showRemoveMenu, + setShowScamWarningModal, +}: TokenListViewProps) => { + const { colors } = useTheme(); + const styles = createStyles(colors); + + const renderTokenListItem = useCallback( + (token: TokenI, index: number) => { + const assetKey = { + address: token.address, + chainId: token.chainId, + isStaked: token.isStaked, + }; + + return ( + + ); + }, + [showRemoveMenu, setShowScamWarningModal], + ); + + return ( + + + {tokenKeys.map((token, index) => renderTokenListItem(token, index))} + + + ); +}; + +export default TokenListView; diff --git a/app/components/UI/Tokens/TokenList/index.tsx b/app/components/UI/Tokens/TokenList/index.tsx index b5c14752d91a..1be0a5b3b8dd 100644 --- a/app/components/UI/Tokens/TokenList/index.tsx +++ b/app/components/UI/Tokens/TokenList/index.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useLayoutEffect, useRef } from 'react'; import { View, RefreshControl } from 'react-native'; -import { FlashList, FlashListRef } from '@shopify/flash-list'; +import { FlashList, FlashListRef, FlashListProps } from '@shopify/flash-list'; import { useSelector } from 'react-redux'; import { useTheme } from '../../../../util/theme'; import { @@ -13,7 +13,6 @@ import TextComponent, { } from '../../../../component-library/components/Texts/Text'; import { TokenI } from '../types'; import { strings } from '../../../../../locales/i18n'; -import { TokenListFooter } from './TokenListFooter'; import { TokenListItem, TokenListItemBip44 } from './TokenListItem'; import { WalletViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/WalletView.selectors'; import { useNavigation } from '@react-navigation/native'; @@ -33,6 +32,7 @@ interface TokenListProps { showRemoveMenu: (arg: TokenI) => void; showPercentageChange?: boolean; setShowScamWarningModal: () => void; + flashListProps?: Partial>; } const TokenListComponent = ({ @@ -42,6 +42,7 @@ const TokenListComponent = ({ showRemoveMenu, showPercentageChange = true, setShowScamWarningModal, + flashListProps, }: TokenListProps) => { const { colors } = useTheme(); const privacyMode = useSelector(selectPrivacyMode); @@ -107,7 +108,6 @@ const TokenListComponent = ({ return `${item.address}-${item.chainId}-${staked}-${idx}`; }} decelerationRate="fast" - ListFooterComponent={} refreshControl={ } extraData={{ isTokenNetworkFilterEqualCurrentNetwork }} - scrollEnabled + {...flashListProps} /> ) : ( diff --git a/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.tsx b/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.tsx index 127b16f3f98a..de98be28b375 100644 --- a/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.tsx +++ b/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { ViewStyle } from 'react-native'; import { useSelector } from 'react-redux'; import { selectIsEvmNetworkSelected } from '../../../../selectors/multichainNetworkController'; import { WalletViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/WalletView.selectors'; @@ -12,10 +13,12 @@ import createControlBarStyles from '../../shared/ControlBarStyles'; interface TokenListControlBarProps { goToAddToken: () => void; + style?: ViewStyle; } export const TokenListControlBar = ({ goToAddToken, + style, }: TokenListControlBarProps) => { const { styles } = useStyles(createControlBarStyles, undefined); const isEvmSelected = useSelector(selectIsEvmNetworkSelected); @@ -38,6 +41,7 @@ export const TokenListControlBar = ({ additionalButtons={additionalButtons} useEvmSelectionLogic={isEvmSelected} customWrapper="outer" + style={style} /> ); }; diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx index a41ef1164168..6f8209854339 100644 --- a/app/components/UI/Tokens/index.tsx +++ b/app/components/UI/Tokens/index.tsx @@ -271,6 +271,7 @@ const Tokens = memo(() => { onRefresh={onRefresh} showRemoveMenu={showRemoveMenu} setShowScamWarningModal={handleScamWarningModal} + flashListProps={{ scrollEnabled: true }} /> )} diff --git a/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx b/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx index f0410d921b31..ee4b9ae3d130 100644 --- a/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx +++ b/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx @@ -1,5 +1,5 @@ import React, { useCallback, ReactNode, useMemo, useEffect } from 'react'; -import { View } from 'react-native'; +import { View, ViewStyle } from 'react-native'; import { useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; import { SolScope } from '@metamask/keyring-api'; @@ -77,6 +77,10 @@ export interface BaseControlBarProps { * Custom wrapper component for the control buttons */ customWrapper?: 'outer' | 'none'; + /** + * Custom style to apply to the action bar wrapper + */ + style?: ViewStyle; } const BaseControlBar: React.FC = ({ @@ -88,6 +92,7 @@ const BaseControlBar: React.FC = ({ additionalButtons, useEvmSelectionLogic = false, customWrapper = 'outer', + style, }) => { const { styles } = useStyles(createControlBarStyles, undefined); const navigation = useNavigation(); @@ -256,7 +261,7 @@ const BaseControlBar: React.FC = ({ if (customWrapper === 'none') { return ( - + {networkButton} {sortButton} {additionalButtons} @@ -265,7 +270,7 @@ const BaseControlBar: React.FC = ({ } return ( - + {networkButton} diff --git a/app/components/Views/AddAsset/AddAsset.tsx b/app/components/Views/AddAsset/AddAsset.tsx index 572624fcf6c1..7c137af9b604 100644 --- a/app/components/Views/AddAsset/AddAsset.tsx +++ b/app/components/Views/AddAsset/AddAsset.tsx @@ -15,7 +15,7 @@ import ScrollableTabView, { } from '@tommasini/react-native-scrollable-tab-view'; import { strings } from '../../../../locales/i18n'; import AddCustomCollectible from '../../UI/AddCustomCollectible'; -import { getImportTokenNavbarOptions } from '../../UI/Navbar'; +import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; import { isTokenDetectionSupportedForNetwork } from '@metamask/assets-controllers'; import { selectEvmChainId, @@ -104,22 +104,9 @@ const AddAsset = () => { return tokensData && Object.keys(tokensData).length > 0; }, [selectedNetwork, tokenListForAllChains]); - const updateNavBar = useCallback(() => { - navigation.setOptions( - getImportTokenNavbarOptions( - `add_asset.${assetType === TOKEN ? TOKEN_TITLE : NFT_TITLE}`, - true, - navigation, - colors, - true, - 0, - ), - ); - }, [assetType, colors, navigation]); - - useEffect(() => { - updateNavBar(); - }, [updateNavBar]); + const handleBackPress = useCallback(() => { + navigation.goBack(); + }, [navigation]); const goToSecuritySettings = () => { navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { @@ -147,6 +134,9 @@ const AddAsset = () => { return ( + + {strings(`add_asset.${assetType === TOKEN ? TOKEN_TITLE : NFT_TITLE}`)} + {assetType !== 'token' && ( { + const navigation = + useNavigation< + StackNavigationProp + >(); + const { trackEvent, createEventBuilder } = useMetrics(); + const tw = useTailwind(); + + // evm + const evmNetworkConfigurationsByChainId = useSelector( + selectEvmNetworkConfigurationsByChainId, + ); + const currentChainId = useSelector(selectChainId); + const nativeCurrencies = useSelector(selectNativeNetworkCurrencies); + const isEvmSelected = useSelector(selectIsEvmNetworkSelected); + + const actionSheet = useRef(); + const [tokenToRemove, setTokenToRemove] = useState(); + const [refreshing, setRefreshing] = useState(false); + const selectedAccountId = useSelector(selectSelectedInternalAccountId); + + const selectedSolanaAccount = + useSelector(selectSelectedInternalAccountByScope)(SolScope.Mainnet) || null; + const isSolanaSelected = selectedSolanaAccount !== null; + + const [showScamWarningModal, setShowScamWarningModal] = useState(false); + const [isTokensLoading, setIsTokensLoading] = useState(true); + const [renderedTokenKeys, setRenderedTokenKeys] = useState< + typeof sortedTokenKeys + >([]); + const [progressiveTokens, setProgressiveTokens] = useState< + typeof sortedTokenKeys + >([]); + const lastTokenDataRef = useRef(); + + // BIP44 MAINTENANCE: Once stable, only use selectSortedAssetsBySelectedAccountGroup + const isMultichainAccountsState2Enabled = useSelector( + selectMultichainAccountsState2Enabled, + ); + + // Memoize selector computation for better performance + const sortedTokenKeys = useSelector( + useMemo( + () => + isMultichainAccountsState2Enabled + ? selectSortedAssetsBySelectedAccountGroup + : selectSortedTokenKeys, + [isMultichainAccountsState2Enabled], + ), + ); + + // High-performance async rendering with progressive loading + useEffect(() => { + // Debounce rapid data changes + if ( + JSON.stringify(sortedTokenKeys) === + JSON.stringify(lastTokenDataRef.current) + ) { + return; + } + lastTokenDataRef.current = sortedTokenKeys; + + if (sortedTokenKeys?.length) { + setIsTokensLoading(true); + setProgressiveTokens([]); + + // Use InteractionManager for better performance than setTimeout + InteractionManager.runAfterInteractions(() => { + const CHUNK_SIZE = 20; // Process 20 tokens at a time + const chunks: (typeof sortedTokenKeys)[] = []; + + for (let i = 0; i < sortedTokenKeys.length; i += CHUNK_SIZE) { + chunks.push(sortedTokenKeys.slice(i, i + CHUNK_SIZE)); + } + + // Progressive loading for better perceived performance + let currentChunkIndex = 0; + let accumulatedTokens: typeof sortedTokenKeys = []; + + const processChunk = () => { + if (currentChunkIndex < chunks.length) { + accumulatedTokens = [ + ...accumulatedTokens, + ...chunks[currentChunkIndex], + ]; + setProgressiveTokens([...accumulatedTokens]); + currentChunkIndex++; + + // Process next chunk after allowing UI to update + requestAnimationFrame(() => { + if (currentChunkIndex < chunks.length) { + setTimeout(processChunk, 0); + } else { + // All chunks processed + const tokenMap = new Map(); + accumulatedTokens.forEach((item) => { + const staked = item.isStaked ? 'staked' : 'unstaked'; + const key = `${item.address}-${item.chainId}-${staked}`; + tokenMap.set(key, item); + }); + const deduped = Array.from(tokenMap.values()); + setRenderedTokenKeys(deduped); + setIsTokensLoading(false); + } + }); + } + }; + + processChunk(); + }); + + return; + } + + // No tokens to render + setRenderedTokenKeys([]); + setProgressiveTokens([]); + setIsTokensLoading(false); + }, [sortedTokenKeys]); + + const showRemoveMenu = useCallback( + (token: TokenI) => { + // remove token currently only supported on evm + if (isEvmSelected && actionSheet.current) { + setTokenToRemove(token); + actionSheet.current.show(); + } + }, + [isEvmSelected], + ); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + + // Use InteractionManager for better performance during refresh + InteractionManager.runAfterInteractions(() => { + refreshTokens({ + isSolanaSelected, + evmNetworkConfigurationsByChainId, + nativeCurrencies, + selectedAccountId, + }); + setRefreshing(false); + }); + }, [ + isSolanaSelected, + evmNetworkConfigurationsByChainId, + nativeCurrencies, + selectedAccountId, + ]); + + const removeToken = useCallback(async () => { + // remove token currently only supported on evm + if (isEvmSelected && tokenToRemove) { + await removeEvmToken({ + tokenToRemove, + currentChainId, + trackEvent, + strings, + getDecimalChainId, + createEventBuilder, // Now passed as a prop + }); + } + }, [ + isEvmSelected, + tokenToRemove, + currentChainId, + trackEvent, + createEventBuilder, + ]); + + const goToAddToken = useCallback(() => { + // add token currently only support on evm + if (isEvmSelected) { + goToAddEvmToken({ + navigation, + trackEvent, + createEventBuilder, + getDecimalChainId, + currentChainId, + }); + } + }, [ + isEvmSelected, + navigation, + trackEvent, + createEventBuilder, + currentChainId, + ]); + + const onActionSheetPress = useCallback( + (index: number) => { + if (index === 0) { + removeToken(); + } + }, + [removeToken], + ); + + const handleScamWarningModal = useCallback(() => { + setShowScamWarningModal((prev) => !prev); + }, []); + + const handleBackPress = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + return ( + + + + {strings('wallet.tokens')} + + + + + + {isTokensLoading && progressiveTokens.length === 0 && ( + + )} + {(progressiveTokens.length > 0 || renderedTokenKeys.length > 0) && ( + + )} + + {showScamWarningModal && ( + + )} + } + title={strings('wallet.remove_token_title')} + options={[strings('wallet.remove'), strings('wallet.cancel')]} + cancelButtonIndex={1} + destructiveButtonIndex={0} + onPress={onActionSheetPress} + /> + } + title={strings('wallet.remove_token_title')} + options={[strings('wallet.remove'), strings('wallet.cancel')]} + cancelButtonIndex={1} + destructiveButtonIndex={0} + onPress={onActionSheetPress} + /> + + + + ); +}); + +TokensFullView.displayName = 'TokensFullView'; + +export default TokensFullView; diff --git a/app/components/Views/TokensFullView/index.ts b/app/components/Views/TokensFullView/index.ts new file mode 100644 index 000000000000..b97b65c0ca35 --- /dev/null +++ b/app/components/Views/TokensFullView/index.ts @@ -0,0 +1 @@ +export { default } from './TokensFullView'; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 9ceba06f4689..0d4f52a0498e 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -214,6 +214,7 @@ const Routes = { HOME: 'WalletTabHome', TAB_STACK_FLOW: 'WalletTabStackFlow', WALLET_CONNECT_SESSIONS_VIEW: 'WalletConnectSessionsView', + TOKENS_FULL_VIEW: 'TokensFullView', }, VAULT_RECOVERY: { RESTORE_WALLET: 'RestoreWallet', From 90e374ae5813177b9394df2998bf4f0ab0aace09 Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Fri, 24 Oct 2025 09:39:34 -0700 Subject: [PATCH 2/8] Added tests to TokensFullView --- .../TokensFullView/TokensFullView.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 app/components/Views/TokensFullView/TokensFullView.test.ts diff --git a/app/components/Views/TokensFullView/TokensFullView.test.ts b/app/components/Views/TokensFullView/TokensFullView.test.ts new file mode 100644 index 000000000000..05ef0766b353 --- /dev/null +++ b/app/components/Views/TokensFullView/TokensFullView.test.ts @@ -0,0 +1,71 @@ +import { renderScreen } from '../../../util/test/renderWithProvider'; +import TokensFullView from './TokensFullView'; + +// Mock only external dependencies that are not under test +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => (className: string) => ({ className }), +})); + +jest.mock('../../../components/hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: jest.fn(), + createEventBuilder: jest.fn(), + }), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + goBack: jest.fn(), + navigate: jest.fn(), + }), +})); + +describe('TokensFullView', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders header with title and back button', () => { + const { getByTestId } = renderScreen(TokensFullView, { + name: 'TokensFullView', + }); + + expect(getByTestId('bottom-sheet-header')).toBeOnTheScreen(); + expect(getByTestId('header-title')).toBeOnTheScreen(); + expect(getByTestId('back-button')).toBeOnTheScreen(); + }); + + it('renders token list control bar with add button', () => { + const { getByTestId } = renderScreen(TokensFullView, { + name: 'TokensFullView', + }); + + expect(getByTestId('token-list-control-bar')).toBeOnTheScreen(); + expect(getByTestId('add-token-button')).toBeOnTheScreen(); + }); + + it('renders token list component', () => { + const { getByTestId } = renderScreen(TokensFullView, { + name: 'TokensFullView', + }); + + expect(getByTestId('token-list')).toBeOnTheScreen(); + }); + + it('displays token count in token list', () => { + const { getByTestId } = renderScreen(TokensFullView, { + name: 'TokensFullView', + }); + + expect(getByTestId('token-count')).toBeOnTheScreen(); + }); + + it('shows loader when tokens are loading', () => { + const { getByTestId } = renderScreen(TokensFullView, { + name: 'TokensFullView', + }); + + expect(getByTestId('loader')).toBeOnTheScreen(); + }); +}); From ae658c47c8bd43da75550f7a526b3aa67fa27f65 Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Fri, 24 Oct 2025 09:48:18 -0700 Subject: [PATCH 3/8] Added View all Tokens button to help review --- app/components/UI/Tokens/index.tsx | 17 +++++++++++++++++ app/components/UI/Tokens/styles.ts | 4 ++++ locales/languages/en.json | 1 + 3 files changed, 22 insertions(+) diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx index 6f8209854339..8e31992876db 100644 --- a/app/components/UI/Tokens/index.tsx +++ b/app/components/UI/Tokens/index.tsx @@ -26,6 +26,8 @@ import { strings } from '../../../../locales/i18n'; import { refreshTokens, removeEvmToken, goToAddEvmToken } from './util'; import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; +import { Button, ButtonVariant } from '@metamask/design-system-react-native'; +import Routes from '../../../constants/navigation/Routes'; import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; import { TokenListControlBar } from './TokenListControlBar'; import { selectSelectedInternalAccountId } from '../../../selectors/accountsController'; @@ -39,6 +41,7 @@ import { SolScope } from '@metamask/keyring-api'; interface TokenListNavigationParamList { AddAsset: { assetType: string }; + TokensFullView: undefined; [key: string]: undefined | object; } @@ -247,12 +250,26 @@ const Tokens = memo(() => { setShowScamWarningModal((prev) => !prev); }, []); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const navigateToTokensFullView = useCallback(() => { + navigation.navigate(Routes.WALLET.TOKENS_FULL_VIEW); + }, [navigation]); + return ( + {/* Uncomment these lines to review TokensFullView */} + {/* + + */} {!isTokensLoading && renderedTokenKeys.length === 0 && progressiveTokens.length === 0 ? ( diff --git a/app/components/UI/Tokens/styles.ts b/app/components/UI/Tokens/styles.ts index 3372e10ff6b4..d7d045257845 100644 --- a/app/components/UI/Tokens/styles.ts +++ b/app/components/UI/Tokens/styles.ts @@ -238,6 +238,10 @@ const createStyles = (colors: Colors) => badge: { marginTop: 8, }, + viewAllTokensButton: { + paddingHorizontal: 16, + paddingVertical: 8, + }, }); export default createStyles; diff --git a/locales/languages/en.json b/locales/languages/en.json index 4c1618e2bac7..9f631f57b97f 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1819,6 +1819,7 @@ "wallet": { "title": "Wallet", "tokens": "Tokens", + "view_all_tokens": "View all tokens", "collectible": "Collectible", "collectibles": "NFTs", "defi": "DeFi", From 3555f5dafcf3a59bde187dd84b5d447ec3d92ae9 Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Fri, 24 Oct 2025 09:58:48 -0700 Subject: [PATCH 4/8] Removed unnecessary additions --- .../UI/Tokens/TokenList/TokenListView.tsx | 59 ------------------- 1 file changed, 59 deletions(-) delete mode 100644 app/components/UI/Tokens/TokenList/TokenListView.tsx diff --git a/app/components/UI/Tokens/TokenList/TokenListView.tsx b/app/components/UI/Tokens/TokenList/TokenListView.tsx deleted file mode 100644 index 1e0d49393324..000000000000 --- a/app/components/UI/Tokens/TokenList/TokenListView.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { useCallback } from 'react'; -import { View } from 'react-native'; -import { useTheme } from '../../../../util/theme'; -import { TokenI } from '../types'; -import { TokenListItem } from './TokenListItem'; -import { WalletViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/WalletView.selectors'; -import createStyles from '../styles'; - -interface TokenListViewProps { - tokenKeys: TokenI[]; - showRemoveMenu: (token: TokenI) => void; - setShowScamWarningModal: () => void; -} - -const TokenListView = ({ - tokenKeys, - showRemoveMenu, - setShowScamWarningModal, -}: TokenListViewProps) => { - const { colors } = useTheme(); - const styles = createStyles(colors); - - const renderTokenListItem = useCallback( - (token: TokenI, index: number) => { - const assetKey = { - address: token.address, - chainId: token.chainId, - isStaked: token.isStaked, - }; - - return ( - - ); - }, - [showRemoveMenu, setShowScamWarningModal], - ); - - return ( - - - {tokenKeys.map((token, index) => renderTokenListItem(token, index))} - - - ); -}; - -export default TokenListView; From 50f4b0b7ba33e174cf6d9efb2f4c3f5b9d237edf Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Fri, 24 Oct 2025 10:14:09 -0700 Subject: [PATCH 5/8] Updated tests --- .../__snapshots__/MainNavigator.test.tsx.snap | 24 +++++++++++++++++++ app/components/UI/Tokens/index.tsx | 1 + 2 files changed, 25 insertions(+) diff --git a/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap b/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap index b0b4739d0f7c..8ec54f9a2d71 100644 --- a/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap +++ b/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap @@ -38,6 +38,30 @@ exports[`MainNavigator matches rendered snapshot 1`] = ` component={[Function]} name="Home" /> + + Date: Fri, 24 Oct 2025 14:05:49 -0700 Subject: [PATCH 6/8] Removed TokenListFooter --- .../__snapshots__/index.test.tsx.snap | 66 --------- .../TokenList/TokenListFooter/index.test.tsx | 135 ------------------ .../TokenList/TokenListFooter/index.tsx | 86 ----------- 3 files changed, 287 deletions(-) delete mode 100644 app/components/UI/Tokens/TokenList/TokenListFooter/__snapshots__/index.test.tsx.snap delete mode 100644 app/components/UI/Tokens/TokenList/TokenListFooter/index.test.tsx delete mode 100644 app/components/UI/Tokens/TokenList/TokenListFooter/index.tsx diff --git a/app/components/UI/Tokens/TokenList/TokenListFooter/__snapshots__/index.test.tsx.snap b/app/components/UI/Tokens/TokenList/TokenListFooter/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 3ed906236b99..000000000000 --- a/app/components/UI/Tokens/TokenList/TokenListFooter/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,66 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TokenListFooter renders correctly 1`] = ` - - - Fund your wallet to get started in web3 - - - - Add funds - - - -`; diff --git a/app/components/UI/Tokens/TokenList/TokenListFooter/index.test.tsx b/app/components/UI/Tokens/TokenList/TokenListFooter/index.test.tsx deleted file mode 100644 index addb199517b9..000000000000 --- a/app/components/UI/Tokens/TokenList/TokenListFooter/index.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React from 'react'; -import { render, fireEvent, waitFor } from '@testing-library/react-native'; -import { TokenListFooter } from '.'; -import configureMockStore from 'redux-mock-store'; -import { Provider } from 'react-redux'; -import { strings } from '../../../../../../locales/i18n'; -import useRampNetwork from '../../../Ramp/Aggregator/hooks/useRampNetwork'; -import { MetaMetricsEvents } from '../../../../../components/hooks/useMetrics'; -import { mockNetworkState } from '../../../../../util/test/network'; -import { backgroundState } from '../../../../../util/test/initial-root-state'; -import { useSelectedAccountMultichainBalances } from '../../../../hooks/useMultichainBalances'; - -jest.mock('../../../Ramp/Aggregator/hooks/useRampNetwork', () => jest.fn()); -jest.mock('../../../../hooks/useMultichainBalances', () => ({ - useSelectedAccountMultichainBalances: jest.fn(), -})); -jest.mock('../../../../../core/Engine', () => ({ - context: { - PreferencesController: { - setPrivacyMode: jest.fn(), - }, - }, - getTotalEvmFiatAccountBalance: jest.fn(() => ({ - ethFiat: 0, - tokenFiat: 0, - tokenFiat1dAgo: 0, - ethFiat1dAgo: 0, - totalNativeTokenBalance: '0', - ticker: 'ETH', - })), -})); -jest.mock('../../../../../components/hooks/useMetrics', () => ({ - MetaMetricsEvents: { - CARD_ADD_FUNDS_DEPOSIT_CLICKED: 'CARD_ADD_FUNDS_DEPOSIT_CLICKED', - RAMPS_BUTTON_CLICKED: 'RAMPS_BUTTON_CLICKED', - }, - useMetrics: jest.fn(() => ({ - trackEvent: jest.fn(), - createEventBuilder: jest.fn(() => ({ - addProperties: jest.fn().mockReturnThis(), - build: jest.fn(), - })), - })), -})); - -jest.mock('../../../../../util/trace', () => ({ - trace: jest.fn(), - TraceName: { - LoadDepositExperience: 'Load Deposit Experience', - }, -})); - -jest.mock('@react-navigation/native', () => { - const actualNav = jest.requireActual('@react-navigation/native'); - return { - ...actualNav, - useNavigation: () => ({ - navigate: jest.fn(), - }), - }; -}); - -const mockStore = configureMockStore(); - -const initialState = { - engine: { - backgroundState: { - ...backgroundState, - NetworkController: { - ...mockNetworkState({ - chainId: '0x1', - id: 'mainnet', - nickname: 'Ethereum Mainnet', - ticker: 'ETH', - }), - }, - }, - }, - settings: { - primaryCurrency: 'usd', - }, -}; - -const store = mockStore(initialState); - -describe('TokenListFooter', () => { - beforeEach(() => { - (useRampNetwork as jest.Mock).mockReturnValue([true, true]); - (useSelectedAccountMultichainBalances as jest.Mock).mockReturnValue({ - selectedAccountMultichainBalance: { - totalFiatBalance: 0, - }, - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - const renderComponent = (initialStore = store) => - render( - - - , - ); - - it('renders correctly', () => { - const { toJSON } = renderComponent(); - expect(toJSON()).toMatchSnapshot(); - }); - - it('does not render the deposit button when total account balance is greater than zero', () => { - (useSelectedAccountMultichainBalances as jest.Mock).mockReturnValue({ - selectedAccountMultichainBalance: { - totalFiatBalance: 100, - }, - }); - const { queryByText } = renderComponent(); - expect(queryByText(strings('wallet.add_funds'))).toBeNull(); - expect( - queryByText(strings('wallet.fund_your_wallet_to_get_started')), - ).toBeNull(); - }); - - it('tracks the CARD_ADD_FUNDS_DEPOSIT_CLICKED and RAMPS_BUTTON_CLICKED events when the deposit button is pressed', async () => { - const { getByText } = renderComponent(); - - fireEvent.press(getByText(strings('wallet.add_funds'))); - - await waitFor(() => { - expect(MetaMetricsEvents.CARD_ADD_FUNDS_DEPOSIT_CLICKED).toBeDefined(); - expect(MetaMetricsEvents.RAMPS_BUTTON_CLICKED).toBeDefined(); - }); - }); -}); diff --git a/app/components/UI/Tokens/TokenList/TokenListFooter/index.tsx b/app/components/UI/Tokens/TokenList/TokenListFooter/index.tsx deleted file mode 100644 index 6c936386bf47..000000000000 --- a/app/components/UI/Tokens/TokenList/TokenListFooter/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from 'react'; -import createStyles from '../../styles'; -import { useTheme } from '../../../../../util/theme'; -import { View } from 'react-native'; -import TextComponent, { - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import { strings } from '../../../../../../locales/i18n'; -import { useSelector } from 'react-redux'; -import Button, { - ButtonVariants, - ButtonSize, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; -import { useNavigation } from '@react-navigation/native'; -import { - MetaMetricsEvents, - useMetrics, -} from '../../../../../components/hooks/useMetrics'; -import { getDecimalChainId } from '../../../../../util/networks'; -import { selectChainId } from '../../../../../selectors/networkController'; -import { trace, TraceName } from '../../../../../util/trace'; -import { useSelectedAccountMultichainBalances } from '../../../../hooks/useMultichainBalances'; -import { createDepositNavigationDetails } from '../../../Ramp/Deposit/routes/utils'; - -export const TokenListFooter = () => { - const chainId = useSelector(selectChainId); - const navigation = useNavigation(); - const { colors } = useTheme(); - const styles = createStyles(colors); - const { trackEvent, createEventBuilder } = useMetrics(); - const { selectedAccountMultichainBalance } = - useSelectedAccountMultichainBalances(); - - const shouldShowFooter = - selectedAccountMultichainBalance?.totalFiatBalance === 0; - - const goToDeposit = () => { - navigation.navigate(...createDepositNavigationDetails()); - - trackEvent( - createEventBuilder( - MetaMetricsEvents.CARD_ADD_FUNDS_DEPOSIT_CLICKED, - ).build(), - ); - - trackEvent( - createEventBuilder(MetaMetricsEvents.RAMPS_BUTTON_CLICKED) - .addProperties({ - text: 'Deposit', - location: 'TokenListFooter', - chain_id_destination: getDecimalChainId(chainId), - ramp_type: 'DEPOSIT', - }) - .build(), - ); - - trace({ - name: TraceName.LoadDepositExperience, - }); - }; - - return ( - <> - {/* render buy button */} - {shouldShowFooter && ( - - - {strings('wallet.fund_your_wallet_to_get_started')} - - + */} + {!isTokensLoading && + renderedTokenKeys.length === 0 && + progressiveTokens.length === 0 ? ( + + ) : ( + <> + {isTokensLoading && progressiveTokens.length === 0 && ( + + )} + {(progressiveTokens.length > 0 || renderedTokenKeys.length > 0) && ( + + )} + + )} + {showScamWarningModal && ( + + )} + } + title={strings('wallet.remove_token_title')} + options={[strings('wallet.remove'), strings('wallet.cancel')]} + cancelButtonIndex={1} + destructiveButtonIndex={0} + onPress={onActionSheetPress} + /> + } + title={strings('wallet.remove_token_title')} + options={[strings('wallet.remove'), strings('wallet.cancel')]} + cancelButtonIndex={1} + destructiveButtonIndex={0} + onPress={onActionSheetPress} + /> + + ); +}); + +TokensTabView.displayName = 'TokensTabView'; + +export default TokensTabView; diff --git a/app/components/UI/Tokens/TokensTabView/index.ts b/app/components/UI/Tokens/TokensTabView/index.ts new file mode 100644 index 000000000000..176cf06dc820 --- /dev/null +++ b/app/components/UI/Tokens/TokensTabView/index.ts @@ -0,0 +1 @@ +export { default } from './TokensTabView'; diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx index c36cb5b046ad..6d2a2594cd5d 100644 --- a/app/components/UI/Tokens/index.tsx +++ b/app/components/UI/Tokens/index.tsx @@ -1,325 +1,2 @@ -import React, { - useRef, - useState, - LegacyRef, - memo, - useCallback, - useEffect, - useMemo, -} from 'react'; -import { View, InteractionManager } from 'react-native'; -import ActionSheet from '@metamask/react-native-actionsheet'; -import { useSelector } from 'react-redux'; -import { useTheme } from '../../../util/theme'; -import { useMetrics } from '../../../components/hooks/useMetrics'; -import { - selectChainId, - selectEvmNetworkConfigurationsByChainId, - selectNativeNetworkCurrencies, -} from '../../../selectors/networkController'; -import { getDecimalChainId } from '../../../util/networks'; -import createStyles from './styles'; -import { TokenList } from './TokenList'; -import { TokenI } from './types'; -import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors'; -import { strings } from '../../../../locales/i18n'; -import { refreshTokens, removeEvmToken, goToAddEvmToken } from './util'; -import { useNavigation } from '@react-navigation/native'; -import { StackNavigationProp } from '@react-navigation/stack'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { Button, ButtonVariant } from '@metamask/design-system-react-native'; -import Routes from '../../../constants/navigation/Routes'; -import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; -import { TokenListControlBar } from './TokenListControlBar'; -import { selectSelectedInternalAccountId } from '../../../selectors/accountsController'; -import { ScamWarningModal } from './TokenList/ScamWarningModal'; -import { selectSortedTokenKeys } from '../../../selectors/tokenList'; -import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts'; -import { selectSortedAssetsBySelectedAccountGroup } from '../../../selectors/assets/assets-list'; -import Loader from '../../../component-library/components-temp/Loader'; -import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts'; -import { SolScope } from '@metamask/keyring-api'; - -interface TokenListNavigationParamList { - AddAsset: { assetType: string }; - TokensFullView: undefined; - [key: string]: undefined | object; -} - -const Tokens = memo(() => { - const navigation = - useNavigation< - StackNavigationProp - >(); - const { colors } = useTheme(); - const { trackEvent, createEventBuilder } = useMetrics(); - - // evm - const evmNetworkConfigurationsByChainId = useSelector( - selectEvmNetworkConfigurationsByChainId, - ); - const currentChainId = useSelector(selectChainId); - const nativeCurrencies = useSelector(selectNativeNetworkCurrencies); - const isEvmSelected = useSelector(selectIsEvmNetworkSelected); - - const actionSheet = useRef(); - const [tokenToRemove, setTokenToRemove] = useState(); - const [refreshing, setRefreshing] = useState(false); - const selectedAccountId = useSelector(selectSelectedInternalAccountId); - - const selectedSolanaAccount = - useSelector(selectSelectedInternalAccountByScope)(SolScope.Mainnet) || null; - const isSolanaSelected = selectedSolanaAccount !== null; - - const [showScamWarningModal, setShowScamWarningModal] = useState(false); - const [isTokensLoading, setIsTokensLoading] = useState(true); - const [renderedTokenKeys, setRenderedTokenKeys] = useState< - typeof sortedTokenKeys - >([]); - const [progressiveTokens, setProgressiveTokens] = useState< - typeof sortedTokenKeys - >([]); - const lastTokenDataRef = useRef(); - - const styles = useMemo(() => createStyles(colors), [colors]); - - // BIP44 MAINTENANCE: Once stable, only use selectSortedAssetsBySelectedAccountGroup - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); - - // Memoize selector computation for better performance - const sortedTokenKeys = useSelector( - useMemo( - () => - isMultichainAccountsState2Enabled - ? selectSortedAssetsBySelectedAccountGroup - : selectSortedTokenKeys, - [isMultichainAccountsState2Enabled], - ), - ); - - // High-performance async rendering with progressive loading - useEffect(() => { - // Debounce rapid data changes - if ( - JSON.stringify(sortedTokenKeys) === - JSON.stringify(lastTokenDataRef.current) - ) { - return; - } - lastTokenDataRef.current = sortedTokenKeys; - - if (sortedTokenKeys?.length) { - setIsTokensLoading(true); - setProgressiveTokens([]); - - // Use InteractionManager for better performance than setTimeout - InteractionManager.runAfterInteractions(() => { - const CHUNK_SIZE = 20; // Process 20 tokens at a time - const chunks: (typeof sortedTokenKeys)[] = []; - - for (let i = 0; i < sortedTokenKeys.length; i += CHUNK_SIZE) { - chunks.push(sortedTokenKeys.slice(i, i + CHUNK_SIZE)); - } - - // Progressive loading for better perceived performance - let currentChunkIndex = 0; - let accumulatedTokens: typeof sortedTokenKeys = []; - - const processChunk = () => { - if (currentChunkIndex < chunks.length) { - accumulatedTokens = [ - ...accumulatedTokens, - ...chunks[currentChunkIndex], - ]; - setProgressiveTokens([...accumulatedTokens]); - currentChunkIndex++; - - // Process next chunk after allowing UI to update - requestAnimationFrame(() => { - if (currentChunkIndex < chunks.length) { - setTimeout(processChunk, 0); - } else { - // All chunks processed - const tokenMap = new Map(); - accumulatedTokens.forEach((item) => { - const staked = item.isStaked ? 'staked' : 'unstaked'; - const key = `${item.address}-${item.chainId}-${staked}`; - tokenMap.set(key, item); - }); - const deduped = Array.from(tokenMap.values()); - setRenderedTokenKeys(deduped); - setIsTokensLoading(false); - } - }); - } - }; - - processChunk(); - }); - - return; - } - - // No tokens to render - setRenderedTokenKeys([]); - setProgressiveTokens([]); - setIsTokensLoading(false); - }, [sortedTokenKeys]); - - const showRemoveMenu = useCallback( - (token: TokenI) => { - // remove token currently only supported on evm - if (isEvmSelected && actionSheet.current) { - setTokenToRemove(token); - actionSheet.current.show(); - } - }, - [isEvmSelected], - ); - - const onRefresh = useCallback(async () => { - setRefreshing(true); - - // Use InteractionManager for better performance during refresh - InteractionManager.runAfterInteractions(() => { - refreshTokens({ - isSolanaSelected, - evmNetworkConfigurationsByChainId, - nativeCurrencies, - selectedAccountId, - }); - setRefreshing(false); - }); - }, [ - isSolanaSelected, - evmNetworkConfigurationsByChainId, - nativeCurrencies, - selectedAccountId, - ]); - - const removeToken = useCallback(async () => { - // remove token currently only supported on evm - if (isEvmSelected && tokenToRemove) { - await removeEvmToken({ - tokenToRemove, - currentChainId, - trackEvent, - strings, - getDecimalChainId, - createEventBuilder, // Now passed as a prop - }); - } - }, [ - isEvmSelected, - tokenToRemove, - currentChainId, - trackEvent, - createEventBuilder, - ]); - - const goToAddToken = useCallback(() => { - // add token currently only support on evm - if (isEvmSelected) { - goToAddEvmToken({ - navigation, - trackEvent, - createEventBuilder, - getDecimalChainId, - currentChainId, - }); - } - }, [ - isEvmSelected, - navigation, - trackEvent, - createEventBuilder, - currentChainId, - ]); - - const onActionSheetPress = useCallback( - (index: number) => { - if (index === 0) { - removeToken(); - } - }, - [removeToken], - ); - - const handleScamWarningModal = useCallback(() => { - setShowScamWarningModal((prev) => !prev); - }, []); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const navigateToTokensFullView = useCallback(() => { - navigation.navigate(Routes.WALLET.TOKENS_FULL_VIEW); - }, [navigation]); - - return ( - - - {/* Uncomment these lines to review TokensFullView */} - {/* - - */} - {!isTokensLoading && - renderedTokenKeys.length === 0 && - progressiveTokens.length === 0 ? ( - - ) : ( - <> - {isTokensLoading && progressiveTokens.length === 0 && ( - - )} - {(progressiveTokens.length > 0 || renderedTokenKeys.length > 0) && ( - - )} - - )} - {showScamWarningModal && ( - - )} - } - title={strings('wallet.remove_token_title')} - options={[strings('wallet.remove'), strings('wallet.cancel')]} - cancelButtonIndex={1} - destructiveButtonIndex={0} - onPress={onActionSheetPress} - /> - } - title={strings('wallet.remove_token_title')} - options={[strings('wallet.remove'), strings('wallet.cancel')]} - cancelButtonIndex={1} - destructiveButtonIndex={0} - onPress={onActionSheetPress} - /> - - ); -}); - -Tokens.displayName = 'Tokens'; - -export default Tokens; +export { default as TokensTabView } from './TokensTabView'; +export { default as TokensFullView } from './TokensFullView'; diff --git a/app/components/UI/Tokens/styles.ts b/app/components/UI/Tokens/styles.ts index d7d045257845..4ad61845d33e 100644 --- a/app/components/UI/Tokens/styles.ts +++ b/app/components/UI/Tokens/styles.ts @@ -1,21 +1,10 @@ -import { StyleSheet, TextStyle } from 'react-native'; +import { StyleSheet } from 'react-native'; import { fontStyles } from '../../../styles/common'; import { Colors } from 'app/util/theme/models'; -import { typography } from '@metamask/design-tokens'; -import { - getFontFamily, - TextVariant, -} from '../../../component-library/components/Texts/Text'; const createStyles = (colors: Colors) => StyleSheet.create({ - wrapper: { - backgroundColor: colors.background.default, - flex: 1, - }, - bottomSheetWrapper: { - alignItems: 'flex-start', - }, + // Bottom Sheet Styles bottomSheetTitle: { alignSelf: 'center', paddingTop: 16, @@ -24,6 +13,36 @@ const createStyles = (colors: Colors) => bottomSheetText: { width: '100%', }, + bottomModal: { + justifyContent: 'flex-end', + margin: 0, + }, + box: { + backgroundColor: colors.background.default, + paddingHorizontal: 8, + paddingBottom: 20, + borderWidth: 0, + padding: 0, + }, + boxContent: { + backgroundColor: colors.background.default, + paddingBottom: 21, + paddingTop: 0, + borderWidth: 0, + }, + notch: { + width: 40, + height: 4, + borderRadius: 2, + backgroundColor: colors.border.muted, + alignSelf: 'center', + marginTop: 4, + }, + editNetworkButton: { + width: '100%', + }, + + // Empty State Styles emptyView: { backgroundColor: colors.background.default, justifyContent: 'center', @@ -37,37 +56,16 @@ const createStyles = (colors: Colors) => emptyTokensViewText: { fontFamily: 'Geist Medium', }, - text: { - fontSize: 20, - color: colors.text.default, - ...fontStyles.normal, - }, - add: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - addText: { - fontSize: 14, - color: colors.primary.default, - ...fontStyles.normal, - }, - tokensDetectedButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginTop: 16, - }, - tokensDetectedText: { - fontSize: 14, - color: colors.primary.default, - ...fontStyles.normal, + + // Token List Item Styles + ethLogo: { + width: 40, + height: 40, + borderRadius: 20, + overflow: 'hidden', }, - footer: { - flex: 1, - paddingBottom: 30, - alignItems: 'center', - marginTop: 9, + badge: { + marginTop: 8, }, balances: { flex: 1, @@ -79,36 +77,6 @@ const createStyles = (colors: Colors) => ...fontStyles.normal, textTransform: 'uppercase', }, - balanceFiatTokenError: { - textTransform: 'capitalize', - }, - ethLogo: { - width: 40, - height: 40, - borderRadius: 20, - overflow: 'hidden', - }, - emptyText: { - color: colors.text.alternative, - marginBottom: 8, - fontSize: 14, - }, - skeleton: { width: 50 }, - buy: { - alignItems: 'center', - marginVertical: 5, - marginHorizontal: 15, - }, - buyTitle: { - marginVertical: 5, - textAlign: 'center', - }, - buyButton: { - marginVertical: 5, - }, - centered: { - textAlign: 'center', - }, assetName: { flexDirection: 'row', gap: 8, @@ -118,130 +86,29 @@ const createStyles = (colors: Colors) => alignItems: 'center', alignContent: 'center', }, - stakeButton: { - flexDirection: 'row', - }, - dot: { - marginLeft: 2, - marginRight: 2, - }, - sprout: { - marginTop: 3, - marginLeft: 2, - }, + + // Portfolio Balance Styles portfolioBalance: { marginHorizontal: 16, }, - portfolioLink: { marginLeft: 8 }, - bottomModal: { - justifyContent: 'flex-end', - margin: 0, - }, - box: { - backgroundColor: colors.background.default, - paddingHorizontal: 8, - paddingBottom: 20, - borderWidth: 0, - padding: 0, - }, - boxContent: { - backgroundColor: colors.background.default, - paddingBottom: 21, - paddingTop: 0, - borderWidth: 0, - }, - boxContentHeader: { - borderWidth: 0, - color: colors.text.default, - alignItems: 'center', - }, - textMoadlHeader: { - ...typography.sBodyMDBold, - fontFamily: getFontFamily(TextVariant.BodyMDBold), - fontSize: 18, - } as TextStyle, - editNetworkButton: { - width: '100%', - }, - notch: { - width: 40, - height: 4, - borderRadius: 2, - backgroundColor: colors.border.muted, - alignSelf: 'center', - marginTop: 4, - }, - actionBarWrapper: { - flexDirection: 'row', - justifyContent: 'space-between', - }, - controlButtonOuterWrapper: { - flexDirection: 'row', - width: '100%', - justifyContent: 'space-between', - alignItems: 'center', - }, - controlButtonInnerWrapper: { - flexDirection: 'row', - gap: 12, - }, - controlButton: { - backgroundColor: colors.background.default, - borderColor: colors.border.default, - marginRight: 4, - maxWidth: '60%', - paddingHorizontal: 0, - }, - controlButtonDisabled: { - backgroundColor: colors.background.default, - borderColor: colors.border.default, - marginRight: 4, - maxWidth: '60%', - paddingHorizontal: 0, - opacity: 0.5, - }, - controlButtonText: { - color: colors.text.default, - }, - controlIconButton: { - backgroundColor: colors.background.default, - }, - controlIconButtonDisabled: { - backgroundColor: colors.background.default, - borderColor: colors.border.default, - borderStyle: 'solid', - borderWidth: 1, - marginLeft: 5, - marginRight: 5, - borderRadius: 50, - width: 50, - height: 40, - opacity: 0.5, - }, balanceContainer: { flexDirection: 'row', alignItems: 'center', }, - privacyIcon: { - marginLeft: 8, - }, loaderWrapper: { paddingLeft: 40, }, - portfolioButtonContainer: { - alignItems: 'center', + + // Control Bar Styles + controlIconButton: { + backgroundColor: colors.background.default, }, + + // Network Image Styles networkImageContainer: { position: 'absolute', right: 0, }, - badge: { - marginTop: 8, - }, - viewAllTokensButton: { - paddingHorizontal: 16, - paddingVertical: 8, - }, }); export default createStyles; diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 9937a57f58b4..dbb3e969a1aa 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -32,7 +32,7 @@ import StorageWrapper from '../../../store/storage-wrapper'; import { baseStyles } from '../../../styles/common'; import { PERPS_GTM_MODAL_SHOWN } from '../../../constants/storage'; import { getWalletNavbarOptions } from '../../UI/Navbar'; -import Tokens from '../../UI/Tokens'; +import { TokensTabView } from '../../UI/Tokens'; import { NavigationProp, @@ -392,7 +392,7 @@ const WalletTokensTabView = React.memo((props: WalletTokensTabViewProps) => { // Build tabs array dynamically based on enabled features const tabsToRender = useMemo(() => { - const tabs = []; + const tabs = []; if (isPerpsEnabled) { tabs.push( From 9701ea97378d70e2505313e292d4fc09f13a634f Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Sun, 26 Oct 2025 10:24:06 -0700 Subject: [PATCH 8/8] Added maxItems to TokenList --- .../UI/Tokens/TokenList/TokenList.test.tsx | 349 ++++++++++++++++++ .../TokenList/{index.tsx => TokenList.tsx} | 113 +++--- .../UI/Tokens/TokenList/TokenList.types.ts | 62 ++++ app/components/UI/Tokens/TokenList/index.ts | 2 + .../UI/Tokens/TokensTabView/TokensTabView.tsx | 21 +- 5 files changed, 481 insertions(+), 66 deletions(-) create mode 100644 app/components/UI/Tokens/TokenList/TokenList.test.tsx rename app/components/UI/Tokens/TokenList/{index.tsx => TokenList.tsx} (58%) create mode 100644 app/components/UI/Tokens/TokenList/TokenList.types.ts create mode 100644 app/components/UI/Tokens/TokenList/index.ts diff --git a/app/components/UI/Tokens/TokenList/TokenList.test.tsx b/app/components/UI/Tokens/TokenList/TokenList.test.tsx new file mode 100644 index 000000000000..245e666453ce --- /dev/null +++ b/app/components/UI/Tokens/TokenList/TokenList.test.tsx @@ -0,0 +1,349 @@ +import React from 'react'; +import { waitFor } from '@testing-library/react-native'; +import { TokenList } from './TokenList'; +import { FlashListAssetKey } from './TokenList.types'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import { createStackNavigator } from '@react-navigation/stack'; +import { WalletViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/WalletView.selectors'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; +import { useNavigation } from '@react-navigation/native'; + +// Mock external dependencies +jest.mock('../../../../util/theme', () => ({ + useTheme: () => ({ + colors: { + primary: { default: '#037DD6' }, + icon: { default: '#037DD6' }, + }, + }), +})); + +jest.mock('../../../../selectors/preferencesController', () => ({ + selectIsTokenNetworkFilterEqualCurrentNetwork: jest.fn(() => true), + selectPrivacyMode: jest.fn(() => false), +})); + +jest.mock( + '../../../../selectors/featureFlagController/multichainAccounts', + () => ({ + selectMultichainAccountsState2Enabled: jest.fn(() => false), + }), +); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: jest.fn(), + }), +})); + +const mockTokenKeys: FlashListAssetKey[] = [ + { + address: '0x123', + chainId: '0x1', + isStaked: false, + }, + { + address: '0x456', + chainId: '0x1', + isStaked: true, + }, +]; + +const mockProps = { + tokenKeys: mockTokenKeys, + refreshing: false, + onRefresh: jest.fn(), + showRemoveMenu: jest.fn(), + showPercentageChange: true, + setShowScamWarningModal: jest.fn(), + flashListProps: {}, + maxItems: undefined as number | undefined, +}; + +const Stack = createStackNavigator(); + +const renderComponent = (props = mockProps) => + renderWithProvider( + + + {() => } + + , + ); + +describe('TokenList', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders token list when tokens are available', () => { + const { getByTestId } = renderComponent(); + + expect( + getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST), + ).toBeOnTheScreen(); + }); + + it('renders empty state when no tokens are available', () => { + const propsWithNoTokens = { + ...mockProps, + tokenKeys: [], + }; + + const { getByText } = renderComponent(propsWithNoTokens); + + expect(getByText(strings('wallet.no_tokens'))).toBeOnTheScreen(); + expect( + getByText(strings('wallet.show_tokens_without_balance')), + ).toBeOnTheScreen(); + }); + + it('renders empty state when tokenKeys is undefined', () => { + const propsWithUndefinedTokens = { + ...mockProps, + tokenKeys: undefined as unknown as FlashListAssetKey[], + }; + + const { getByText } = renderComponent(propsWithUndefinedTokens); + + expect(getByText(strings('wallet.no_tokens'))).toBeOnTheScreen(); + expect( + getByText(strings('wallet.show_tokens_without_balance')), + ).toBeOnTheScreen(); + }); + + it('calls onRefresh when refresh control is triggered', async () => { + const mockOnRefresh = jest.fn(); + const propsWithRefresh = { + ...mockProps, + refreshing: true, + onRefresh: mockOnRefresh, + }; + + const { getByTestId } = renderComponent(propsWithRefresh); + + const refreshControl = getByTestId( + WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST, + ); + + // Simulate refresh control trigger + await waitFor(() => { + refreshControl.props.refreshControl.props.onRefresh(); + }); + + expect(mockOnRefresh).toHaveBeenCalledTimes(1); + }); + + it('renders with correct key extractor for tokens', () => { + const { getByTestId } = renderComponent(); + + const flashList = getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST); + + // Verify the keyExtractor function works correctly + const key1 = flashList.props.keyExtractor(mockTokenKeys[0], 0); + const key2 = flashList.props.keyExtractor(mockTokenKeys[1], 1); + + expect(key1).toBe('0x123-0x1-unstaked-0'); + expect(key2).toBe('0x456-0x1-staked-1'); + }); + + it('passes correct props to TokenListItem components', () => { + const { getByTestId } = renderComponent(); + + const flashList = getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST); + + // Verify renderItem function receives correct props + const renderedItem = flashList.props.renderItem({ item: mockTokenKeys[0] }); + + expect(renderedItem).toBeDefined(); + }); + + it('handles showPercentageChange prop correctly', () => { + const propsWithoutPercentageChange = { + ...mockProps, + showPercentageChange: false, + }; + + const { getByTestId } = renderComponent(propsWithoutPercentageChange); + + expect( + getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST), + ).toBeOnTheScreen(); + }); + + it('applies custom flashListProps when provided', () => { + const customFlashListProps = { + estimatedItemSize: 100, + scrollEnabled: false, + }; + + const propsWithCustomFlashListProps = { + ...mockProps, + flashListProps: customFlashListProps, + }; + + const { getByTestId } = renderComponent(propsWithCustomFlashListProps); + + const flashList = getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST); + + expect(flashList.props.estimatedItemSize).toBe(100); + expect(flashList.props.scrollEnabled).toBe(false); + }); + + it('handles empty state link press', () => { + const propsWithNoTokens = { + ...mockProps, + tokenKeys: [], + }; + + const { getByText } = renderComponent(propsWithNoTokens); + + const linkText = getByText(strings('wallet.show_tokens_without_balance')); + + // Verify the link is pressable + expect(linkText.props.onPress).toBeDefined(); + }); + + it('renders with correct viewability configuration', () => { + const { getByTestId } = renderComponent(); + + const flashList = getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST); + + expect(flashList.props.viewabilityConfig).toEqual({ + itemVisiblePercentThreshold: 50, + minimumViewTime: 1000, + }); + }); + + it('renders with correct deceleration rate', () => { + const { getByTestId } = renderComponent(); + + const flashList = getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST); + + expect(flashList.props.decelerationRate).toBe('fast'); + }); + + it('renders with correct extraData', () => { + const { getByTestId } = renderComponent(); + + const flashList = getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST); + + expect(flashList.props.extraData).toEqual({ + isTokenNetworkFilterEqualCurrentNetwork: true, + }); + }); + + it('displays all items when maxItems is undefined', () => { + const propsWithUndefinedMaxItems = { + ...mockProps, + maxItems: undefined, + }; + + const { getByTestId } = renderComponent(propsWithUndefinedMaxItems); + + const flashList = getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST); + + expect(flashList.props.data).toHaveLength(2); + expect(flashList.props.data).toEqual(mockTokenKeys); + }); + + it('displays limited items when maxItems is defined', () => { + const propsWithMaxItems = { + ...mockProps, + maxItems: 1, + }; + + const { getByTestId } = renderComponent(propsWithMaxItems); + + const flashList = getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST); + + expect(flashList.props.data).toHaveLength(1); + expect(flashList.props.data).toEqual([mockTokenKeys[0]]); + }); + + it('displays empty state when maxItems is 0', () => { + const propsWithZeroMaxItems = { + ...mockProps, + maxItems: 0, + }; + + const { getByText } = renderComponent(propsWithZeroMaxItems); + + expect(getByText(strings('wallet.no_tokens'))).toBeOnTheScreen(); + expect( + getByText(strings('wallet.show_tokens_without_balance')), + ).toBeOnTheScreen(); + }); + + it('displays all items when maxItems exceeds available items', () => { + const propsWithLargeMaxItems = { + ...mockProps, + maxItems: 10, + }; + + const { getByTestId } = renderComponent(propsWithLargeMaxItems); + + const flashList = getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST); + + expect(flashList.props.data).toHaveLength(2); + expect(flashList.props.data).toEqual(mockTokenKeys); + }); + + it('shows "View all tokens" button when maxItems is set and there are more items', () => { + const propsWithMaxItems = { + ...mockProps, + maxItems: 1, + }; + + const { getByText } = renderComponent(propsWithMaxItems); + + expect(getByText(strings('wallet.view_all_tokens'))).toBeOnTheScreen(); + }); + + it('does not show "View all tokens" button when maxItems is undefined', () => { + const propsWithUndefinedMaxItems = { + ...mockProps, + maxItems: undefined, + }; + + const { queryByText } = renderComponent(propsWithUndefinedMaxItems); + + expect( + queryByText(strings('wallet.view_all_tokens')), + ).not.toBeOnTheScreen(); + }); + + it('does not show "View all tokens" button when items do not exceed maxItems', () => { + const propsWithLargeMaxItems = { + ...mockProps, + maxItems: 10, + }; + + const { queryByText } = renderComponent(propsWithLargeMaxItems); + + expect( + queryByText(strings('wallet.view_all_tokens')), + ).not.toBeOnTheScreen(); + }); + + it('calls navigation.navigate when "View all tokens" button is pressed', () => { + const mockNavigate = jest.fn(); + jest.mocked(useNavigation).mockReturnValue({ + navigate: mockNavigate, + } as unknown as ReturnType); + + const propsWithMaxItems = { + ...mockProps, + maxItems: 1, + }; + + const { getByText } = renderComponent(propsWithMaxItems); + + const button = getByText(strings('wallet.view_all_tokens')); + button.props.onPress(); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.TOKENS_FULL_VIEW); + }); +}); diff --git a/app/components/UI/Tokens/TokenList/index.tsx b/app/components/UI/Tokens/TokenList/TokenList.tsx similarity index 58% rename from app/components/UI/Tokens/TokenList/index.tsx rename to app/components/UI/Tokens/TokenList/TokenList.tsx index 1be0a5b3b8dd..15f415cac280 100644 --- a/app/components/UI/Tokens/TokenList/index.tsx +++ b/app/components/UI/Tokens/TokenList/TokenList.tsx @@ -1,6 +1,6 @@ -import React, { useCallback, useLayoutEffect, useRef } from 'react'; +import React, { useCallback, useLayoutEffect, useRef, useMemo } from 'react'; import { View, RefreshControl } from 'react-native'; -import { FlashList, FlashListRef, FlashListProps } from '@shopify/flash-list'; +import { FlashList, FlashListRef } from '@shopify/flash-list'; import { useSelector } from 'react-redux'; import { useTheme } from '../../../../util/theme'; import { @@ -11,29 +11,18 @@ import createStyles from '../styles'; import TextComponent, { TextColor, } from '../../../../component-library/components/Texts/Text'; -import { TokenI } from '../types'; import { strings } from '../../../../../locales/i18n'; import { TokenListItem, TokenListItemBip44 } from './TokenListItem'; import { WalletViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/WalletView.selectors'; import { useNavigation } from '@react-navigation/native'; import Routes from '../../../../constants/navigation/Routes'; import { selectMultichainAccountsState2Enabled } from '../../../../selectors/featureFlagController/multichainAccounts'; - -export interface FlashListAssetKey { - address: string; - chainId: string | undefined; - isStaked: boolean | undefined; -} - -interface TokenListProps { - tokenKeys: FlashListAssetKey[]; - refreshing: boolean; - onRefresh: () => void; - showRemoveMenu: (arg: TokenI) => void; - showPercentageChange?: boolean; - setShowScamWarningModal: () => void; - flashListProps?: Partial>; -} +import { FlashListAssetKey, TokenListProps } from './TokenList.types'; +import { + Box, + Button, + ButtonVariant, +} from '@metamask/design-system-react-native'; const TokenListComponent = ({ tokenKeys, @@ -43,6 +32,7 @@ const TokenListComponent = ({ showPercentageChange = true, setShowScamWarningModal, flashListProps, + maxItems, }: TokenListProps) => { const { colors } = useTheme(); const privacyMode = useSelector(selectPrivacyMode); @@ -73,6 +63,10 @@ const TokenListComponent = ({ }); }; + const handleViewAllTokens = useCallback(() => { + navigation.navigate(Routes.WALLET.TOKENS_FULL_VIEW); + }, [navigation]); + const renderTokenListItem = useCallback( ({ item }: { item: FlashListAssetKey }) => ( { - const staked = item.isStaked ? 'staked' : 'unstaked'; - return `${item.address}-${item.chainId}-${staked}-${idx}`; - }} - decelerationRate="fast" - refreshControl={ - - } - extraData={{ isTokenNetworkFilterEqualCurrentNetwork }} - {...flashListProps} - /> + // Apply maxItems limit if specified + const displayTokenKeys = useMemo(() => { + if (maxItems === undefined) { + return tokenKeys; + } + return tokenKeys.slice(0, maxItems); + }, [tokenKeys, maxItems]); + + // Determine if we should show the "View all tokens" button + const shouldShowViewAllButton = useMemo( + () => maxItems !== undefined && tokenKeys.length > maxItems, + [maxItems, tokenKeys.length], + ); + + return displayTokenKeys?.length ? ( + + { + const staked = item.isStaked ? 'staked' : 'unstaked'; + return `${item.address}-${item.chainId}-${staked}-${idx}`; + }} + decelerationRate="fast" + refreshControl={ + + } + extraData={{ isTokenNetworkFilterEqualCurrentNetwork }} + {...flashListProps} + /> + {shouldShowViewAllButton && ( + + + + )} + ) : ( diff --git a/app/components/UI/Tokens/TokenList/TokenList.types.ts b/app/components/UI/Tokens/TokenList/TokenList.types.ts new file mode 100644 index 000000000000..9aab7a12ada7 --- /dev/null +++ b/app/components/UI/Tokens/TokenList/TokenList.types.ts @@ -0,0 +1,62 @@ +// Third party dependencies. +import { FlashListProps } from '@shopify/flash-list'; + +// Internal dependencies. +import { TokenI } from '../types'; + +/** + * FlashList asset key interface for token list items. + */ +export interface FlashListAssetKey { + /** + * Token contract address. + */ + address: string; + /** + * Chain ID where the token exists. + */ + chainId: string | undefined; + /** + * Whether the token is staked. + */ + isStaked: boolean | undefined; +} + +/** + * TokenList component props. + */ +export interface TokenListProps { + /** + * Array of token keys to display in the list. + */ + tokenKeys: FlashListAssetKey[]; + /** + * Whether the list is currently refreshing. + */ + refreshing: boolean; + /** + * Callback function triggered when refresh is requested. + */ + onRefresh: () => void; + /** + * Callback function to show remove menu for a token. + */ + showRemoveMenu: (arg: TokenI) => void; + /** + * Whether to show percentage change for tokens. + * @default true + */ + showPercentageChange?: boolean; + /** + * Callback function to show scam warning modal. + */ + setShowScamWarningModal: () => void; + /** + * Additional props to pass to the FlashList component. + */ + flashListProps?: Partial>; + /** + * Maximum number of items to display. If undefined, displays all items. + */ + maxItems?: number; +} diff --git a/app/components/UI/Tokens/TokenList/index.ts b/app/components/UI/Tokens/TokenList/index.ts new file mode 100644 index 000000000000..5c1b21d591af --- /dev/null +++ b/app/components/UI/Tokens/TokenList/index.ts @@ -0,0 +1,2 @@ +export { TokenList } from './TokenList'; +export type { FlashListAssetKey, TokenListProps } from './TokenList.types'; diff --git a/app/components/UI/Tokens/TokensTabView/TokensTabView.tsx b/app/components/UI/Tokens/TokensTabView/TokensTabView.tsx index 638b9efc0004..3dfa5f8f14b0 100644 --- a/app/components/UI/Tokens/TokensTabView/TokensTabView.tsx +++ b/app/components/UI/Tokens/TokensTabView/TokensTabView.tsx @@ -25,12 +25,7 @@ import { refreshTokens, removeEvmToken, goToAddEvmToken } from '../util'; import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -import { - Button, - ButtonVariant, - Box, -} from '@metamask/design-system-react-native'; -import Routes from '../../../../constants/navigation/Routes'; +import { Box } from '@metamask/design-system-react-native'; import { selectIsEvmNetworkSelected } from '../../../../selectors/multichainNetworkController'; import { TokenListControlBar } from '../TokenListControlBar'; import { selectSelectedInternalAccountId } from '../../../../selectors/accountsController'; @@ -250,26 +245,12 @@ const TokensTabView = memo(() => { setShowScamWarningModal((prev) => !prev); }, []); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const navigateToTokensFullView = useCallback(() => { - navigation.navigate(Routes.WALLET.TOKENS_FULL_VIEW); - }, [navigation]); - return ( - {/* Uncomment these lines to review TokensFullView */} - {/* - - */} {!isTokensLoading && renderedTokenKeys.length === 0 && progressiveTokens.length === 0 ? (