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 && ( + + - - 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 ? ( @@ -271,6 +289,7 @@ const Tokens = memo(() => { onRefresh={onRefresh} showRemoveMenu={showRemoveMenu} setShowScamWarningModal={handleScamWarningModal} + flashListProps={{ scrollEnabled: true }} /> )} 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/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' && ( ({ + 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(); + }); +}); diff --git a/app/components/Views/TokensFullView/TokensFullView.tsx b/app/components/Views/TokensFullView/TokensFullView.tsx new file mode 100644 index 000000000000..0bc71315b2d0 --- /dev/null +++ b/app/components/Views/TokensFullView/TokensFullView.tsx @@ -0,0 +1,323 @@ +import React, { + useRef, + useState, + LegacyRef, + memo, + useCallback, + useEffect, + useMemo, +} from 'react'; +import { InteractionManager } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import ActionSheet from '@metamask/react-native-actionsheet'; +import { useSelector } from 'react-redux'; +import { useMetrics } from '../../../components/hooks/useMetrics'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + selectChainId, + selectEvmNetworkConfigurationsByChainId, + selectNativeNetworkCurrencies, +} from '../../../selectors/networkController'; +import { getDecimalChainId } from '../../../util/networks'; +import { TokenList } from '../../UI/Tokens/TokenList'; +import { TokenI } from '../../UI/Tokens/types'; +import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors'; +import { strings } from '../../../../locales/i18n'; +import { + refreshTokens, + removeEvmToken, + goToAddEvmToken, +} from '../../UI/Tokens/util'; +import { useNavigation } from '@react-navigation/native'; +import { StackNavigationProp } from '@react-navigation/stack'; +import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; +import { TokenListControlBar } from '../../UI/Tokens/TokenListControlBar'; +import { selectSelectedInternalAccountId } from '../../../selectors/accountsController'; +import { ScamWarningModal } from '../../UI/Tokens/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'; +import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { Box } from '@metamask/design-system-react-native'; + +interface TokenListNavigationParamList { + AddAsset: { assetType: string }; + [key: string]: undefined | object; +} + +const TokensFullView = memo(() => { + 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', 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",