From 3df28cc621be1fdd29fcb4449571b6427d5563f6 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Mon, 20 Jul 2026 11:29:11 +0100 Subject: [PATCH 1/5] feat: add watchlist full empty view --- .../WatchlistFullScreenView.styles.ts | 3 + .../WatchlistFullScreenView.test.tsx | 14 +- .../WatchlistFullScreenView.tsx | 16 +- .../WatchlistDefaultTokenCard.styles.ts | 41 ++++ .../WatchlistDefaultTokenCard.test.tsx | 94 ++++++++ .../WatchlistDefaultTokenCard.testIds.ts | 9 + .../WatchlistDefaultTokenCard.tsx | 119 ++++++++++ .../WatchlistDefaultTokenCard/index.ts | 1 + .../WatchlistEmptyCTA.styles.ts | 48 ++++ .../WatchlistEmptyCTA.test.tsx | 183 ++++++++++++++++ .../WatchlistEmptyCTA.testIds.ts | 6 + .../WatchlistEmptyCTA/WatchlistEmptyCTA.tsx | 205 ++++++++++++++++++ .../components/WatchlistEmptyCTA/index.ts | 1 + .../constants/defaultWatchlistTokens.ts | 12 + .../UI/Assets/watchlist/hooks/index.ts | 1 + .../useSuggestedWatchlistItemsQuery.test.ts | 75 +++++-- .../hooks/useSuggestedWatchlistItemsQuery.ts | 33 ++- .../hooks/useTokenWatchlistQuery.test.ts | 2 +- .../watchlist/hooks/useTokenWatchlistQuery.ts | 6 +- .../watchlist/hooks/watchlist-query-keys.ts | 7 +- .../utils/defaultWatchlistGeo.test.ts | 57 +++++ .../watchlist/utils/defaultWatchlistGeo.ts | 72 ++++++ locales/languages/en.json | 3 +- 23 files changed, 976 insertions(+), 32 deletions(-) create mode 100644 app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.styles.ts create mode 100644 app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.test.tsx create mode 100644 app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.testIds.ts create mode 100644 app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.tsx create mode 100644 app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/index.ts create mode 100644 app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.styles.ts create mode 100644 app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx create mode 100644 app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.testIds.ts create mode 100644 app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx create mode 100644 app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/index.ts create mode 100644 app/components/UI/Assets/watchlist/constants/defaultWatchlistTokens.ts create mode 100644 app/components/UI/Assets/watchlist/utils/defaultWatchlistGeo.test.ts create mode 100644 app/components/UI/Assets/watchlist/utils/defaultWatchlistGeo.ts diff --git a/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.styles.ts b/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.styles.ts index 38d6c10436a3..e3e349f8478b 100644 --- a/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.styles.ts +++ b/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.styles.ts @@ -24,6 +24,9 @@ const styleSheet = (params: { theme: Theme }) => { paddingHorizontal: 16, paddingTop: 8, }, + emptyContentContainer: { + flex: 1, + }, editableRow: { flexDirection: 'row', alignItems: 'center', diff --git a/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.test.tsx b/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.test.tsx index 851c058169d0..a14de88f3758 100644 --- a/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.test.tsx +++ b/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.test.tsx @@ -24,6 +24,15 @@ jest.mock('react-native-reanimated', () => { return Reanimated; }); +jest.mock('../../components/WatchlistEmptyCTA', () => { + const { View } = jest.requireActual('react-native'); + const ReactActual = jest.requireActual('react'); + const Mock = () => + ReactActual.createElement(View, { testID: 'watchlist-empty-cta' }); + Mock.displayName = 'WatchlistEmptyCTA'; + return Mock; +}); + jest.mock('./WatchlistEditableRow', () => { const { Text, View } = jest.requireActual('react-native'); const ReactActual = jest.requireActual('react'); @@ -130,9 +139,10 @@ describe('WatchlistFullScreenView', () => { expect(getByTestId('editable-row-eip155:1/erc20:0xeth')).toBeDefined(); }); - it('omits the token list when the watchlist is empty', () => { - const { queryByTestId } = render(); + it('renders empty CTA when the watchlist is empty', () => { + const { getByTestId, queryByTestId } = render(); + expect(getByTestId('watchlist-empty-cta')).toBeDefined(); expect( queryByTestId(WatchlistFullScreenViewSelectorsIDs.TOKEN_LIST), ).toBeNull(); diff --git a/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.tsx b/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.tsx index 959a15124b4a..c81b2e7ce2f2 100644 --- a/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.tsx +++ b/app/components/UI/Assets/watchlist/Views/WatchlistFullScreenView/WatchlistFullScreenView.tsx @@ -21,6 +21,7 @@ import TrendingTokensSkeleton from '../../../../Trending/components/TrendingToke import { strings } from '../../../../../../../locales/i18n'; import { WatchlistFullScreenViewSelectorsIDs } from './WatchlistFullScreenView.testIds'; import WatchlistEditableRow from './WatchlistEditableRow'; +import WatchlistEmptyCTA from '../../components/WatchlistEmptyCTA'; import styleSheet from './WatchlistFullScreenView.styles'; const SKELETON_COUNT = 5; @@ -119,7 +120,11 @@ const WatchlistFullScreenView = () => { } if (!hasItems) { - return null; + return ( + + + + ); } return ( @@ -145,7 +150,14 @@ const WatchlistFullScreenView = () => { ); - }, [displayTokens, hasItems, isEditMode, isLoading, styles.listContainer]); + }, [ + displayTokens, + hasItems, + isEditMode, + isLoading, + styles.emptyContentContainer, + styles.listContainer, + ]); return ( { + const { theme, vars } = params; + const { isSelected } = vars; + + return StyleSheet.create({ + card: { + flex: 1, + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'flex-start', + gap: 12, + padding: 16, + borderRadius: 12, + borderWidth: 1, + borderColor: isSelected + ? theme.colors.border.default + : theme.colors.border.muted, + backgroundColor: theme.colors.background.section, + }, + topRow: { + width: '100%', + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + }, + logoContainer: { + flexShrink: 0, + }, + checkboxContainer: { + flexShrink: 0, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.test.tsx b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.test.tsx new file mode 100644 index 000000000000..d6f65d822a84 --- /dev/null +++ b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import WatchlistDefaultTokenCard from './WatchlistDefaultTokenCard'; +import { + getWatchlistDefaultTokenCardTestId, + WatchlistDefaultTokenCardTestIds, +} from './WatchlistDefaultTokenCard.testIds'; +import type { WatchlistTokenWithBalance } from '../../utils/addBalanceToTokens'; + +jest.mock('../../../../Trending/components/TrendingTokenLogo', () => { + const { View } = jest.requireActual('react-native'); + const ReactActual = jest.requireActual('react'); + const Mock = ({ testID }: { testID?: string }) => + ReactActual.createElement(View, { testID: testID ?? 'token-logo' }); + Mock.displayName = 'TrendingTokenLogo'; + return Mock; +}); + +const makeToken = ( + overrides: Partial = {}, +): WatchlistTokenWithBalance => ({ + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + balance: '0', + isInWallet: false, + marketData: { + price: 3000, + pricePercentChange24h: 4.2, + }, + ...overrides, +}); + +describe('WatchlistDefaultTokenCard', () => { + it('renders symbol and price change', () => { + const token = makeToken(); + const { getByTestId } = render( + , + ); + + expect( + getByTestId(`${WatchlistDefaultTokenCardTestIds.SYMBOL}-${token.assetId}`) + .props.children, + ).toBe('ETH'); + expect( + getByTestId( + `${WatchlistDefaultTokenCardTestIds.PRICE_CHANGE}-${token.assetId}`, + ).props.children, + ).toBe('+4.20%'); + }); + + it('calls onToggle when the card is pressed', () => { + const onToggle = jest.fn(); + const token = makeToken(); + const { getByTestId } = render( + , + ); + + fireEvent.press( + getByTestId(getWatchlistDefaultTokenCardTestId(token.assetId)), + ); + + expect(onToggle).toHaveBeenCalledWith(String(token.assetId)); + }); + + it('calls onToggle when the checkbox is pressed', () => { + const onToggle = jest.fn(); + const token = makeToken(); + const { getByTestId } = render( + , + ); + + fireEvent.press( + getByTestId( + `${WatchlistDefaultTokenCardTestIds.CHECKBOX}-${token.assetId}`, + ), + ); + + expect(onToggle).toHaveBeenCalledWith(String(token.assetId)); + }); +}); diff --git a/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.testIds.ts b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.testIds.ts new file mode 100644 index 000000000000..7c4d485e7f2c --- /dev/null +++ b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.testIds.ts @@ -0,0 +1,9 @@ +export enum WatchlistDefaultTokenCardTestIds { + CARD = 'watchlist-default-token-card', + CHECKBOX = 'watchlist-default-token-card-checkbox', + SYMBOL = 'watchlist-default-token-card-symbol', + PRICE_CHANGE = 'watchlist-default-token-card-price-change', +} + +export const getWatchlistDefaultTokenCardTestId = (assetId: string): string => + `${WatchlistDefaultTokenCardTestIds.CARD}-${assetId}`; diff --git a/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.tsx b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.tsx new file mode 100644 index 000000000000..76ad38d7c91f --- /dev/null +++ b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/WatchlistDefaultTokenCard.tsx @@ -0,0 +1,119 @@ +import React, { useCallback, useMemo } from 'react'; +import { Pressable, View } from 'react-native'; +import { + BadgeNetwork, + BadgeWrapper, + BadgeWrapperPosition, + Checkbox, + Text, + TextVariant, + FontWeight, +} from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../../../component-library/hooks'; +import TrendingTokenLogo from '../../../../Trending/components/TrendingTokenLogo'; +import { + getCaipChainIdFromAssetId, + getNetworkBadgeSource, +} from '../../../../Trending/components/TrendingTokenRowItem/utils'; +import { formatPercentChange } from '../../../../Trending/utils/formatPercentChange'; +import type { WatchlistTokenWithBalance } from '../../utils/addBalanceToTokens'; +import styleSheet from './WatchlistDefaultTokenCard.styles'; +import { + getWatchlistDefaultTokenCardTestId, + WatchlistDefaultTokenCardTestIds, +} from './WatchlistDefaultTokenCard.testIds'; + +interface WatchlistDefaultTokenCardProps { + token: WatchlistTokenWithBalance; + isSelected: boolean; + onToggle: (assetId: string) => void; +} + +const WatchlistDefaultTokenCard: React.FC = ({ + token, + isSelected, + onToggle, +}) => { + const assetId = String(token.assetId); + const { styles } = useStyles(styleSheet, { isSelected }); + + const networkBadgeSource = useMemo( + () => getNetworkBadgeSource(getCaipChainIdFromAssetId(assetId)), + [assetId], + ); + + const { changeLabel, changeTextColor } = useMemo( + () => formatPercentChange(token.marketData?.pricePercentChange24h), + [token.marketData?.pricePercentChange24h], + ); + + const handlePress = useCallback(() => { + onToggle(assetId); + }, [assetId, onToggle]); + + const handleCheckboxChange = useCallback(() => { + onToggle(assetId); + }, [assetId, onToggle]); + + return ( + + + + ['src'] + } + /> + ) : null + } + > + + + + + + + + + {token.symbol} + + {changeLabel ? ( + + {changeLabel} + + ) : null} + + ); +}; + +export default WatchlistDefaultTokenCard; diff --git a/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/index.ts b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/index.ts new file mode 100644 index 000000000000..c4372476e5a4 --- /dev/null +++ b/app/components/UI/Assets/watchlist/components/WatchlistDefaultTokenCard/index.ts @@ -0,0 +1 @@ +export { default } from './WatchlistDefaultTokenCard'; diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.styles.ts b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.styles.ts new file mode 100644 index 000000000000..6795fe59cc9e --- /dev/null +++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.styles.ts @@ -0,0 +1,48 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../../util/theme/models'; + +const styleSheet = (_params: { theme: Theme }) => + StyleSheet.create({ + container: { + flex: 1, + }, + scrollContent: { + flexGrow: 1, + paddingHorizontal: 16, + paddingTop: 8, + paddingBottom: 16, + }, + scrollView: { + flex: 1, + }, + grid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + }, + gridItem: { + width: '48%', + }, + skeletonGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + }, + skeletonCard: { + width: '48%', + height: 140, + borderRadius: 12, + backgroundColor: _params.theme.colors.background.section, + }, + footer: { + flexDirection: 'column', + alignItems: 'flex-start', + paddingVertical: 4, + }, + button: { + alignSelf: 'stretch', + width: '100%', + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx new file mode 100644 index 000000000000..33f667591441 --- /dev/null +++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx @@ -0,0 +1,183 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import WatchlistEmptyCTA from './WatchlistEmptyCTA'; +import { WatchlistEmptyCTATestIds } from './WatchlistEmptyCTA.testIds'; +import type { WatchlistTokenWithBalance } from '../../utils/addBalanceToTokens'; + +const mockMutate = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); +const mockUseSuggestedWatchlistItemsQuery = jest.fn(); + +jest.mock('../../hooks/useSuggestedWatchlistItemsQuery', () => ({ + useSuggestedWatchlistItemsQuery: () => mockUseSuggestedWatchlistItemsQuery(), +})); + +jest.mock('../../hooks/useTokenWatchlistMutations', () => ({ + useTokenWatchlistAddItemMutation: () => ({ + mutate: mockMutate, + isPending: false, + }), +})); + +jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 34, top: 0, left: 0, right: 0 }), +})); + +jest.mock('../WatchlistDefaultTokenCard', () => { + const { Text, TouchableOpacity, View } = jest.requireActual('react-native'); + const ReactActual = jest.requireActual('react'); + const Mock = ({ + token, + isSelected, + onToggle, + }: { + token: { assetId: string; symbol: string }; + isSelected: boolean; + onToggle: (assetId: string) => void; + }) => + ReactActual.createElement( + TouchableOpacity, + { + testID: `mock-card-${token.assetId}`, + onPress: () => onToggle(String(token.assetId)), + }, + ReactActual.createElement(Text, null, token.symbol), + ReactActual.createElement( + Text, + { testID: `mock-card-selected-${token.assetId}` }, + isSelected ? 'selected' : 'unselected', + ), + ); + Mock.displayName = 'WatchlistDefaultTokenCard'; + return Mock; +}); + +const makeToken = ( + assetId: string, + symbol: string, +): WatchlistTokenWithBalance => ({ + assetId, + symbol, + name: symbol, + decimals: 18, + balance: '0', + isInWallet: false, + marketData: { pricePercentChange24h: 1.2 }, +}); + +describe('WatchlistEmptyCTA', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCreateEventBuilder.mockReturnValue({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({ name: 'Watchlist Token Added' }), + }); + mockUseSuggestedWatchlistItemsQuery.mockReturnValue({ + data: [ + makeToken('eip155:1/slip44:60', 'ETH'), + makeToken('bip122:000000000019d6689c085ae165831e93/slip44:0', 'BTC'), + ], + isLoading: false, + }); + }); + + it('renders all suggested tokens selected by default', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId('mock-card-selected-eip155:1/slip44:60').props.children, + ).toBe('selected'); + expect( + getByTestId( + 'mock-card-selected-bip122:000000000019d6689c085ae165831e93/slip44:0', + ).props.children, + ).toBe('selected'); + }); + + it('renders sticky footer matching TDP layout', () => { + const { getByTestId } = render( + , + ); + + const footer = getByTestId('bottomsheetfooter'); + expect(footer).toBeDefined(); + expect(footer.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + flexDirection: 'column', + alignItems: 'flex-start', + }), + expect.objectContaining({ + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: 40, + }), + expect.not.objectContaining({ flex: 1 }), + ]), + ); + }); + + it('shows add button with selected count', () => { + const { getByTestId, getByText } = render( + , + ); + + expect(getByTestId(WatchlistEmptyCTATestIds.ADD_BUTTON)).toBeDefined(); + expect(getByText('Add 2 tokens')).toBeDefined(); + }); + + it('disables add button when no tokens are selected', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('mock-card-eip155:1/slip44:60')); + fireEvent.press( + getByTestId('mock-card-bip122:000000000019d6689c085ae165831e93/slip44:0'), + ); + + expect( + getByTestId(WatchlistEmptyCTATestIds.ADD_BUTTON).props.accessibilityState + ?.disabled, + ).toBe(true); + }); + + it('calls add mutation with selected asset IDs', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('mock-card-eip155:1/slip44:60')); + fireEvent.press(getByTestId(WatchlistEmptyCTATestIds.ADD_BUTTON)); + + expect(mockMutate).toHaveBeenCalledWith([ + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + ]); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + + it('renders skeleton cards while loading', () => { + mockUseSuggestedWatchlistItemsQuery.mockReturnValue({ + data: undefined, + isLoading: true, + }); + + const { getAllByTestId } = render( + , + ); + + expect( + getAllByTestId(WatchlistEmptyCTATestIds.SKELETON).length, + ).toBeGreaterThan(0); + }); +}); diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.testIds.ts b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.testIds.ts new file mode 100644 index 000000000000..34b260cea528 --- /dev/null +++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.testIds.ts @@ -0,0 +1,6 @@ +export enum WatchlistEmptyCTATestIds { + CONTAINER = 'watchlist-empty-cta-container', + GRID = 'watchlist-empty-cta-grid', + SKELETON = 'watchlist-empty-cta-skeleton', + ADD_BUTTON = 'watchlist-empty-cta-add-button', +} diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx new file mode 100644 index 000000000000..f5f4e9a47976 --- /dev/null +++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx @@ -0,0 +1,205 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { ScrollView, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@metamask/design-system-react-native'; +import type { CaipAssetType } from '@metamask/utils'; +import { useStyles } from '../../../../../../component-library/hooks'; +import { strings } from '../../../../../../../locales/i18n'; +import { MetaMetricsEvents } from '../../../../../../core/Analytics'; +import { useTheme } from '../../../../../../util/theme'; +import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; +import { useSuggestedWatchlistItemsQuery } from '../../hooks/useSuggestedWatchlistItemsQuery'; +import { useTokenWatchlistAddItemMutation } from '../../hooks/useTokenWatchlistMutations'; +import type { WatchlistTokenWithBalance } from '../../utils/addBalanceToTokens'; +import WatchlistDefaultTokenCard from '../WatchlistDefaultTokenCard'; +import styleSheet from './WatchlistEmptyCTA.styles'; +import { WatchlistEmptyCTATestIds } from './WatchlistEmptyCTA.testIds'; + +const SKELETON_COUNT = 6; + +const getWatchlistAssetType = (assetId: string): 'native' | 'erc20' => + assetId.includes('/erc20:') ? 'erc20' : 'native'; + +interface WatchlistEmptyCTAProps { + /** Analytics source for watchlist add events. */ + source: string; +} + +const WatchlistEmptyCTA: React.FC = ({ source }) => { + const { styles } = useStyles(styleSheet, {}); + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { data: suggestedTokens, isLoading } = + useSuggestedWatchlistItemsQuery(); + const addMutation = useTokenWatchlistAddItemMutation(); + const { trackEvent, createEventBuilder } = useAnalytics(); + + const [selectedAssetIds, setSelectedAssetIds] = useState>( + () => new Set(), + ); + const initializedForTokensRef = useRef(''); + + useEffect(() => { + if (!suggestedTokens?.length) { + return; + } + + const tokenKey = suggestedTokens.map((t) => String(t.assetId)).join(','); + if (initializedForTokensRef.current === tokenKey) { + return; + } + + initializedForTokensRef.current = tokenKey; + setSelectedAssetIds( + new Set(suggestedTokens.map((token) => String(token.assetId))), + ); + }, [suggestedTokens]); + + const handleToggle = useCallback((assetId: string) => { + setSelectedAssetIds((prev) => { + const next = new Set(prev); + if (next.has(assetId)) { + next.delete(assetId); + } else { + next.add(assetId); + } + return next; + }); + }, []); + + const selectedCount = selectedAssetIds.size; + + const trackAdds = useCallback( + (tokens: WatchlistTokenWithBalance[]) => { + for (const token of tokens) { + trackEvent( + createEventBuilder(MetaMetricsEvents.WATCHLIST_TOKEN_ADDED) + .addProperties({ + source, + asset_type: getWatchlistAssetType(String(token.assetId)), + has_balance: token.isInWallet, + }) + .build(), + ); + } + }, + [createEventBuilder, source, trackEvent], + ); + + const handleAddPress = useCallback(() => { + if (!suggestedTokens?.length || selectedCount === 0) { + return; + } + + const toAdd = suggestedTokens.filter((token) => + selectedAssetIds.has(String(token.assetId)), + ); + const assetIds = toAdd.map((token) => token.assetId as CaipAssetType); + + addMutation.mutate(assetIds); + trackAdds(toAdd); + }, [ + addMutation, + selectedAssetIds, + selectedCount, + suggestedTokens, + trackAdds, + ]); + + const gridContent = useMemo(() => { + if (isLoading) { + return ( + + {Array.from({ length: SKELETON_COUNT }, (_, index) => ( + + ))} + + ); + } + + if (!suggestedTokens?.length) { + return null; + } + + return ( + + {suggestedTokens.map((token) => { + const assetId = String(token.assetId); + return ( + + + + ); + })} + + ); + }, [ + handleToggle, + isLoading, + selectedAssetIds, + styles.grid, + styles.gridItem, + styles.skeletonCard, + styles.skeletonGrid, + suggestedTokens, + ]); + + const footerStyle = useMemo( + () => ({ + backgroundColor: colors.background.default, + paddingHorizontal: 16, + paddingTop: 16, + paddingBottom: insets.bottom + 6, + }), + [colors.background.default, insets.bottom], + ); + + return ( + + + {gridContent} + + + + + + ); +}; + +export default WatchlistEmptyCTA; diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/index.ts b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/index.ts new file mode 100644 index 000000000000..f728fede8102 --- /dev/null +++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/index.ts @@ -0,0 +1 @@ +export { default } from './WatchlistEmptyCTA'; diff --git a/app/components/UI/Assets/watchlist/constants/defaultWatchlistTokens.ts b/app/components/UI/Assets/watchlist/constants/defaultWatchlistTokens.ts new file mode 100644 index 000000000000..56fc9e5aa6fd --- /dev/null +++ b/app/components/UI/Assets/watchlist/constants/defaultWatchlistTokens.ts @@ -0,0 +1,12 @@ +/** Curated default watchlist asset IDs for the empty-state CTA (mainnet). */ +export const DEFAULT_WATCHLIST_BASE_ASSET_IDS: readonly string[] = [ + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'eip155:1/slip44:60', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + 'eip155:56/slip44:714', + 'eip155:1/erc20:0x6982508145454Ce325dDbE47a25d4ec3d2311933', +] as const; + +/** SpaceX (Ondo tokenized) on Ethereum mainnet — 6th default when geo-eligible. */ +export const SPACEX_DEFAULT_ASSET_ID = + 'eip155:1/erc20:0xc9eef266834730340a55b6cc24621b31baf55581' as const; diff --git a/app/components/UI/Assets/watchlist/hooks/index.ts b/app/components/UI/Assets/watchlist/hooks/index.ts index e29339c9a638..4c495efcaf8e 100644 --- a/app/components/UI/Assets/watchlist/hooks/index.ts +++ b/app/components/UI/Assets/watchlist/hooks/index.ts @@ -13,3 +13,4 @@ export { type WatchlistRemoveInput, type WatchlistUpdateListInput, } from './useTokenWatchlistMutations'; +export { useSuggestedWatchlistItemsQuery } from './useSuggestedWatchlistItemsQuery'; diff --git a/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.test.ts b/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.test.ts index 81b2b0ffbcd5..f428763b9212 100644 --- a/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.test.ts +++ b/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.test.ts @@ -1,41 +1,84 @@ +import { renderHook } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; import { useTokenWatchlistQuery } from './useTokenWatchlistQuery'; -import { - SUGGESTED_WATCHLIST_ASSET_IDS, - useSuggestedWatchlistItemsQuery, -} from './useSuggestedWatchlistItemsQuery'; +import { useSuggestedWatchlistItemsQuery } from './useSuggestedWatchlistItemsQuery'; +import { DEFAULT_WATCHLIST_BASE_ASSET_IDS , SPACEX_DEFAULT_ASSET_ID } from '../constants/defaultWatchlistTokens'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); jest.mock('./useTokenWatchlistQuery', () => ({ useTokenWatchlistQuery: jest.fn(), })); +jest.mock('../utils/defaultWatchlistGeo', () => ({ + isSpaceXDefaultEligible: jest.fn(), + getDefaultWatchlistAssetIds: jest.fn(), +})); + +import { + getDefaultWatchlistAssetIds, + isSpaceXDefaultEligible, +} from '../utils/defaultWatchlistGeo'; + +const mockedUseSelector = useSelector as unknown as jest.Mock; const mockedUseTokenWatchlistQuery = useTokenWatchlistQuery as jest.MockedFunction; +const mockedIsSpaceXDefaultEligible = + isSpaceXDefaultEligible as jest.MockedFunction< + typeof isSpaceXDefaultEligible + >; +const mockedGetDefaultWatchlistAssetIds = + getDefaultWatchlistAssetIds as jest.MockedFunction< + typeof getDefaultWatchlistAssetIds + >; describe('useSuggestedWatchlistItemsQuery', () => { beforeEach(() => { jest.clearAllMocks(); - }); - - it('exposes the three curated mainnet native asset IDs (ETH/BTC/SOL)', () => { - expect(SUGGESTED_WATCHLIST_ASSET_IDS).toStrictEqual([ - 'eip155:1/slip44:60', - 'bip122:000000000019d6689c085ae165831e93/slip44:0', - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + mockedUseSelector.mockReturnValue('DE'); + mockedIsSpaceXDefaultEligible.mockReturnValue(true); + mockedGetDefaultWatchlistAssetIds.mockReturnValue([ + ...DEFAULT_WATCHLIST_BASE_ASSET_IDS, + SPACEX_DEFAULT_ASSET_ID, ]); }); - it('delegates to useTokenWatchlistQuery with the curated suggested IDs', () => { + it('delegates to useTokenWatchlistQuery with geo-aware suggested IDs', () => { const stubResult = { data: undefined, isSuccess: false }; mockedUseTokenWatchlistQuery.mockReturnValue( stubResult as unknown as ReturnType, ); - const returned = useSuggestedWatchlistItemsQuery(); + const { result } = renderHook(() => useSuggestedWatchlistItemsQuery()); + + expect(mockedGetDefaultWatchlistAssetIds).toHaveBeenCalledWith('DE'); + expect(mockedUseTokenWatchlistQuery).toHaveBeenCalledWith({ + suggestedTokens: [ + ...DEFAULT_WATCHLIST_BASE_ASSET_IDS, + SPACEX_DEFAULT_ASSET_ID, + ], + suggestedIncludeSpaceX: true, + }); + expect(result.current).toBe(stubResult); + }); + + it('passes suggestedIncludeSpaceX false when SpaceX is not eligible', () => { + mockedUseSelector.mockReturnValue('US'); + mockedIsSpaceXDefaultEligible.mockReturnValue(false); + mockedGetDefaultWatchlistAssetIds.mockReturnValue( + DEFAULT_WATCHLIST_BASE_ASSET_IDS, + ); + mockedUseTokenWatchlistQuery.mockReturnValue( + {} as ReturnType, + ); + + renderHook(() => useSuggestedWatchlistItemsQuery()); - expect(mockedUseTokenWatchlistQuery).toHaveBeenCalledTimes(1); expect(mockedUseTokenWatchlistQuery).toHaveBeenCalledWith({ - suggestedTokens: SUGGESTED_WATCHLIST_ASSET_IDS, + suggestedTokens: DEFAULT_WATCHLIST_BASE_ASSET_IDS, + suggestedIncludeSpaceX: false, }); - expect(returned).toBe(stubResult); }); }); diff --git a/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.ts b/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.ts index 36edea992e0b..57f4f8d790f9 100644 --- a/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.ts +++ b/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.ts @@ -1,16 +1,35 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; import { type UseQueryResult } from '@tanstack/react-query'; +import { getDetectedGeolocation } from '../../../../../reducers/fiatOrders'; import { type WatchlistTokenWithBalance } from '../utils/addBalanceToTokens'; +import { + getDefaultWatchlistAssetIds, + isSpaceXDefaultEligible, +} from '../utils/defaultWatchlistGeo'; import { useTokenWatchlistQuery } from './useTokenWatchlistQuery'; -/** Curated ETH / BTC / SOL native asset IDs surfaced in the empty-state CTA. */ -export const SUGGESTED_WATCHLIST_ASSET_IDS: readonly string[] = [ - 'eip155:1/slip44:60', - 'bip122:000000000019d6689c085ae165831e93/slip44:0', - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', -] as const; +export { DEFAULT_WATCHLIST_BASE_ASSET_IDS } from '../constants/defaultWatchlistTokens'; +/** + * Hydrates curated default watchlist tokens for the empty-state CTA. + * Geo-aware: eligible users receive SpaceX as a 6th default. + */ export const useSuggestedWatchlistItemsQuery = (): UseQueryResult< WatchlistTokenWithBalance[], Error -> => useTokenWatchlistQuery({ suggestedTokens: SUGGESTED_WATCHLIST_ASSET_IDS }); +> => { + const geolocation = useSelector(getDetectedGeolocation); + const includeSpaceX = isSpaceXDefaultEligible(geolocation); + + const suggestedTokens = useMemo( + () => getDefaultWatchlistAssetIds(geolocation), + [geolocation], + ); + + return useTokenWatchlistQuery({ + suggestedTokens, + suggestedIncludeSpaceX: includeSpaceX, + }); +}; diff --git a/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.test.ts b/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.test.ts index 3f1504f12f75..8a5d02a08639 100644 --- a/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.test.ts +++ b/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.test.ts @@ -305,7 +305,7 @@ describe('useTokenWatchlistQuery', () => { }); expect( - queryClient.getQueryData(tokenWatchlistQueryKeys.suggested), + queryClient.getQueryData(tokenWatchlistQueryKeys.suggested(false)), ).toStrictEqual([]); expect( queryClient.getQueryData(tokenWatchlistQueryKeys.hydrated), diff --git a/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.ts b/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.ts index 711622ae22d6..754eb0644426 100644 --- a/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.ts +++ b/app/components/UI/Assets/watchlist/hooks/useTokenWatchlistQuery.ts @@ -19,6 +19,8 @@ export const WATCHLIST_QUERY_STALE_TIME_MS = 60_000; export interface UseTokenWatchlistQueryOptions { /** When provided, bypass the stored watchlist and hydrate these IDs instead. */ suggestedTokens?: readonly string[]; + /** When hydrating suggested tokens, vary the cache key if SpaceX is included. */ + suggestedIncludeSpaceX?: boolean; } /** @@ -29,7 +31,7 @@ export interface UseTokenWatchlistQueryOptions { export const useTokenWatchlistQuery = ( options: UseTokenWatchlistQueryOptions = {}, ): UseQueryResult => { - const { suggestedTokens } = options; + const { suggestedTokens, suggestedIncludeSpaceX = false } = options; const isWatchlistEnabled = useSelector(selectTokenWatchlistEnabled); @@ -44,7 +46,7 @@ export const useTokenWatchlistQuery = ( return useQuery({ queryKey: suggestedTokens - ? tokenWatchlistQueryKeys.suggested + ? tokenWatchlistQueryKeys.suggested(suggestedIncludeSpaceX) : tokenWatchlistQueryKeys.hydrated, staleTime: WATCHLIST_QUERY_STALE_TIME_MS, enabled: isWatchlistEnabled, diff --git a/app/components/UI/Assets/watchlist/hooks/watchlist-query-keys.ts b/app/components/UI/Assets/watchlist/hooks/watchlist-query-keys.ts index e6f3e717fc10..4f8daa6a0bd6 100644 --- a/app/components/UI/Assets/watchlist/hooks/watchlist-query-keys.ts +++ b/app/components/UI/Assets/watchlist/hooks/watchlist-query-keys.ts @@ -2,5 +2,10 @@ export const tokenWatchlistQueryKeys = { all: ['tokenWatchlist'] as const, blob: ['tokenWatchlist', 'blob'] as const, hydrated: ['tokenWatchlist', 'hydrated'] as const, - suggested: ['tokenWatchlist', 'suggested'] as const, + suggested: (includeSpaceX: boolean) => + [ + 'tokenWatchlist', + 'suggested', + includeSpaceX ? 'with-spacex' : 'base', + ] as const, }; diff --git a/app/components/UI/Assets/watchlist/utils/defaultWatchlistGeo.test.ts b/app/components/UI/Assets/watchlist/utils/defaultWatchlistGeo.test.ts new file mode 100644 index 000000000000..c18aef2fec91 --- /dev/null +++ b/app/components/UI/Assets/watchlist/utils/defaultWatchlistGeo.test.ts @@ -0,0 +1,57 @@ +import { + getDefaultWatchlistAssetIds, + isSpaceXDefaultEligible, + SPACEX_DEFAULT_GEO_BLOCKED_COUNTRIES, + SPACEX_DEFAULT_GEO_BLOCKED_REGIONS, +} from './defaultWatchlistGeo'; +import { + DEFAULT_WATCHLIST_BASE_ASSET_IDS, + SPACEX_DEFAULT_ASSET_ID, +} from '../constants/defaultWatchlistTokens'; + +describe('defaultWatchlistGeo', () => { + describe('isSpaceXDefaultEligible', () => { + it('returns false when geolocation is missing or unknown', () => { + expect(isSpaceXDefaultEligible(undefined)).toBe(false); + expect(isSpaceXDefaultEligible('UNKNOWN')).toBe(false); + }); + + it('returns false for blocked countries', () => { + for (const country of SPACEX_DEFAULT_GEO_BLOCKED_COUNTRIES) { + expect(isSpaceXDefaultEligible(country)).toBe(false); + expect(isSpaceXDefaultEligible(`${country}-XX`)).toBe(false); + } + }); + + it('returns false for blocked sub-regions', () => { + for (const region of SPACEX_DEFAULT_GEO_BLOCKED_REGIONS) { + expect(isSpaceXDefaultEligible(region)).toBe(false); + } + }); + + it('returns true for eligible countries', () => { + expect(isSpaceXDefaultEligible('DE')).toBe(true); + expect(isSpaceXDefaultEligible('GB')).toBe(true); + expect(isSpaceXDefaultEligible('AR')).toBe(true); + expect(isSpaceXDefaultEligible('de-be')).toBe(true); + }); + }); + + describe('getDefaultWatchlistAssetIds', () => { + it('returns base 5 tokens when SpaceX is not eligible', () => { + expect(getDefaultWatchlistAssetIds('US')).toStrictEqual( + DEFAULT_WATCHLIST_BASE_ASSET_IDS, + ); + expect(getDefaultWatchlistAssetIds(undefined)).toStrictEqual( + DEFAULT_WATCHLIST_BASE_ASSET_IDS, + ); + }); + + it('returns base 5 plus SpaceX when eligible', () => { + expect(getDefaultWatchlistAssetIds('DE')).toStrictEqual([ + ...DEFAULT_WATCHLIST_BASE_ASSET_IDS, + SPACEX_DEFAULT_ASSET_ID, + ]); + }); + }); +}); diff --git a/app/components/UI/Assets/watchlist/utils/defaultWatchlistGeo.ts b/app/components/UI/Assets/watchlist/utils/defaultWatchlistGeo.ts new file mode 100644 index 000000000000..16f2c360cd29 --- /dev/null +++ b/app/components/UI/Assets/watchlist/utils/defaultWatchlistGeo.ts @@ -0,0 +1,72 @@ +import { + DEFAULT_WATCHLIST_BASE_ASSET_IDS, + SPACEX_DEFAULT_ASSET_ID, +} from '../constants/defaultWatchlistTokens'; + +/** Countries where SpaceX must not appear in watchlist defaults (Slack / product). */ +export const SPACEX_DEFAULT_GEO_BLOCKED_COUNTRIES = new Set([ + 'US', + 'CA', + 'AF', + 'BY', + 'CU', + 'KP', + 'IR', + 'LY', + 'MM', + 'RU', + 'SO', + 'SS', + 'SD', + 'SY', +]); + +/** + * Sub-regions (Crimea, DNR, LNR, etc.) where SpaceX must not appear. + * ISO 3166-2 codes; matched against the full geolocation string. + */ +export const SPACEX_DEFAULT_GEO_BLOCKED_REGIONS = new Set([ + 'UA-43', + 'UA-14', + 'UA-09', + 'UA-65', + 'RU-43', + 'RU-92', + 'RU-09', + 'RU-23', +]); + +/** + * Returns whether SpaceX may be included in watchlist default suggestions. + * Fail-closed: unknown or missing geo → not eligible. + */ +export const isSpaceXDefaultEligible = ( + location: string | undefined, +): boolean => { + if (!location || location.toUpperCase() === 'UNKNOWN') { + return false; + } + + const upper = location.toUpperCase(); + const country = upper.split('-')[0]; + + if (SPACEX_DEFAULT_GEO_BLOCKED_COUNTRIES.has(country)) { + return false; + } + + if (SPACEX_DEFAULT_GEO_BLOCKED_REGIONS.has(upper)) { + return false; + } + + return true; +}; + +/** Resolves the ordered default asset IDs for the empty-state CTA. */ +export const getDefaultWatchlistAssetIds = ( + location: string | undefined, +): readonly string[] => { + if (isSpaceXDefaultEligible(location)) { + return [...DEFAULT_WATCHLIST_BASE_ASSET_IDS, SPACEX_DEFAULT_ASSET_ID]; + } + return DEFAULT_WATCHLIST_BASE_ASSET_IDS; +}; diff --git a/locales/languages/en.json b/locales/languages/en.json index c5071460288d..e60b04c3ed6b 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -10431,6 +10431,7 @@ "fullscreen_title": "Watchlist", "tokens_tab": "Tokens", "edit": "Edit", - "done": "Done" + "done": "Done", + "empty_add_tokens_cta": "Add {{count}} tokens" } } From 78457c922f7fdb887e7404cacda3947f99460ff7 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Mon, 20 Jul 2026 11:57:04 +0100 Subject: [PATCH 2/5] feat: format --- .../watchlist/hooks/useSuggestedWatchlistItemsQuery.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.test.ts b/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.test.ts index f428763b9212..735ffba3823f 100644 --- a/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.test.ts +++ b/app/components/UI/Assets/watchlist/hooks/useSuggestedWatchlistItemsQuery.test.ts @@ -2,7 +2,10 @@ import { renderHook } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import { useTokenWatchlistQuery } from './useTokenWatchlistQuery'; import { useSuggestedWatchlistItemsQuery } from './useSuggestedWatchlistItemsQuery'; -import { DEFAULT_WATCHLIST_BASE_ASSET_IDS , SPACEX_DEFAULT_ASSET_ID } from '../constants/defaultWatchlistTokens'; +import { + DEFAULT_WATCHLIST_BASE_ASSET_IDS, + SPACEX_DEFAULT_ASSET_ID, +} from '../constants/defaultWatchlistTokens'; jest.mock('react-redux', () => ({ useSelector: jest.fn(), From 907acbf20ebb1a4d0b576770cb418e4b5784fb00 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Mon, 20 Jul 2026 12:56:43 +0100 Subject: [PATCH 3/5] fix: fix singlular copy issue --- .../WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx | 12 ++++++++++++ .../WatchlistEmptyCTA/WatchlistEmptyCTA.tsx | 16 +++++++++++++--- locales/languages/en.json | 3 ++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx index 33f667591441..00223fa36297 100644 --- a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx +++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx @@ -136,6 +136,18 @@ describe('WatchlistEmptyCTA', () => { expect(getByText('Add 2 tokens')).toBeDefined(); }); + it('uses singular copy when one token is selected', () => { + const { getByTestId, getByText } = render( + , + ); + + fireEvent.press( + getByTestId('mock-card-bip122:000000000019d6689c085ae165831e93/slip44:0'), + ); + + expect(getByText('Add 1 token')).toBeDefined(); + }); + it('disables add button when no tokens are selected', () => { const { getByTestId } = render( , diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx index f5f4e9a47976..4ceedb1dc76c 100644 --- a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx +++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx @@ -79,6 +79,18 @@ const WatchlistEmptyCTA: React.FC = ({ source }) => { const selectedCount = selectedAssetIds.size; + const addButtonLabel = useMemo( + () => + selectedCount === 1 + ? strings('token_watchlist.empty_add_tokens_cta', { + count: selectedCount, + }) + : strings('token_watchlist.empty_add_tokens_cta_plural', { + count: selectedCount, + }), + [selectedCount], + ); + const trackAdds = useCallback( (tokens: WatchlistTokenWithBalance[]) => { for (const token of tokens) { @@ -193,9 +205,7 @@ const WatchlistEmptyCTA: React.FC = ({ source }) => { isDisabled={selectedCount === 0 || isLoading || addMutation.isPending} testID={WatchlistEmptyCTATestIds.ADD_BUTTON} > - {strings('token_watchlist.empty_add_tokens_cta', { - count: selectedCount, - })} + {addButtonLabel} diff --git a/locales/languages/en.json b/locales/languages/en.json index e60b04c3ed6b..00acd190e7a6 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -10432,6 +10432,7 @@ "tokens_tab": "Tokens", "edit": "Edit", "done": "Done", - "empty_add_tokens_cta": "Add {{count}} tokens" + "empty_add_tokens_cta": "Add {{count}} token", + "empty_add_tokens_cta_plural": "Add {{count}} tokens" } } From a1de3abd88c8a4d366e9349bc67c11d442f0a321 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Mon, 20 Jul 2026 13:17:05 +0100 Subject: [PATCH 4/5] fix: fix disabled cta after add --- .../WatchlistEmptyCTA.test.tsx | 42 +++++++++++++++++-- .../WatchlistEmptyCTA/WatchlistEmptyCTA.tsx | 22 ++++++++-- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx index 00223fa36297..c6f05a28ccfd 100644 --- a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx +++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx @@ -165,6 +165,10 @@ describe('WatchlistEmptyCTA', () => { }); it('calls add mutation with selected asset IDs', () => { + mockMutate.mockImplementation((_assetIds, options) => { + options?.onSuccess?.(); + }); + const { getByTestId } = render( , ); @@ -172,12 +176,44 @@ describe('WatchlistEmptyCTA', () => { fireEvent.press(getByTestId('mock-card-eip155:1/slip44:60')); fireEvent.press(getByTestId(WatchlistEmptyCTATestIds.ADD_BUTTON)); - expect(mockMutate).toHaveBeenCalledWith([ - 'bip122:000000000019d6689c085ae165831e93/slip44:0', - ]); + expect(mockMutate).toHaveBeenCalledWith( + ['bip122:000000000019d6689c085ae165831e93/slip44:0'], + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); expect(mockTrackEvent).toHaveBeenCalledTimes(1); }); + it('keeps add button disabled after submit while hydrated list refetches', () => { + const { getByTestId } = render( + , + ); + + const addButton = getByTestId(WatchlistEmptyCTATestIds.ADD_BUTTON); + fireEvent.press(addButton); + + expect(addButton.props.accessibilityState?.disabled).toBe(true); + fireEvent.press(addButton); + expect(mockMutate).toHaveBeenCalledTimes(1); + }); + + it('re-enables add button when mutation fails', () => { + mockMutate.mockImplementation((_assetIds, options) => { + options?.onError?.(); + }); + + const { getByTestId } = render( + , + ); + + const addButton = getByTestId(WatchlistEmptyCTATestIds.ADD_BUTTON); + fireEvent.press(addButton); + + expect(addButton.props.accessibilityState?.disabled).toBe(false); + }); + it('renders skeleton cards while loading', () => { mockUseSuggestedWatchlistItemsQuery.mockReturnValue({ data: undefined, diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx index 4ceedb1dc76c..ae622aea0bfd 100644 --- a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx +++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx @@ -47,6 +47,7 @@ const WatchlistEmptyCTA: React.FC = ({ source }) => { const [selectedAssetIds, setSelectedAssetIds] = useState>( () => new Set(), ); + const [hasSubmitted, setHasSubmitted] = useState(false); const initializedForTokensRef = useRef(''); useEffect(() => { @@ -109,7 +110,7 @@ const WatchlistEmptyCTA: React.FC = ({ source }) => { ); const handleAddPress = useCallback(() => { - if (!suggestedTokens?.length || selectedCount === 0) { + if (!suggestedTokens?.length || selectedCount === 0 || hasSubmitted) { return; } @@ -118,10 +119,18 @@ const WatchlistEmptyCTA: React.FC = ({ source }) => { ); const assetIds = toAdd.map((token) => token.assetId as CaipAssetType); - addMutation.mutate(assetIds); - trackAdds(toAdd); + setHasSubmitted(true); + addMutation.mutate(assetIds, { + onSuccess: () => { + trackAdds(toAdd); + }, + onError: () => { + setHasSubmitted(false); + }, + }); }, [ addMutation, + hasSubmitted, selectedAssetIds, selectedCount, suggestedTokens, @@ -202,7 +211,12 @@ const WatchlistEmptyCTA: React.FC = ({ source }) => { size={ButtonSize.Lg} style={styles.button} onPress={handleAddPress} - isDisabled={selectedCount === 0 || isLoading || addMutation.isPending} + isDisabled={ + selectedCount === 0 || + isLoading || + addMutation.isPending || + hasSubmitted + } testID={WatchlistEmptyCTATestIds.ADD_BUTTON} > {addButtonLabel} From 1055c61edf0cef345c08444085ef583cfbbb9446 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Mon, 20 Jul 2026 13:27:19 +0100 Subject: [PATCH 5/5] fix: fix disabled button cta reset --- .../WatchlistEmptyCTA.test.tsx | 46 +++++++++++++++++++ .../WatchlistEmptyCTA/WatchlistEmptyCTA.tsx | 25 ++++++++++ 2 files changed, 71 insertions(+) diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx index c6f05a28ccfd..1da461be2494 100644 --- a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx +++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.test.tsx @@ -8,15 +8,24 @@ const mockMutate = jest.fn(); const mockTrackEvent = jest.fn(); const mockCreateEventBuilder = jest.fn(); const mockUseSuggestedWatchlistItemsQuery = jest.fn(); +const mockUseTokenWatchlistQuery = jest.fn(); +let mockAddMutationIsSuccess = false; jest.mock('../../hooks/useSuggestedWatchlistItemsQuery', () => ({ useSuggestedWatchlistItemsQuery: () => mockUseSuggestedWatchlistItemsQuery(), })); +jest.mock('../../hooks/useTokenWatchlistQuery', () => ({ + useTokenWatchlistQuery: () => mockUseTokenWatchlistQuery(), +})); + jest.mock('../../hooks/useTokenWatchlistMutations', () => ({ useTokenWatchlistAddItemMutation: () => ({ mutate: mockMutate, isPending: false, + get isSuccess() { + return mockAddMutationIsSuccess; + }, }), })); @@ -76,6 +85,8 @@ const makeToken = ( describe('WatchlistEmptyCTA', () => { beforeEach(() => { jest.clearAllMocks(); + mockAddMutationIsSuccess = false; + mockMutate.mockImplementation(() => undefined); mockCreateEventBuilder.mockReturnValue({ addProperties: jest.fn().mockReturnThis(), build: jest.fn().mockReturnValue({ name: 'Watchlist Token Added' }), @@ -87,6 +98,10 @@ describe('WatchlistEmptyCTA', () => { ], isLoading: false, }); + mockUseTokenWatchlistQuery.mockReturnValue({ + data: [], + isFetching: false, + }); }); it('renders all suggested tokens selected by default', () => { @@ -166,6 +181,7 @@ describe('WatchlistEmptyCTA', () => { it('calls add mutation with selected asset IDs', () => { mockMutate.mockImplementation((_assetIds, options) => { + mockAddMutationIsSuccess = true; options?.onSuccess?.(); }); @@ -214,6 +230,36 @@ describe('WatchlistEmptyCTA', () => { expect(addButton.props.accessibilityState?.disabled).toBe(false); }); + it('re-enables add button when hydrated refetch settles without items', () => { + mockMutate.mockImplementation((_assetIds, options) => { + mockAddMutationIsSuccess = true; + options?.onSuccess?.(); + }); + mockUseTokenWatchlistQuery.mockReturnValue({ + data: [], + isFetching: true, + }); + + const { getByTestId, rerender } = render( + , + ); + + const addButton = getByTestId(WatchlistEmptyCTATestIds.ADD_BUTTON); + fireEvent.press(addButton); + expect(addButton.props.accessibilityState?.disabled).toBe(true); + + mockUseTokenWatchlistQuery.mockReturnValue({ + data: [], + isFetching: false, + }); + rerender(); + + expect( + getByTestId(WatchlistEmptyCTATestIds.ADD_BUTTON).props.accessibilityState + ?.disabled, + ).toBe(false); + }); + it('renders skeleton cards while loading', () => { mockUseSuggestedWatchlistItemsQuery.mockReturnValue({ data: undefined, diff --git a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx index ae622aea0bfd..d7462d9874bb 100644 --- a/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx +++ b/app/components/UI/Assets/watchlist/components/WatchlistEmptyCTA/WatchlistEmptyCTA.tsx @@ -20,6 +20,7 @@ import { useTheme } from '../../../../../../util/theme'; import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; import { useSuggestedWatchlistItemsQuery } from '../../hooks/useSuggestedWatchlistItemsQuery'; import { useTokenWatchlistAddItemMutation } from '../../hooks/useTokenWatchlistMutations'; +import { useTokenWatchlistQuery } from '../../hooks/useTokenWatchlistQuery'; import type { WatchlistTokenWithBalance } from '../../utils/addBalanceToTokens'; import WatchlistDefaultTokenCard from '../WatchlistDefaultTokenCard'; import styleSheet from './WatchlistEmptyCTA.styles'; @@ -41,6 +42,8 @@ const WatchlistEmptyCTA: React.FC = ({ source }) => { const insets = useSafeAreaInsets(); const { data: suggestedTokens, isLoading } = useSuggestedWatchlistItemsQuery(); + const { data: hydratedTokens, isFetching: isHydratedFetching } = + useTokenWatchlistQuery(); const addMutation = useTokenWatchlistAddItemMutation(); const { trackEvent, createEventBuilder } = useAnalytics(); @@ -66,6 +69,28 @@ const WatchlistEmptyCTA: React.FC = ({ source }) => { ); }, [suggestedTokens]); + useEffect(() => { + if ( + !hasSubmitted || + !addMutation.isSuccess || + addMutation.isPending || + isHydratedFetching || + (hydratedTokens?.length ?? 0) > 0 + ) { + return; + } + + // Mutation succeeded but the empty CTA is still visible — allow retry when + // the hydrated refetch failed or did not populate the list. + setHasSubmitted(false); + }, [ + addMutation.isPending, + addMutation.isSuccess, + hasSubmitted, + hydratedTokens, + isHydratedFetching, + ]); + const handleToggle = useCallback((assetId: string) => { setSelectedAssetIds((prev) => { const next = new Set(prev);