diff --git a/.github/guidelines/E2E_DECISION_TREE.md b/.github/guidelines/E2E_DECISION_TREE.md index e5c88d7aaf7a..1279ccc2f57c 100644 --- a/.github/guidelines/E2E_DECISION_TREE.md +++ b/.github/guidelines/E2E_DECISION_TREE.md @@ -20,7 +20,7 @@ flowchart TD Android & iOS & Both --> LABEL{{PR label: skip-smart-e2e-selection ?}} LABEL -->|yes| AllTags[Run all E2E needed] LABEL -->|no| AI[🤖 AI selects test suites + confidence score] - AI --> CONF{{Confidence >= 80% ?}} + AI --> CONF{{Confidence >= 85% ?}} CONF -->|yes| SelectedTags[Run selected E2E suites] CONF -->|no| AllTagsFallback[Run all E2E needed] ``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bb4aa3faf6f..080daee6496f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1003,7 +1003,7 @@ jobs: ${{ !cancelled() && needs.get_requirements.outputs.android_e2e_needed == 'true' && - !(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags == '[]') + !(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 85 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags == '[]') }} permissions: contents: read @@ -1032,7 +1032,7 @@ jobs: changed_files: ${{ needs.get_requirements.outputs.changed_files }} selected_tags: >- ${{ - (fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) || + (fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 85 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) || '["ALL"]' }} runner_provider: ${{ inputs.runner_provider }} @@ -1044,7 +1044,7 @@ jobs: ${{ !cancelled() && needs.get_requirements.outputs.ios_e2e_needed == 'true' && - !(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags == '[]') + !(fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 85 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags == '[]') }} permissions: contents: read @@ -1080,7 +1080,7 @@ jobs: changed_files: ${{ needs.get_requirements.outputs.changed_files }} selected_tags: >- ${{ - (fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) || + (fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 85 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) || '["ALL"]' }} runner_provider: ${{ inputs.runner_provider }} diff --git a/.github/workflows/slack-rc-notification.yml b/.github/workflows/slack-rc-notification.yml index 097cfa66a20e..c9e37c1f6ddd 100644 --- a/.github/workflows/slack-rc-notification.yml +++ b/.github/workflows/slack-rc-notification.yml @@ -84,4 +84,5 @@ jobs: ANDROID_PUBLIC_URL: ${{ secrets.ANDROID_PUBLIC_BUCKET_URL }} SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} PR_NUMBER: ${{ inputs.pr_number }} - ANDROID_PLAY_STORE_CHECK_MRKDWN_FILE: ${{ github.workspace }}/android-play-store-check-out/android-play-store-check-slack.md + # Disable android check msg for now + #ANDROID_PLAY_STORE_CHECK_MRKDWN_FILE: ${{ github.workspace }}/android-play-store-check-out/android-play-store-check-slack.md diff --git a/.js.env.example b/.js.env.example index 4c6d0867c2cc..2bf4eca766cf 100644 --- a/.js.env.example +++ b/.js.env.example @@ -133,8 +133,7 @@ export MM_MUSD_CONVERSION_REWARDS_UI_ENABLED="false" export MM_MUSD_CONVERSION_GEO_BLOCKED_COUNTRIES="GB" export MM_MUSD_CONVERSION_MIN_ASSET_BALANCE_REQUIRED="0.01" -# Money Home Screen -export MM_MONEY_HOME_SCREEN_ENABLED="false" +# Money Hub export MM_MONEY_HUB_ENABLED="false" # Activates remote feature flag override mode. diff --git a/.yarn/patches/@metamask-transaction-controller-npm-66.0.0-23fe4d7dfe.patch b/.yarn/patches/@metamask-transaction-controller-npm-66.0.0-23fe4d7dfe.patch new file mode 100644 index 000000000000..b6b44bfff882 --- /dev/null +++ b/.yarn/patches/@metamask-transaction-controller-npm-66.0.0-23fe4d7dfe.patch @@ -0,0 +1,24 @@ +diff --git a/dist/hooks/ExtraTransactionsPublishHook.cjs b/dist/hooks/ExtraTransactionsPublishHook.cjs +index e182a81d3096512ec8726e31dea9465fca60f861..0e0301e037d9fc7ccf1cf9c2b0404e8c7327d62d 100644 +--- a/dist/hooks/ExtraTransactionsPublishHook.cjs ++++ b/dist/hooks/ExtraTransactionsPublishHook.cjs +@@ -115,6 +115,7 @@ _ExtraTransactionsPublishHook_addTransactionBatch = new WeakMap(), _ExtraTransac + }; + await __classPrivateFieldGet(this, _ExtraTransactionsPublishHook_addTransactionBatch, "f").call(this, { + from, ++ isInternal: true, + networkClientId, + requireApproval: false, + transactions, +diff --git a/dist/hooks/ExtraTransactionsPublishHook.mjs b/dist/hooks/ExtraTransactionsPublishHook.mjs +index 67d39aa5786e0d89ca851e07f30c8eeefe556724..3b0927b1eb0f634bf780bc221fd0ff8c5768c51e 100644 +--- a/dist/hooks/ExtraTransactionsPublishHook.mjs ++++ b/dist/hooks/ExtraTransactionsPublishHook.mjs +@@ -111,6 +111,7 @@ _ExtraTransactionsPublishHook_addTransactionBatch = new WeakMap(), _ExtraTransac + }; + await __classPrivateFieldGet(this, _ExtraTransactionsPublishHook_addTransactionBatch, "f").call(this, { + from, ++ isInternal: true, + networkClientId, + requireApproval: false, + transactions, diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index a989439d17f3..c55774b4b7c5 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -54,7 +54,6 @@ import RewardsNavigator from '../../UI/Rewards/RewardsNavigator'; import { ExploreFeed } from '../../Views/TrendingView/TrendingView'; import WhatsHappeningDetailView from '../../Views/WhatsHappeningDetailView'; import ExploreSearchScreen from '../../Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen'; -import ExploreSectionResultsFullView from '../../Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView'; import TrendingFeedSessionManager from '../../UI/Trending/services/TrendingFeedSessionManager'; import CollectiblesDetails from '../../UI/CollectibleModal'; import OptinMetrics from '../../UI/OptinMetrics'; @@ -109,7 +108,7 @@ import { MoneyTabScreenStack, } from '../../UI/Money/routes'; import MoneyOnboardingView from '../../UI/Money/Views/MoneyOnboardingView'; -import { selectMoneyHomeScreenEnabledFlag } from '../../UI/Money/selectors/featureFlags'; +import { selectMoneyEnableMoneyAccountFlag } from '../../UI/Money/selectors/featureFlags'; import { BridgeTransactionDetails } from '../../UI/Bridge/components/TransactionDetails/TransactionDetails'; import { BridgeModalStack, BridgeScreenStack } from '../../UI/Bridge/routes'; import { @@ -647,9 +646,7 @@ const HomeTabs = () => { const { trackEvent, createEventBuilder } = useAnalytics(); const [isKeyboardHidden, setIsKeyboardHidden] = useState(true); - const isMoneyHomeScreenEnabled = useSelector( - selectMoneyHomeScreenEnabledFlag, - ); + const isMoneyAccountEnabled = useSelector(selectMoneyEnableMoneyAccountFlag); const accountsLength = useSelector(selectAccountsLength); @@ -882,7 +879,7 @@ const HomeTabs = () => { /> {/* Activity Tab (replaced by Money when feature flag is on) */} - {isMoneyHomeScreenEnabled ? ( + {isMoneyAccountEnabled ? ( { }, [dispatch]); // Get feature flag state for conditional Money home screen registration - const isMoneyHomeScreenEnabled = useSelector( - selectMoneyHomeScreenEnabledFlag, - ); + const isMoneyAccountEnabled = useSelector(selectMoneyEnableMoneyAccountFlag); // Get feature flag state for conditional Perps screen registration const perpsEnabledFlag = useSelector(selectPerpsEnabledFlag); const isPerpsEnabled = useMemo(() => perpsEnabledFlag, [perpsEnabledFlag]); @@ -1256,7 +1251,7 @@ const MainNavigator = () => { presentation: 'transparentModal', }} /> - {isMoneyHomeScreenEnabled && ( + {isMoneyAccountEnabled && ( <> { component={WhatsHappeningDetailView} options={{ headerShown: false, ...slideFromRightAnimation }} /> - ({ jest.mock('../../hooks/useAnalytics/useAnalytics'); -const mockSelectMoneyHomeScreenEnabledFlag = jest.fn().mockReturnValue(false); +const mockSelectMoneyEnableMoneyAccountFlag = jest.fn().mockReturnValue(false); jest.mock('../../UI/Money/selectors/featureFlags', () => ({ - selectMoneyHomeScreenEnabledFlag: (state: unknown) => - mockSelectMoneyHomeScreenEnabledFlag(state), + selectMoneyEnableMoneyAccountFlag: (state: unknown) => + mockSelectMoneyEnableMoneyAccountFlag(state), })); describe('MainNavigator', () => { @@ -1474,7 +1474,7 @@ describe('MainNavigator', () => { }); }); - describe('Money home screen conditional rendering', () => { + describe('Money account conditional rendering', () => { const getHomeTabsScreenNames = (): string[] => { const { root: mainRoot } = renderWithProvider(, { state: initialRootState, @@ -1505,16 +1505,16 @@ describe('MainNavigator', () => { }; it('includes Money route when feature flag is enabled', () => { - mockSelectMoneyHomeScreenEnabledFlag.mockReturnValue(true); + mockSelectMoneyEnableMoneyAccountFlag.mockReturnValue(true); const tabScreenNames = getHomeTabsScreenNames(); expect(tabScreenNames).toContain(Routes.MONEY.ROOT); - mockSelectMoneyHomeScreenEnabledFlag.mockReturnValue(false); + mockSelectMoneyEnableMoneyAccountFlag.mockReturnValue(false); }); it('excludes Money route when feature flag is disabled', () => { - mockSelectMoneyHomeScreenEnabledFlag.mockReturnValue(false); + mockSelectMoneyEnableMoneyAccountFlag.mockReturnValue(false); const tabScreenNames = getHomeTabsScreenNames(); diff --git a/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts b/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts index 7824ba23aee5..e2af6c074e87 100644 --- a/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts +++ b/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts @@ -1,4 +1,5 @@ import type { BridgeState } from '../../../../core/redux/slices/bridge'; +import { initialHardwareWalletsSwapsState } from '../../HardwareWallet/Swaps/HardwareWalletsSwaps.state'; import { BridgeViewMode } from '../types'; export const mockBridgeReducerState: BridgeState = { @@ -38,6 +39,7 @@ export const mockBridgeReducerState: BridgeState = { tokenSelectorNetworkFilter: undefined, visiblePillChainIds: undefined, selectedQuoteRequestId: undefined, + hardwareWalletsSwaps: initialHardwareWalletsSwapsState, batchSellSourceTokens: [], batchSellSourceTokenAmounts: {}, batchSellDestToken: undefined, diff --git a/app/components/UI/Card/Views/CardWelcome/CardWelcome.test.tsx b/app/components/UI/Card/Views/CardWelcome/CardWelcome.test.tsx index f5e83bebd771..e1b5bcaa4df7 100644 --- a/app/components/UI/Card/Views/CardWelcome/CardWelcome.test.tsx +++ b/app/components/UI/Card/Views/CardWelcome/CardWelcome.test.tsx @@ -7,6 +7,17 @@ import { CardWelcomeSelectors } from './CardWelcome.testIds'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { MONEY_HOME_CARD_ORIGIN } from '../../hooks/useCardPostAuthRedirect'; + +const mockUseCardPostAuthRedirect = jest.fn(); + +jest.mock('../../hooks/useCardPostAuthRedirect', () => ({ + useCardPostAuthRedirect: () => mockUseCardPostAuthRedirect(), + MONEY_HOME_CARD_ORIGIN: { + screen: 'Money', + params: { screen: 'MoneyHome' }, + }, +})); // Mocks const mockNavigate = jest.fn(); @@ -81,6 +92,7 @@ describe('CardWelcome', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseCardPostAuthRedirect.mockReturnValue(undefined); mockNavigate.mockClear(); mockGoBack.mockClear(); mockTrackEvent.mockClear(); @@ -167,7 +179,10 @@ describe('CardWelcome', () => { fireEvent.press(getByTestId(CardWelcomeSelectors.VERIFY_ACCOUNT_BUTTON)); - expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ONBOARDING.ROOT); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.CARD.ONBOARDING.ROOT, + undefined, + ); expect(mockCreateEventBuilder).toHaveBeenCalledWith( MetaMetricsEvents.CARD_BUTTON_CLICKED, ); @@ -185,10 +200,47 @@ describe('CardWelcome', () => { fireEvent.press(getByTestId(CardWelcomeSelectors.VERIFY_ACCOUNT_BUTTON)); - expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.AUTHENTICATION); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.CARD.AUTHENTICATION, + undefined, + ); expect(mockCreateEventBuilder).toHaveBeenCalledWith( MetaMetricsEvents.CARD_BUTTON_CLICKED, ); }); + + it('forwards postAuthRedirect to onboarding when opened from Money (non-cardholder)', () => { + mockUseCardPostAuthRedirect.mockReturnValue(MONEY_HOME_CARD_ORIGIN); + store = createTestStore({ cardholderAccounts: [] }); + const { getByTestId } = render( + + + , + ); + + fireEvent.press(getByTestId(CardWelcomeSelectors.VERIFY_ACCOUNT_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ONBOARDING.ROOT, { + postAuthRedirect: MONEY_HOME_CARD_ORIGIN, + }); + }); + + it('forwards postAuthRedirect to authentication when opened from Money (cardholder)', () => { + mockUseCardPostAuthRedirect.mockReturnValue(MONEY_HOME_CARD_ORIGIN); + store = createTestStore({ + cardholderAccounts: ['0x1234567890abcdef'], + }); + const { getByTestId } = render( + + + , + ); + + fireEvent.press(getByTestId(CardWelcomeSelectors.VERIFY_ACCOUNT_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.AUTHENTICATION, { + postAuthRedirect: MONEY_HOME_CARD_ORIGIN, + }); + }); }); }); diff --git a/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx b/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx index 1735a80596ca..3e253fed9fe6 100644 --- a/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx +++ b/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx @@ -24,11 +24,13 @@ import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { CardActions, CardScreens } from '../../util/metrics'; import { selectHasCardholderAccounts } from '../../../../../selectors/cardController'; import { useSelector } from 'react-redux'; +import { useCardPostAuthRedirect } from '../../hooks/useCardPostAuthRedirect'; const CardWelcome = () => { const { trackEvent, createEventBuilder } = useAnalytics(); const { goBack, navigate } = useNavigation(); const hasCardholderAccounts = useSelector(selectHasCardholderAccounts); + const postAuthRedirect = useCardPostAuthRedirect(); const theme = useTheme(); const dimensions = useWindowDimensions(); const styles = createStyles(theme, dimensions); @@ -57,11 +59,23 @@ const CardWelcome = () => { ); if (hasCardholderAccounts) { - navigate(Routes.CARD.AUTHENTICATION); + navigate( + Routes.CARD.AUTHENTICATION, + postAuthRedirect ? { postAuthRedirect } : undefined, + ); } else { - navigate(Routes.CARD.ONBOARDING.ROOT); + navigate( + Routes.CARD.ONBOARDING.ROOT, + postAuthRedirect ? { postAuthRedirect } : undefined, + ); } - }, [hasCardholderAccounts, navigate, trackEvent, createEventBuilder]); + }, [ + hasCardholderAccounts, + navigate, + postAuthRedirect, + trackEvent, + createEventBuilder, + ]); return ( ({ 'card.card_spending_limit.money_account_label': 'Money account', 'card.card_spending_limit.money_account_token_symbol': 'mUSD', 'card.card_spending_limit.use_money_account_cta': 'Use Money account', - 'card.card_spending_limit.spend_and_earn_title': 'Spend while you earn', - 'card.card_spending_limit.spend_and_earn_cta': 'Link to Money account', + 'card.card_spending_limit.spend_and_earn_title': 'Spend and earn', + 'card.card_spending_limit.spend_and_earn_description_prefix': + 'Link your balance to your card and get mUSD back on purchases. Plus, earn up to ', + 'card.card_spending_limit.spend_and_earn_description_suffix': + ' (variable) on your balance.', + 'card.card_spending_limit.spend_and_earn_description_no_apy': + 'Link your balance to your card and get mUSD back on purchases.', + 'card.card_spending_limit.spend_and_earn_cta': 'Link card', }; - if (key === 'card.card_spending_limit.spend_and_earn_description') { + if (key === 'card.card_spending_limit.spend_and_earn_description_apy') { const apy = (params as { apy?: number | string } | undefined)?.apy; - const cashback = (params as { cashback?: number | string } | undefined) - ?.cashback; - return `Spend with your Money account and earn up to ${apy}% APY on your balance. Also get ${cashback}% mUSD back.`; - } - if (key === 'card.card_spending_limit.spend_and_earn_description_no_apy') { - const cashback = (params as { cashback?: number | string } | undefined) - ?.cashback; - return `Spend with your Money account and earn APY on your balance. Also get ${cashback}% mUSD back.`; + return `${apy}% APY`; } return strings[key] || key; }, @@ -1102,13 +1101,13 @@ describe('SpendingLimit Component', () => { render({ params: { flow: 'onboarding' } }); expect(screen.getByTestId('use-money-account-cta')).toBeOnTheScreen(); - expect(screen.getByText('Spend while you earn')).toBeOnTheScreen(); + expect(screen.getByText('Spend and earn')).toBeOnTheScreen(); expect( screen.getByText( - 'Spend with your Money account and earn up to 4% APY on your balance. Also get 1% mUSD back.', + /Link your balance to your card and get mUSD back on purchases\. Plus, earn up to 4% APY \(variable\) on your balance\./, ), ).toBeOnTheScreen(); - expect(screen.getByText('Link to Money account')).toBeOnTheScreen(); + expect(screen.getByText('Link card')).toBeOnTheScreen(); }); it('drops the explicit APY clause when moneyAccountApyPercent is undefined', () => { @@ -1123,15 +1122,15 @@ describe('SpendingLimit Component', () => { render({ params: { flow: 'onboarding' } }); expect(screen.getByTestId('use-money-account-cta')).toBeOnTheScreen(); - expect(screen.getByText('Spend while you earn')).toBeOnTheScreen(); + expect(screen.getByText('Spend and earn')).toBeOnTheScreen(); expect( screen.getByText( - 'Spend with your Money account and earn APY on your balance. Also get 1% mUSD back.', + 'Link your balance to your card and get mUSD back on purchases.', ), ).toBeOnTheScreen(); }); - it('advertises 3% mUSD back when the user has a Metal card', () => { + it('renders the same promo copy when the user has a Metal card', () => { mockUseSpendingLimit.mockReturnValue({ ...getDefaultUseSpendingLimitMock(), isMoneyAccountSource: false, @@ -1144,7 +1143,7 @@ describe('SpendingLimit Component', () => { expect( screen.getByText( - 'Spend with your Money account and earn up to 4% APY on your balance. Also get 3% mUSD back.', + /Link your balance to your card and get mUSD back on purchases\. Plus, earn up to 4% APY \(variable\) on your balance\./, ), ).toBeOnTheScreen(); }); diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx index e1b1b4305268..0583102fe3b2 100644 --- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx @@ -346,7 +346,6 @@ const SpendingLimit: React.FC = ({ route }) => { {canShowMoneyAccountCta && ( )} diff --git a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.test.tsx b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.test.tsx index ce1d7b1cce5a..b7fd738fbe0c 100644 --- a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.test.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.test.tsx @@ -6,15 +6,18 @@ jest.mock('react-native-linear-gradient', () => 'LinearGradient'); jest.mock('../../../../../../../locales/i18n', () => ({ strings: (key: string, params?: Record) => { - if (key === 'card.card_spending_limit.spend_and_earn_description') { - return `Spend with your Money account and earn up to ${params?.apy}% APY on your balance. Also get ${params?.cashback}% mUSD back.`; - } - if (key === 'card.card_spending_limit.spend_and_earn_description_no_apy') { - return `Spend with your Money account and earn APY on your balance. Also get ${params?.cashback}% mUSD back.`; + if (key === 'card.card_spending_limit.spend_and_earn_description_apy') { + return `${params?.apy}% APY`; } const map: Record = { - 'card.card_spending_limit.spend_and_earn_title': 'Spend while you earn', - 'card.card_spending_limit.spend_and_earn_cta': 'Link to Money account', + 'card.card_spending_limit.spend_and_earn_title': 'Spend and earn', + 'card.card_spending_limit.spend_and_earn_description_prefix': + 'Link your balance to your card and get mUSD back on purchases. Plus, earn up to ', + 'card.card_spending_limit.spend_and_earn_description_suffix': + ' (variable) on your balance.', + 'card.card_spending_limit.spend_and_earn_description_no_apy': + 'Link your balance to your card and get mUSD back on purchases.', + 'card.card_spending_limit.spend_and_earn_cta': 'Link card', 'card.card_spending_limit.use_money_account_cta': 'Use Money account', }; return map[key] ?? key; @@ -32,36 +35,27 @@ describe('SpendAndEarnPromoCard', () => { jest.clearAllMocks(); }); - it('renders the title, full description with APY + cashback, and CTA label', () => { + it('renders the title, description with APY highlight, and CTA label', () => { render(); - expect(screen.getByText('Spend while you earn')).toBeOnTheScreen(); + expect(screen.getByText('Spend and earn')).toBeOnTheScreen(); expect( screen.getByText( - 'Spend with your Money account and earn up to 4% APY on your balance. Also get 1% mUSD back.', + /Link your balance to your card and get mUSD back on purchases\. Plus, earn up to 4% APY \(variable\) on your balance\./, ), ).toBeOnTheScreen(); - expect(screen.getByText('Link to Money account')).toBeOnTheScreen(); + expect(screen.getByText('Link card')).toBeOnTheScreen(); }); - it('drops the explicit APY clause when apyPercent is undefined', () => { + it('drops the APY clause when apyPercent is undefined', () => { render(); expect( screen.getByText( - 'Spend with your Money account and earn APY on your balance. Also get 1% mUSD back.', - ), - ).toBeOnTheScreen(); - }); - - it('advertises the 3% Metal cashback rate when cashbackPercent is 3', () => { - render(); - - expect( - screen.getByText( - 'Spend with your Money account and earn up to 4% APY on your balance. Also get 3% mUSD back.', + 'Link your balance to your card and get mUSD back on purchases.', ), ).toBeOnTheScreen(); + expect(screen.queryByText('4% APY')).not.toBeOnTheScreen(); }); it('invokes onPress when the CTA button is tapped', () => { diff --git a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx index d866e11e1b96..c3a19ee49d05 100644 --- a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx @@ -1,11 +1,13 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { TouchableOpacity } from 'react-native'; import { Box, Button, ButtonSize, ButtonVariant, + FontWeight, Text, + TextColor, TextVariant, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -14,7 +16,6 @@ import ShimmerOverlay from './ShimmerOverlay'; export interface SpendAndEarnPromoCardProps { apyPercent?: number; - cashbackPercent: number; onPress: () => void; testID?: string; accessibilityLabel?: string; @@ -35,33 +36,18 @@ const PRIMARY_BUTTON_RADIUS = 8; /** * Promo card highlighting the Money account spend-and-earn benefit. * - * Renders a title, a single-paragraph description that embeds the current APY - * and mUSD cashback rate, and a dedicated primary CTA. The whole card is - * pressable; the CTA has a pronounced horizontal shimmer to draw the eye. + * Renders a title, description with a highlighted APY rate, and a dedicated + * primary CTA. The whole card is pressable; the CTA has a pronounced horizontal + * shimmer to draw the eye. */ const SpendAndEarnPromoCard: React.FC = ({ apyPercent, - cashbackPercent, onPress, testID = 'use-money-account-cta', accessibilityLabel, }) => { const tw = useTailwind(); - const description = useMemo( - () => - apyPercent !== undefined - ? strings('card.card_spending_limit.spend_and_earn_description', { - apy: apyPercent, - cashback: cashbackPercent, - }) - : strings( - 'card.card_spending_limit.spend_and_earn_description_no_apy', - { cashback: cashbackPercent }, - ), - [apyPercent, cashbackPercent], - ); - const resolvedAccessibilityLabel = accessibilityLabel ?? strings('card.card_spending_limit.use_money_account_cta'); @@ -77,15 +63,41 @@ const SpendAndEarnPromoCard: React.FC = ({ > - + {strings('card.card_spending_limit.spend_and_earn_title')} - - {description} - + {apyPercent !== undefined ? ( + + {strings( + 'card.card_spending_limit.spend_and_earn_description_prefix', + )} + + {strings( + 'card.card_spending_limit.spend_and_earn_description_apy', + { apy: apyPercent }, + )} + + {strings( + 'card.card_spending_limit.spend_and_earn_description_suffix', + )} + + ) : ( + + {strings( + 'card.card_spending_limit.spend_and_earn_description_no_apy', + )} + + )} ({ + useCardPostAuthRedirect: () => mockUseCardPostAuthRedirect(), + MONEY_HOME_CARD_ORIGIN: { + screen: 'Money', + params: { screen: 'MoneyHome' }, + }, +})); // Mock navigation jest.mock('@react-navigation/native', () => ({ @@ -140,12 +152,16 @@ describe('SignUp Component', () => { let store: ReturnType; let mockSendEmailVerification: jest.Mock; let mockNavigate: jest.Mock; + let mockGoBack: jest.Mock; beforeEach(() => { jest.clearAllMocks(); + mockUseCardPostAuthRedirect.mockReturnValue(undefined); mockNavigate = jest.fn(); + mockGoBack = jest.fn(); mockUseNavigation.mockReturnValue({ navigate: mockNavigate, + goBack: mockGoBack, } as unknown as ReturnType); mockSendEmailVerification = jest .fn() @@ -736,7 +752,7 @@ describe('SignUp Component', () => { }); describe('Navigation', () => { - it('navigates to authentication screen when "I already have an account" is pressed', () => { + it('navigates to authentication when "I already have an account" is pressed (direct card flow)', () => { const { getByTestId } = render( @@ -748,7 +764,25 @@ describe('SignUp Component', () => { ); fireEvent.press(alreadyHaveAccountButton); - expect(mockNavigate).toHaveBeenCalledWith('CardAuthentication'); + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.AUTHENTICATION); + expect(mockGoBack).not.toHaveBeenCalled(); + }); + + it('forwards postAuthRedirect to authentication when opened from Money', () => { + mockUseCardPostAuthRedirect.mockReturnValue(MONEY_HOME_CARD_ORIGIN); + + const { getByTestId } = render( + + + , + ); + + fireEvent.press(getByTestId('signup-i-already-have-an-account-text')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.AUTHENTICATION, { + postAuthRedirect: MONEY_HOME_CARD_ORIGIN, + }); + expect(mockGoBack).not.toHaveBeenCalled(); }); }); }); diff --git a/app/components/UI/Card/components/Onboarding/SignUp.tsx b/app/components/UI/Card/components/Onboarding/SignUp.tsx index 1b8c5325ac71..089bcfca1be7 100644 --- a/app/components/UI/Card/components/Onboarding/SignUp.tsx +++ b/app/components/UI/Card/components/Onboarding/SignUp.tsx @@ -45,6 +45,7 @@ import { mapCountryToLocation } from '../../util/mapCountryToLocation'; import type { Region } from '../../types'; import { selectGeolocationLocation } from '../../../../../selectors/geolocationController'; import { HUBSPOT_WAITLIST_URL } from '../../constants'; +import { useCardPostAuthRedirect } from '../../hooks/useCardPostAuthRedirect'; const buildWaitlistUrl = (countryName: string, email?: string): string => { // country must come first per HubSpot field ordering @@ -72,6 +73,15 @@ const SignUp = () => { isLoading: isLoadingRegistrationSettings, } = useRegions(); const { trackEvent, createEventBuilder } = useAnalytics(); + const postAuthRedirect = useCardPostAuthRedirect(); + + const handleAlreadyHaveAccountPress = useCallback(() => { + if (postAuthRedirect) { + navigation.navigate(Routes.CARD.AUTHENTICATION, { postAuthRedirect }); + return; + } + navigation.navigate(Routes.CARD.AUTHENTICATION); + }, [navigation, postAuthRedirect]); useEffect(() => { trackEvent( @@ -396,9 +406,7 @@ const SignUp = () => { ? strings('card.card_onboarding.sign_up.join_waitlist') : strings('card.card_onboarding.continue_button')} - navigation.navigate(Routes.CARD.AUTHENTICATION)} - > + { { networkClientId: mockNetworkClientId, origin: TransactionTypes.MMM_CARD, + isInternal: true, type: TransactionType.tokenMethodApprove, deviceConfirmedOn: WalletDevice.MM_MOBILE, requireApproval: true, diff --git a/app/components/UI/Card/hooks/useCardDelegation.ts b/app/components/UI/Card/hooks/useCardDelegation.ts index 215fef62df7d..b8f0dd7a219d 100644 --- a/app/components/UI/Card/hooks/useCardDelegation.ts +++ b/app/components/UI/Card/hooks/useCardDelegation.ts @@ -155,6 +155,7 @@ export const useCardDelegation = (token?: CardFundingToken | null) => { { networkClientId, origin: TransactionTypes.MMM_CARD, + isInternal: true, type: TransactionType.tokenMethodApprove, deviceConfirmedOn: WalletDevice.MM_MOBILE, requireApproval: true, diff --git a/app/components/UI/Card/hooks/useCardPostAuthRedirect.test.ts b/app/components/UI/Card/hooks/useCardPostAuthRedirect.test.ts new file mode 100644 index 000000000000..2c1f59ebe7f5 --- /dev/null +++ b/app/components/UI/Card/hooks/useCardPostAuthRedirect.test.ts @@ -0,0 +1,64 @@ +import { renderHook } from '@testing-library/react-hooks'; +import Routes from '../../../../constants/navigation/Routes'; +import { + MONEY_HOME_CARD_ORIGIN, + useCardPostAuthRedirect, +} from './useCardPostAuthRedirect'; + +const mockGetParent = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + getParent: mockGetParent, + }), +})); + +describe('useCardPostAuthRedirect', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetParent.mockReturnValue(undefined); + }); + + it('returns undefined when no parent navigator exposes postAuthRedirect', () => { + const { result } = renderHook(() => useCardPostAuthRedirect()); + expect(result.current).toBeUndefined(); + }); + + it('returns postAuthRedirect from a parent route params', () => { + mockGetParent.mockReturnValue({ + getState: () => ({ + routes: [ + { + params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + }, + ], + }), + getParent: () => undefined, + }); + + const { result } = renderHook(() => useCardPostAuthRedirect()); + expect(result.current).toEqual({ + screen: Routes.MONEY.ROOT, + params: { screen: Routes.MONEY.HOME }, + }); + }); + + it('returns postAuthRedirect from nested onboarding params', () => { + mockGetParent.mockReturnValue({ + getState: () => ({ + routes: [ + { + params: { + screen: Routes.CARD.ONBOARDING.ROOT, + params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + }, + }, + ], + }), + getParent: () => undefined, + }); + + const { result } = renderHook(() => useCardPostAuthRedirect()); + expect(result.current).toEqual(MONEY_HOME_CARD_ORIGIN); + }); +}); diff --git a/app/components/UI/Card/hooks/useCardPostAuthRedirect.ts b/app/components/UI/Card/hooks/useCardPostAuthRedirect.ts new file mode 100644 index 000000000000..18b6b43008b9 --- /dev/null +++ b/app/components/UI/Card/hooks/useCardPostAuthRedirect.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import Routes from '../../../../constants/navigation/Routes'; +import type { LinkFlowOrigin } from './useMoneyAccountCardLinkage'; + +export const MONEY_HOME_CARD_ORIGIN: LinkFlowOrigin = { + screen: Routes.MONEY.ROOT, + params: { screen: Routes.MONEY.HOME }, +}; + +const isLinkFlowOrigin = (value: unknown): value is LinkFlowOrigin => + typeof value === 'object' && + value !== null && + 'screen' in value && + typeof (value as LinkFlowOrigin).screen === 'string'; + +/** + * Reads `postAuthRedirect` from the current card navigation stack when set by a + * Money account entry point. Returns undefined for direct card opens. + */ +export const useCardPostAuthRedirect = (): LinkFlowOrigin | undefined => { + const navigation = useNavigation(); + + return useMemo(() => { + let parent = navigation.getParent(); + + while (parent) { + const state = parent.getState(); + for (const route of state.routes) { + const redirect = (route.params as { postAuthRedirect?: unknown }) + ?.postAuthRedirect; + if (isLinkFlowOrigin(redirect)) { + return redirect; + } + + const nestedParams = route.params as + | { params?: { postAuthRedirect?: unknown } } + | undefined; + const nestedRedirect = nestedParams?.params?.postAuthRedirect; + if (isLinkFlowOrigin(nestedRedirect)) { + return nestedRedirect; + } + } + + parent = parent.getParent(); + } + + return undefined; + }, [navigation]); +}; diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx index 63a82c6693d0..722a47ef0851 100644 --- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx +++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx @@ -457,7 +457,10 @@ describe('useMoneyAccountCardLinkage', () => { expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, - params: { screen: Routes.CARD.ONBOARDING.ROOT }, + params: { + screen: Routes.CARD.ONBOARDING.ROOT, + params: { postAuthRedirect: ORIGIN }, + }, }); expect(mockShowToast).not.toHaveBeenCalled(); }); @@ -500,7 +503,10 @@ describe('useMoneyAccountCardLinkage', () => { expect(mockDispatch).not.toHaveBeenCalled(); expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, - params: { screen: Routes.CARD.ONBOARDING.ROOT }, + params: { + screen: Routes.CARD.ONBOARDING.ROOT, + params: { postAuthRedirect: ORIGIN }, + }, }); expect(mockShowToast).not.toHaveBeenCalled(); }); diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx index 6a294d4ce918..c8f7da45f8a0 100644 --- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx +++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx @@ -248,7 +248,10 @@ export const useMoneyAccountCardLinkage = navigation.navigate(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, - params: { screen: Routes.CARD.ONBOARDING.ROOT }, + params: { + screen: Routes.CARD.ONBOARDING.ROOT, + params: { postAuthRedirect: origin }, + }, }); }, [ diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx index d1ec397d7dcc..e4c398d9dad1 100644 --- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx +++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx @@ -473,6 +473,7 @@ const EarnInputView = () => { from: (selectedAccount?.address as Hex) || '0x', networkClientId, origin: ORIGIN_METAMASK, + isInternal: true, transactions: [approveTx, lendingDepositTx], requireApproval: true, }); diff --git a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaimTransaction.ts b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaimTransaction.ts index e204b32cb773..4def8a941808 100644 --- a/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaimTransaction.ts +++ b/app/components/UI/Earn/components/MerklRewards/hooks/useMerklClaimTransaction.ts @@ -131,6 +131,7 @@ export const useMerklClaimTransaction = (asset: TokenI | undefined) => { deviceConfirmedOn: WalletDevice.MM_MOBILE, networkClientId, origin: MERKL_CLAIM_ORIGIN, + isInternal: true, type: TransactionType.musdClaim, }); diff --git a/app/components/UI/Earn/hooks/useMusdConversion.test.ts b/app/components/UI/Earn/hooks/useMusdConversion.test.ts index a1eb54654c68..78184e980568 100644 --- a/app/components/UI/Earn/hooks/useMusdConversion.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConversion.test.ts @@ -355,6 +355,7 @@ describe('useMusdConversion', () => { { networkClientId: 'mainnet', origin: ORIGIN_METAMASK, + isInternal: true, skipInitialGasEstimate: true, type: TransactionType.musdConversion, }, diff --git a/app/components/UI/Earn/utils/musdConversionTransaction.test.ts b/app/components/UI/Earn/utils/musdConversionTransaction.test.ts index dfc405be1f96..007e315dcfce 100644 --- a/app/components/UI/Earn/utils/musdConversionTransaction.test.ts +++ b/app/components/UI/Earn/utils/musdConversionTransaction.test.ts @@ -301,6 +301,7 @@ describe('musdConversionTransaction', () => { networkClientId, origin: ORIGIN_METAMASK, type: TransactionType.musdConversion, + isInternal: true, }, ); }); @@ -526,6 +527,7 @@ describe('musdConversionTransaction', () => { networkClientId: 'networkClientId', origin: ORIGIN_METAMASK, type: TransactionType.musdConversion, + isInternal: true, }, ); diff --git a/app/components/UI/Earn/utils/musdConversionTransaction.ts b/app/components/UI/Earn/utils/musdConversionTransaction.ts index daaaae58cfc8..900d656d1181 100644 --- a/app/components/UI/Earn/utils/musdConversionTransaction.ts +++ b/app/components/UI/Earn/utils/musdConversionTransaction.ts @@ -117,6 +117,7 @@ function buildMusdConversionTx(params: { networkClientId: string; origin: typeof ORIGIN_METAMASK; type: TransactionType.musdConversion; + isInternal: true; }; } { const { chainId, fromAddress, recipientAddress, amountHex, networkClientId } = @@ -142,6 +143,7 @@ function buildMusdConversionTx(params: { networkClientId, origin: ORIGIN_METAMASK, type: TransactionType.musdConversion, + isInternal: true, }, }; } diff --git a/app/components/UI/HardwareWallet/Swaps/useHwConnectionMonitoring.test.ts b/app/components/UI/HardwareWallet/Swaps/useHwConnectionMonitoring.test.ts new file mode 100644 index 000000000000..f508846d8512 --- /dev/null +++ b/app/components/UI/HardwareWallet/Swaps/useHwConnectionMonitoring.test.ts @@ -0,0 +1,653 @@ +import { renderHook } from '@testing-library/react-native'; +import { + ConnectionStatus, + ErrorCode, + HardwareWalletError, +} from '@metamask/hw-wallet-sdk'; +import { + shouldIgnoreAsBaseline, + useHwConnectionMonitoring, +} from './useHwConnectionMonitoring'; +import { updateHardwareWalletsSwaps } from '../../../../core/redux/slices/bridge'; +import { + HardwareWalletsSwapsStatus, + HardwareWalletsSwapsEventType, +} from './HardwareWalletsSwaps.state'; +import type { HardwareWalletContextValue } from '../../../../core/HardwareWallet/contexts'; +import { useHardwareWallet } from '../../../../core/HardwareWallet'; +import { isUserCancellation } from '../../../../core/HardwareWallet/errors/helpers'; +import { parseErrorByType } from '../../../../core/HardwareWallet/errors/parser'; + +jest.mock('../../../../core/HardwareWallet', () => ({ + useHardwareWallet: jest.fn(), +})); + +jest.mock('../../../../core/HardwareWallet/errors/helpers', () => ({ + isUserCancellation: jest.fn(), +})); + +jest.mock('../../../../core/HardwareWallet/errors/parser', () => ({ + parseErrorByType: jest.fn(), +})); + +jest.mock('../../../../core/redux/slices/bridge', () => ({ + updateHardwareWalletsSwaps: jest.fn((action) => action), +})); + +const mockDispatch = jest.fn((action: unknown) => action); + +jest.mock('react-redux', () => ({ + useDispatch: () => mockDispatch, + useSelector: jest.fn(), +})); + +const mockUseHardwareWallet = useHardwareWallet as jest.MockedFunction< + typeof useHardwareWallet +>; + +const stubContext: Omit & { + connectionState: HardwareWalletContextValue['connectionState']; +} = { + walletType: null, + deviceId: null, + connectionState: { status: ConnectionStatus.Disconnected }, + deviceSelection: { + devices: [], + selectedDevice: null, + isScanning: false, + scanError: null, + }, + ensureDeviceReady: + jest.fn() as HardwareWalletContextValue['ensureDeviceReady'], + setTargetWalletType: + jest.fn() as HardwareWalletContextValue['setTargetWalletType'], + setPendingOperationAddress: + jest.fn() as HardwareWalletContextValue['setPendingOperationAddress'], + showHardwareWalletError: + jest.fn() as HardwareWalletContextValue['showHardwareWalletError'], + showAwaitingConfirmation: + jest.fn() as HardwareWalletContextValue['showAwaitingConfirmation'], + hideAwaitingConfirmation: + jest.fn() as HardwareWalletContextValue['hideAwaitingConfirmation'], + qr: { + pendingScanRequest: undefined, + isSigningQRObject: false, + setRequestCompleted: jest.fn(), + isRequestCompleted: false, + cancelQRScanRequestIfPresent: jest.fn(), + }, +}; + +function mockContextWith( + connectionState: HardwareWalletContextValue['connectionState'], +): HardwareWalletContextValue { + return { ...stubContext, connectionState }; +} + +function makeParsedError(code: ErrorCode): HardwareWalletError { + return { code, message: 'test' } as unknown as HardwareWalletError; +} + +function createDisconnectedState() { + return { status: ConnectionStatus.Disconnected } as const; +} + +function createErrorState(error: unknown) { + return { + status: ConnectionStatus.ErrorState, + error: error as HardwareWalletError, + } as const; +} + +function createReadyState() { + return { status: ConnectionStatus.Ready, deviceId: 'test-device' } as const; +} + +function renderAndTransitionToWaiting( + badConnectionState: ReturnType< + typeof createDisconnectedState | typeof createErrorState + >, + hasActiveSigning = true, +) { + const readyState = createReadyState(); + + mockUseHardwareWallet.mockReturnValue(mockContextWith(readyState)); + + const { rerender } = renderHook( + ({ currentStatus }) => + useHwConnectionMonitoring({ + isEnabled: true, + currentStatus, + hasActiveSigning, + }), + { initialProps: { currentStatus: HardwareWalletsSwapsStatus.Idle } }, + ); + + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + mockUseHardwareWallet.mockReturnValue(mockContextWith(badConnectionState)); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + return { rerender }; +} + +describe('shouldIgnoreAsBaseline', () => { + it('returns false when connection statuses differ', () => { + expect( + shouldIgnoreAsBaseline( + { status: ConnectionStatus.Ready }, + { status: ConnectionStatus.Disconnected }, + ), + ).toBe(false); + }); + + it('returns true when statuses match and are not ErrorState', () => { + expect( + shouldIgnoreAsBaseline( + { status: ConnectionStatus.Ready }, + { status: ConnectionStatus.Ready }, + ), + ).toBe(true); + }); + + it('returns true when ErrorState errors are the same reference', () => { + const error = new Error('same error'); + + expect( + shouldIgnoreAsBaseline( + { status: ConnectionStatus.ErrorState, error }, + { status: ConnectionStatus.ErrorState, error }, + ), + ).toBe(true); + }); + + it('returns false when ErrorState errors differ', () => { + expect( + shouldIgnoreAsBaseline( + { status: ConnectionStatus.ErrorState, error: new Error('a') }, + { status: ConnectionStatus.ErrorState, error: new Error('b') }, + ), + ).toBe(false); + }); +}); + +describe('useHwConnectionMonitoring', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseHardwareWallet.mockReturnValue(mockContextWith(createReadyState())); + (parseErrorByType as jest.Mock).mockReturnValue( + makeParsedError(ErrorCode.Unknown), + ); + (isUserCancellation as jest.Mock).mockReturnValue(false); + }); + + it('dispatches DEVICE_DISCONNECTED when connection state changes to Disconnected during signing', () => { + renderAndTransitionToWaiting(createDisconnectedState()); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledWith({ + type: HardwareWalletsSwapsEventType.DeviceDisconnected, + }); + }); + + it('ignores Disconnected readiness handoff before signing starts', () => { + renderAndTransitionToWaiting(createDisconnectedState(), false); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + }); + + it('dispatches DEVICE_DISCONNECTED again after progress re-enters Waiting', () => { + const readyState = createReadyState(); + mockUseHardwareWallet.mockReturnValue(mockContextWith(readyState)); + + const { rerender } = renderHook( + ({ currentStatus }) => + useHwConnectionMonitoring({ + isEnabled: true, + currentStatus, + hasActiveSigning: true, + }), + { initialProps: { currentStatus: HardwareWalletsSwapsStatus.Idle } }, + ); + + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + mockUseHardwareWallet.mockReturnValue( + mockContextWith(createDisconnectedState()), + ); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Disconnected }); + mockUseHardwareWallet.mockReturnValue(mockContextWith(readyState)); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + mockUseHardwareWallet.mockReturnValue( + mockContextWith(createDisconnectedState()), + ); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledTimes(2); + expect(updateHardwareWalletsSwaps).toHaveBeenNthCalledWith(1, { + type: HardwareWalletsSwapsEventType.DeviceDisconnected, + }); + expect(updateHardwareWalletsSwaps).toHaveBeenNthCalledWith(2, { + type: HardwareWalletsSwapsEventType.DeviceDisconnected, + }); + }); + + it('dispatches DEVICE_DISCONNECTED for ConnectionClosed error code', () => { + const error = new Error('connection closed'); + (parseErrorByType as jest.Mock).mockReturnValue( + makeParsedError(ErrorCode.ConnectionClosed), + ); + + renderAndTransitionToWaiting(createErrorState(error)); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledWith({ + type: HardwareWalletsSwapsEventType.DeviceDisconnected, + }); + }); + + it('ignores ConnectionClosed error code before signing starts', () => { + const error = new Error('connection closed'); + (parseErrorByType as jest.Mock).mockReturnValue( + makeParsedError(ErrorCode.ConnectionClosed), + ); + + renderAndTransitionToWaiting(createErrorState(error), false); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + }); + + it('ignores DeviceDisconnected error code before signing starts', () => { + const error = new Error('device disconnected'); + (parseErrorByType as jest.Mock).mockReturnValue( + makeParsedError(ErrorCode.DeviceDisconnected), + ); + + renderAndTransitionToWaiting(createErrorState(error), false); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + }); + + it('dispatches DEVICE_DISCONNECTED for an existing disconnect error once signing starts', () => { + const error = new Error('device disconnected'); + (parseErrorByType as jest.Mock).mockReturnValue( + makeParsedError(ErrorCode.DeviceDisconnected), + ); + + const readyState = createReadyState(); + const errorState = createErrorState(error); + mockUseHardwareWallet.mockReturnValue(mockContextWith(readyState)); + + const { rerender } = renderHook( + ({ hasActiveSigning }) => + useHwConnectionMonitoring({ + isEnabled: true, + currentStatus: HardwareWalletsSwapsStatus.Waiting, + hasActiveSigning, + }), + { initialProps: { hasActiveSigning: false } }, + ); + + mockUseHardwareWallet.mockReturnValue(mockContextWith(errorState)); + rerender({ hasActiveSigning: false }); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + + rerender({ hasActiveSigning: true }); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledWith({ + type: HardwareWalletsSwapsEventType.DeviceDisconnected, + }); + }); + + it('dispatches DEVICE_DISCONNECTED for DeviceDisconnected error code', () => { + const error = new Error('device disconnected'); + (parseErrorByType as jest.Mock).mockReturnValue( + makeParsedError(ErrorCode.DeviceDisconnected), + ); + + renderAndTransitionToWaiting(createErrorState(error)); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledWith({ + type: HardwareWalletsSwapsEventType.DeviceDisconnected, + }); + }); + + it('dispatches REJECTED for user cancellation errors', () => { + const error = new Error('user rejected'); + (parseErrorByType as jest.Mock).mockReturnValue( + makeParsedError(ErrorCode.UserRejected), + ); + (isUserCancellation as jest.Mock).mockReturnValue(true); + + renderAndTransitionToWaiting(createErrorState(error)); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledWith({ + type: HardwareWalletsSwapsEventType.Rejected, + }); + }); + + it('does not dispatch transaction failure for recoverable connection errors', () => { + const error = new Error('Bluetooth is turned off'); + (parseErrorByType as jest.Mock).mockReturnValue( + makeParsedError(ErrorCode.Unknown), + ); + (isUserCancellation as jest.Mock).mockReturnValue(false); + + renderAndTransitionToWaiting(createErrorState(error)); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + }); + + it('does not dispatch when isEnabled is false', () => { + mockUseHardwareWallet.mockReturnValue( + mockContextWith(createDisconnectedState()), + ); + + renderHook(() => + useHwConnectionMonitoring({ + isEnabled: false, + currentStatus: HardwareWalletsSwapsStatus.Waiting, + hasActiveSigning: true, + }), + ); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + }); + + it('does not dispatch when status is not Waiting', () => { + mockUseHardwareWallet.mockReturnValue( + mockContextWith(createDisconnectedState()), + ); + + renderHook(() => + useHwConnectionMonitoring({ + isEnabled: true, + currentStatus: HardwareWalletsSwapsStatus.Submitted, + hasActiveSigning: true, + }), + ); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + }); + + it('does not repeatedly dispatch for ignored recoverable connection errors', () => { + const error = new Error('Bluetooth is turned off'); + (parseErrorByType as jest.Mock).mockReturnValue( + makeParsedError(ErrorCode.Unknown), + ); + + const { rerender } = renderAndTransitionToWaiting(createErrorState(error)); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + }); + + it('does not dispatch for non-error, non-disconnected states', () => { + mockUseHardwareWallet.mockReturnValue(mockContextWith(createReadyState())); + + renderHook(() => + useHwConnectionMonitoring({ + isEnabled: true, + currentStatus: HardwareWalletsSwapsStatus.Waiting, + hasActiveSigning: false, + }), + ); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + }); + + it('returns resetHandledError', () => { + mockUseHardwareWallet.mockReturnValue(mockContextWith(createReadyState())); + + const { result } = renderHook(() => + useHwConnectionMonitoring({ + isEnabled: true, + currentStatus: HardwareWalletsSwapsStatus.Waiting, + hasActiveSigning: true, + }), + ); + + expect(result.current.resetHandledError).toBeInstanceOf(Function); + }); + + it('resetHandledError does not re-dispatch when effect dependencies are unchanged', () => { + const readyState = createReadyState(); + const disconnectedState = createDisconnectedState(); + + mockUseHardwareWallet.mockReturnValue(mockContextWith(readyState)); + + const { rerender, result } = renderHook( + ({ currentStatus }) => + useHwConnectionMonitoring({ + isEnabled: true, + currentStatus, + hasActiveSigning: true, + }), + { initialProps: { currentStatus: HardwareWalletsSwapsStatus.Idle } }, + ); + + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + mockUseHardwareWallet.mockReturnValue(mockContextWith(disconnectedState)); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledTimes(1); + + result.current.resetHandledError(); + + mockUseHardwareWallet.mockReturnValue(mockContextWith(disconnectedState)); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledTimes(1); + }); + + it('resetHandledError allows re-dispatch after a subsequent connection state change', () => { + const readyState = createReadyState(); + const disconnectedState = createDisconnectedState(); + + mockUseHardwareWallet.mockReturnValue(mockContextWith(readyState)); + + const { rerender, result } = renderHook( + ({ currentStatus }) => + useHwConnectionMonitoring({ + isEnabled: true, + currentStatus, + hasActiveSigning: true, + }), + { initialProps: { currentStatus: HardwareWalletsSwapsStatus.Idle } }, + ); + + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + mockUseHardwareWallet.mockReturnValue(mockContextWith(disconnectedState)); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledTimes(1); + + result.current.resetHandledError(); + + mockUseHardwareWallet.mockReturnValue(mockContextWith(readyState)); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + mockUseHardwareWallet.mockReturnValue(mockContextWith(disconnectedState)); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledTimes(2); + }); + + it('does not dispatch DEVICE_DISCONNECTED twice for the same disconnect while Waiting', () => { + const { rerender } = renderAndTransitionToWaiting( + createDisconnectedState(), + ); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledTimes(1); + + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledTimes(1); + }); + + it('does not dispatch REJECTED twice for the same error while Waiting', () => { + const error = new Error('user rejected'); + (parseErrorByType as jest.Mock).mockReturnValue( + makeParsedError(ErrorCode.UserRejected), + ); + (isUserCancellation as jest.Mock).mockReturnValue(true); + + const { rerender } = renderAndTransitionToWaiting(createErrorState(error)); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledTimes(1); + + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledTimes(1); + }); + + it('ignores pre-existing Disconnected state when first entering Waiting', () => { + mockUseHardwareWallet.mockReturnValue( + mockContextWith(createDisconnectedState()), + ); + + renderHook(() => + useHwConnectionMonitoring({ + isEnabled: true, + currentStatus: HardwareWalletsSwapsStatus.Waiting, + hasActiveSigning: false, + }), + ); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + }); + + it('ignores pre-existing ErrorState when first entering Waiting', () => { + const error = new Error('stale error'); + (parseErrorByType as jest.Mock).mockReturnValue( + makeParsedError(ErrorCode.Unknown), + ); + (isUserCancellation as jest.Mock).mockReturnValue(false); + + mockUseHardwareWallet.mockReturnValue( + mockContextWith(createErrorState(error)), + ); + + renderHook(() => + useHwConnectionMonitoring({ + isEnabled: true, + currentStatus: HardwareWalletsSwapsStatus.Waiting, + hasActiveSigning: false, + }), + ); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + }); + + it('dispatches after recovery when a new ErrorState occurs following stale baseline', () => { + const staleError = new Error('stale error'); + const newError = new Error('device disconnected'); + (parseErrorByType as jest.Mock).mockImplementation((error: unknown) => { + if (error === newError) { + return makeParsedError(ErrorCode.DeviceDisconnected); + } + return makeParsedError(ErrorCode.Unknown); + }); + + const readyState = createReadyState(); + mockUseHardwareWallet.mockReturnValue( + mockContextWith(createErrorState(staleError)), + ); + + const { rerender } = renderHook( + ({ currentStatus }) => + useHwConnectionMonitoring({ + isEnabled: true, + currentStatus, + hasActiveSigning: true, + }), + { initialProps: { currentStatus: HardwareWalletsSwapsStatus.Waiting } }, + ); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + + mockUseHardwareWallet.mockReturnValue(mockContextWith(readyState)); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + mockUseHardwareWallet.mockReturnValue( + mockContextWith(createErrorState(newError)), + ); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledWith({ + type: HardwareWalletsSwapsEventType.DeviceDisconnected, + }); + }); + + it('dispatches after recovery when Disconnected re-occurs following stale baseline', () => { + const readyState = createReadyState(); + mockUseHardwareWallet.mockReturnValue( + mockContextWith(createDisconnectedState()), + ); + + const { rerender } = renderHook( + ({ currentStatus }) => + useHwConnectionMonitoring({ + isEnabled: true, + currentStatus, + hasActiveSigning: true, + }), + { initialProps: { currentStatus: HardwareWalletsSwapsStatus.Waiting } }, + ); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + + mockUseHardwareWallet.mockReturnValue(mockContextWith(readyState)); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + mockUseHardwareWallet.mockReturnValue( + mockContextWith(createDisconnectedState()), + ); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + expect(updateHardwareWalletsSwaps).toHaveBeenCalledWith({ + type: HardwareWalletsSwapsEventType.DeviceDisconnected, + }); + }); + + it('clears handled error when connection recovers from error to ready', () => { + const error = new Error('temp error'); + (parseErrorByType as jest.Mock).mockReturnValue( + makeParsedError(ErrorCode.Unknown), + ); + (isUserCancellation as jest.Mock).mockReturnValue(false); + + const readyState = createReadyState(); + mockUseHardwareWallet.mockReturnValue(mockContextWith(readyState)); + + const { rerender } = renderHook( + ({ currentStatus }) => + useHwConnectionMonitoring({ + isEnabled: true, + currentStatus, + hasActiveSigning: true, + }), + { initialProps: { currentStatus: HardwareWalletsSwapsStatus.Idle } }, + ); + + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + mockUseHardwareWallet.mockReturnValue( + mockContextWith(createErrorState(error)), + ); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + + mockUseHardwareWallet.mockReturnValue(mockContextWith(readyState)); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + mockUseHardwareWallet.mockReturnValue( + mockContextWith(createErrorState(error)), + ); + rerender({ currentStatus: HardwareWalletsSwapsStatus.Waiting }); + + expect(updateHardwareWalletsSwaps).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/HardwareWallet/Swaps/useHwConnectionMonitoring.ts b/app/components/UI/HardwareWallet/Swaps/useHwConnectionMonitoring.ts new file mode 100644 index 000000000000..6b4ad23a1ada --- /dev/null +++ b/app/components/UI/HardwareWallet/Swaps/useHwConnectionMonitoring.ts @@ -0,0 +1,156 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { ConnectionStatus, ErrorCode } from '@metamask/hw-wallet-sdk'; +import { useHardwareWallet } from '../../../../core/HardwareWallet'; +import { isUserCancellation } from '../../../../core/HardwareWallet/errors/helpers'; +import { parseErrorByType } from '../../../../core/HardwareWallet/errors/parser'; +import { updateHardwareWalletsSwaps } from '../../../../core/redux/slices/bridge'; +import { + HardwareWalletsSwapsStatus, + HardwareWalletsSwapsEventType, +} from './HardwareWalletsSwaps.state'; + +interface UseHwConnectionMonitoringOptions { + /** When false, connection changes are not observed or dispatched. */ + isEnabled: boolean; + /** Current hardware-wallet swaps state-machine status from Redux. */ + currentStatus: HardwareWalletsSwapsStatus; + /** + * True once a sign operation is in flight. Disconnect events are ignored + * until signing starts so pre-signing readiness handoffs do not fail the flow. + */ + hasActiveSigning: boolean; +} + +/** + * Returns whether `current` should be treated as unchanged relative to the + * baseline captured when entering {@link HardwareWalletsSwapsStatus.Waiting}. + * Used to ignore stale disconnect/error state left over from a prior attempt. + * + * @param baseline - Connection snapshot taken when Waiting began. + * @param current - Latest connection snapshot from hardware wallet context. + * @returns True when `current` matches `baseline` (same status and, for + */ +export function shouldIgnoreAsBaseline( + baseline: { status: ConnectionStatus; error?: unknown }, + current: { status: ConnectionStatus; error?: unknown }, +): boolean { + if (baseline.status !== current.status) { + return false; + } + + if ( + baseline.status === ConnectionStatus.ErrorState && + current.status === ConnectionStatus.ErrorState + ) { + return baseline.error === current.error; + } + + return true; +} + +/** + * Monitors hardware wallet connection state during the swaps signing flow. + * + * While status is {@link HardwareWalletsSwapsStatus.Waiting}, watches for + * disconnects and signing-related errors and dispatches matching + * actions. Ignores pre-existing bad + * connection state on first entry to Waiting and recoverable transport errors. + */ +export function useHwConnectionMonitoring({ + isEnabled, + currentStatus, + hasActiveSigning, +}: UseHwConnectionMonitoringOptions) { + const dispatch = useDispatch(); + const { connectionState } = useHardwareWallet(); + const handledErrorRef = useRef(null); + const baselineStateRef = useRef(null); + const prevWaitingRef = useRef(false); + + useEffect(() => { + const isWaiting = currentStatus === HardwareWalletsSwapsStatus.Waiting; + + if (isWaiting && !prevWaitingRef.current) { + baselineStateRef.current = connectionState; + handledErrorRef.current = null; + } + prevWaitingRef.current = isWaiting; + + if (!isEnabled || !isWaiting) return; + + if ( + baselineStateRef.current && + connectionState.status !== baselineStateRef.current.status + ) { + baselineStateRef.current = null; + } + + if ( + baselineStateRef.current && + shouldIgnoreAsBaseline(baselineStateRef.current, connectionState) + ) { + return; + } + + if (connectionState.status === ConnectionStatus.Disconnected) { + if (!hasActiveSigning) { + return; + } + if (handledErrorRef.current === ConnectionStatus.Disconnected) return; + handledErrorRef.current = ConnectionStatus.Disconnected; + dispatch( + updateHardwareWalletsSwaps({ + type: HardwareWalletsSwapsEventType.DeviceDisconnected, + }), + ); + return; + } + + if (connectionState.status !== ConnectionStatus.ErrorState) { + handledErrorRef.current = null; + return; + } + + const { error } = connectionState; + if (handledErrorRef.current === error) return; + + const parsedError = parseErrorByType(error); + + if ( + parsedError.code === ErrorCode.ConnectionClosed || + parsedError.code === ErrorCode.DeviceDisconnected + ) { + if (!hasActiveSigning) { + return; + } + handledErrorRef.current = error; + dispatch( + updateHardwareWalletsSwaps({ + type: HardwareWalletsSwapsEventType.DeviceDisconnected, + }), + ); + return; + } + + if (error && isUserCancellation(error)) { + handledErrorRef.current = error; + dispatch( + updateHardwareWalletsSwaps({ + type: HardwareWalletsSwapsEventType.Rejected, + }), + ); + return; + } + + handledErrorRef.current = error; + }, [connectionState, currentStatus, hasActiveSigning, isEnabled, dispatch]); + + /** Clears deduplication and baseline refs so a retry can observe the same error again. */ + const resetHandledError = useCallback(() => { + handledErrorRef.current = null; + baselineStateRef.current = null; + }, []); + + return { resetHandledError }; +} diff --git a/app/components/UI/HardwareWallet/Swaps/useHwQrState.test.ts b/app/components/UI/HardwareWallet/Swaps/useHwQrState.test.ts new file mode 100644 index 000000000000..931b0988d670 --- /dev/null +++ b/app/components/UI/HardwareWallet/Swaps/useHwQrState.test.ts @@ -0,0 +1,329 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { ConnectionStatus, HardwareWalletType } from '@metamask/hw-wallet-sdk'; +import { QrScanRequestType } from '@metamask/eth-qr-keyring'; +import { useHwQrState } from './useHwQrState'; +import { updateHardwareWalletsSwaps } from '../../../../core/redux/slices/bridge'; +import { + HardwareWalletsSwapsStatus, + HardwareWalletsSwapsEventType, +} from './HardwareWalletsSwaps.state'; +import type { HardwareWalletContextValue } from '../../../../core/HardwareWallet/contexts'; + +jest.mock('../../../../core/HardwareWallet', () => ({ + useHardwareWallet: jest.fn(), +})); + +jest.mock('../../../../core/redux/slices/bridge', () => ({ + updateHardwareWalletsSwaps: jest.fn((action) => action), +})); + +const mockDispatch = jest.fn((action: unknown) => action); + +jest.mock('react-redux', () => ({ + useDispatch: () => mockDispatch, + useSelector: jest.fn(), +})); + +import { useHardwareWallet } from '../../../../core/HardwareWallet'; + +const mockUseHardwareWallet = useHardwareWallet as jest.MockedFunction< + typeof useHardwareWallet +>; + +function makeQrScanRequest(id: string) { + return { + type: QrScanRequestType.SIGN, + request: { id }, + } as unknown as NonNullable< + HardwareWalletContextValue['qr']['pendingScanRequest'] + >; +} + +function mockQrWallet( + pendingScanRequest?: NonNullable< + HardwareWalletContextValue['qr']['pendingScanRequest'] + >, +): HardwareWalletContextValue { + return { + walletType: HardwareWalletType.Qr, + deviceId: null, + connectionState: { status: ConnectionStatus.Ready }, + deviceSelection: { + devices: [], + selectedDevice: null, + isScanning: false, + scanError: null, + }, + ensureDeviceReady: jest.fn(), + setTargetWalletType: jest.fn(), + setPendingOperationAddress: jest.fn(), + showHardwareWalletError: jest.fn(), + showAwaitingConfirmation: jest.fn(), + hideAwaitingConfirmation: jest.fn(), + qr: { + pendingScanRequest, + isSigningQRObject: false, + setRequestCompleted: jest.fn(), + isRequestCompleted: false, + cancelQRScanRequestIfPresent: jest.fn(), + }, + }; +} + +function mockLedgerWallet(): HardwareWalletContextValue { + return { + walletType: HardwareWalletType.Ledger, + deviceId: null, + connectionState: { status: ConnectionStatus.Ready }, + deviceSelection: { + devices: [], + selectedDevice: null, + isScanning: false, + scanError: null, + }, + ensureDeviceReady: jest.fn(), + setTargetWalletType: jest.fn(), + setPendingOperationAddress: jest.fn(), + showHardwareWalletError: jest.fn(), + showAwaitingConfirmation: jest.fn(), + hideAwaitingConfirmation: jest.fn(), + qr: { + pendingScanRequest: undefined, + isSigningQRObject: false, + setRequestCompleted: jest.fn(), + isRequestCompleted: false, + cancelQRScanRequestIfPresent: jest.fn(), + }, + }; +} + +function renderQrState(options: { + isEnabled?: boolean; + currentStatus?: HardwareWalletsSwapsStatus; +}) { + return renderHook(() => + useHwQrState({ + isEnabled: options.isEnabled ?? true, + currentStatus: + options.currentStatus ?? HardwareWalletsSwapsStatus.Waiting, + }), + ); +} + +describe('useHwQrState', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseHardwareWallet.mockReturnValue(mockLedgerWallet()); + }); + + it('detects QR hardware wallet type', () => { + mockUseHardwareWallet.mockReturnValue(mockQrWallet()); + + const { result } = renderQrState({}); + + expect(result.current.isQrHardwareWallet).toBe(true); + }); + + it('shows inline QR signing when in Waiting state with active QR request', () => { + mockUseHardwareWallet.mockReturnValue( + mockQrWallet(makeQrScanRequest('scan-1')), + ); + + const { result } = renderQrState({}); + + expect(result.current.showInlineQrSigning).toBe(true); + }); + + it('does not show inline QR signing when not in Waiting state', () => { + mockUseHardwareWallet.mockReturnValue( + mockQrWallet(makeQrScanRequest('scan-1')), + ); + + const { result } = renderQrState({ + currentStatus: HardwareWalletsSwapsStatus.Submitted, + }); + + expect(result.current.showInlineQrSigning).toBe(false); + }); + + it('handleQrSignatureCancel calls cancel and dispatches REJECTED', () => { + const mockQr = mockQrWallet(); + mockUseHardwareWallet.mockReturnValue(mockQr); + + const { result } = renderQrState({}); + + act(() => { + result.current.handleQrSignatureCancel(); + }); + + expect(mockQr.qr.cancelQRScanRequestIfPresent).toHaveBeenCalledTimes(1); + expect(updateHardwareWalletsSwaps).toHaveBeenCalledWith({ + type: HardwareWalletsSwapsEventType.Rejected, + }); + }); + + it('resets isReadingQrSignature when request ID changes', () => { + const { result, rerender } = renderHook( + ({ + pendingScanRequest, + }: { + pendingScanRequest: NonNullable< + HardwareWalletContextValue['qr']['pendingScanRequest'] + >; + }) => { + mockUseHardwareWallet.mockReturnValue(mockQrWallet(pendingScanRequest)); + return useHwQrState({ + isEnabled: true, + currentStatus: HardwareWalletsSwapsStatus.Waiting, + }); + }, + { + initialProps: { pendingScanRequest: makeQrScanRequest('scan-1') }, + }, + ); + + act(() => { + result.current.setIsReadingQrSignature(true); + }); + + expect(result.current.isReadingQrSignature).toBe(true); + + rerender({ pendingScanRequest: makeQrScanRequest('scan-2') }); + + expect(result.current.isReadingQrSignature).toBe(false); + }); + + it('returns false for showInlineQrSigning when not a QR wallet', () => { + mockUseHardwareWallet.mockReturnValue(mockLedgerWallet()); + + const { result } = renderQrState({}); + + expect(result.current.isQrHardwareWallet).toBe(false); + expect(result.current.showInlineQrSigning).toBe(false); + }); + + it('returns false for showInlineQrSigning when disabled', () => { + mockUseHardwareWallet.mockReturnValue( + mockQrWallet(makeQrScanRequest('scan-1')), + ); + + const { result } = renderQrState({ isEnabled: false }); + + expect(result.current.showInlineQrSigning).toBe(false); + }); + + describe('auto-cancel pending QR scan on terminal state', () => { + const terminalStatuses: HardwareWalletsSwapsStatus[] = [ + HardwareWalletsSwapsStatus.Failed, + HardwareWalletsSwapsStatus.Rejected, + HardwareWalletsSwapsStatus.Cancelled, + HardwareWalletsSwapsStatus.Disconnected, + ]; + + it.each( + terminalStatuses.map((status) => ({ + status, + statusName: status, + })), + )( + 'cancels pending QR scan request when status transitions to $statusName', + ({ status }) => { + const mockQr = mockQrWallet(makeQrScanRequest('scan-1')); + mockUseHardwareWallet.mockReturnValue(mockQr); + + const { rerender } = renderHook( + ({ currentStatus }: { currentStatus: HardwareWalletsSwapsStatus }) => + useHwQrState({ + isEnabled: true, + currentStatus, + }), + { + initialProps: { currentStatus: HardwareWalletsSwapsStatus.Waiting }, + }, + ); + + expect(mockQr.qr.cancelQRScanRequestIfPresent).not.toHaveBeenCalled(); + + rerender({ currentStatus: status }); + + expect(mockQr.qr.cancelQRScanRequestIfPresent).toHaveBeenCalledTimes(1); + }, + ); + + it('does not cancel QR scan when transitioning to Submitted', () => { + const mockQr = mockQrWallet(makeQrScanRequest('scan-1')); + mockUseHardwareWallet.mockReturnValue(mockQr); + + const { rerender } = renderHook( + ({ currentStatus }: { currentStatus: HardwareWalletsSwapsStatus }) => + useHwQrState({ + isEnabled: true, + currentStatus, + }), + { + initialProps: { currentStatus: HardwareWalletsSwapsStatus.Waiting }, + }, + ); + + rerender({ currentStatus: HardwareWalletsSwapsStatus.Submitted }); + + expect(mockQr.qr.cancelQRScanRequestIfPresent).not.toHaveBeenCalled(); + }); + + it('does not cancel QR scan when already in terminal state', () => { + const mockQr = mockQrWallet(makeQrScanRequest('scan-1')); + mockUseHardwareWallet.mockReturnValue(mockQr); + + renderHook(() => + useHwQrState({ + isEnabled: true, + currentStatus: HardwareWalletsSwapsStatus.Failed, + }), + ); + + expect(mockQr.qr.cancelQRScanRequestIfPresent).not.toHaveBeenCalled(); + }); + + it('does not cancel QR scan for non-QR wallets on terminal state', () => { + const mockLedger = mockLedgerWallet(); + mockUseHardwareWallet.mockReturnValue(mockLedger); + + const { rerender } = renderHook( + ({ currentStatus }: { currentStatus: HardwareWalletsSwapsStatus }) => + useHwQrState({ + isEnabled: true, + currentStatus, + }), + { + initialProps: { currentStatus: HardwareWalletsSwapsStatus.Waiting }, + }, + ); + + rerender({ currentStatus: HardwareWalletsSwapsStatus.Failed }); + + expect(mockLedger.qr.cancelQRScanRequestIfPresent).not.toHaveBeenCalled(); + }); + + it('cancels QR scan only once when transitioning through multiple terminal states', () => { + const mockQr = mockQrWallet(makeQrScanRequest('scan-1')); + mockUseHardwareWallet.mockReturnValue(mockQr); + + const { rerender } = renderHook( + ({ currentStatus }: { currentStatus: HardwareWalletsSwapsStatus }) => + useHwQrState({ + isEnabled: true, + currentStatus, + }), + { + initialProps: { currentStatus: HardwareWalletsSwapsStatus.Waiting }, + }, + ); + + rerender({ currentStatus: HardwareWalletsSwapsStatus.Failed }); + expect(mockQr.qr.cancelQRScanRequestIfPresent).toHaveBeenCalledTimes(1); + + rerender({ currentStatus: HardwareWalletsSwapsStatus.Cancelled }); + expect(mockQr.qr.cancelQRScanRequestIfPresent).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/app/components/UI/HardwareWallet/Swaps/useHwQrState.ts b/app/components/UI/HardwareWallet/Swaps/useHwQrState.ts new file mode 100644 index 000000000000..0d25d9032899 --- /dev/null +++ b/app/components/UI/HardwareWallet/Swaps/useHwQrState.ts @@ -0,0 +1,91 @@ +import { useCallback, useState, useEffect, useMemo, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { useHardwareWallet } from '../../../../core/HardwareWallet'; +import { HardwareWalletType } from '@metamask/hw-wallet-sdk'; +import { updateHardwareWalletsSwaps } from '../../../../core/redux/slices/bridge'; +import { + HardwareWalletsSwapsStatus, + HardwareWalletsSwapsEventType, +} from './HardwareWalletsSwaps.state'; + +interface UseHwQrStateOptions { + isEnabled: boolean; + currentStatus: HardwareWalletsSwapsStatus; +} + +const TERMINAL_STATUSES: Set = new Set([ + HardwareWalletsSwapsStatus.Failed, + HardwareWalletsSwapsStatus.Rejected, + HardwareWalletsSwapsStatus.Cancelled, + HardwareWalletsSwapsStatus.Disconnected, +]); + +export function useHwQrState({ + isEnabled, + currentStatus, +}: UseHwQrStateOptions) { + const dispatch = useDispatch(); + const { walletType, qr } = useHardwareWallet(); + + const isQrHardwareWallet = walletType === HardwareWalletType.Qr; + const pendingScanRequest = qr.pendingScanRequest; + + const [isReadingQrSignature, setIsReadingQrSignature] = useState(false); + + const wasActiveRef = useRef(false); + const hasCancelledForTerminalRef = useRef(false); + + useEffect(() => { + setIsReadingQrSignature(false); + }, [pendingScanRequest]); + + useEffect(() => { + const isActive = + currentStatus === HardwareWalletsSwapsStatus.Waiting || + currentStatus === HardwareWalletsSwapsStatus.Submitted; + const isTerminal = TERMINAL_STATUSES.has(currentStatus); + + if (isActive) { + wasActiveRef.current = true; + hasCancelledForTerminalRef.current = false; + } + + if ( + isTerminal && + wasActiveRef.current && + !hasCancelledForTerminalRef.current && + isEnabled && + isQrHardwareWallet + ) { + hasCancelledForTerminalRef.current = true; + qr.cancelQRScanRequestIfPresent(); + } + }, [currentStatus, isEnabled, isQrHardwareWallet, qr]); + + const showInlineQrSigning = useMemo( + () => + isEnabled && + isQrHardwareWallet && + Boolean(pendingScanRequest) && + currentStatus === HardwareWalletsSwapsStatus.Waiting, + [isEnabled, isQrHardwareWallet, pendingScanRequest, currentStatus], + ); + + const handleQrSignatureCancel = useCallback(() => { + qr.cancelQRScanRequestIfPresent(); + dispatch( + updateHardwareWalletsSwaps({ + type: HardwareWalletsSwapsEventType.Rejected, + }), + ); + }, [qr, dispatch]); + + return { + isReadingQrSignature, + setIsReadingQrSignature, + isQrHardwareWallet, + showInlineQrSigning, + handleQrSignatureCancel, + pendingScanRequest, + }; +} diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx index 4245c2fdf6d5..66a07ad07996 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx @@ -26,6 +26,7 @@ import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import useMoneyAccountInfo from '../../hooks/useMoneyAccountInfo'; import { selectIsCardholder } from '../../../../../selectors/cardController'; import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountCardLinkage'; +import { MONEY_HOME_CARD_ORIGIN } from '../../../Card/hooks/useCardPostAuthRedirect'; import { getDetectedGeolocation } from '../../../../../reducers/fiatOrders'; import { moneyFormatFiat } from '../../utils/moneyFormatFiat'; import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; @@ -255,7 +256,6 @@ describe('MoneyHomeView', () => { } as unknown as ReturnType); mockUseMoneyAccountInfo.mockReturnValue({ - isMoneyAccountFeatureEnabled: true, hasMoneyAccount: true, primaryMoneyAccount: { address: '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', @@ -435,32 +435,11 @@ describe('MoneyHomeView', () => { }); describe('displayState precedence matrix', () => { - it('featureDisabled — renders feature-disabled message, hides MoneyEarnings', () => { - mockUseMoneyAccountInfo.mockReturnValue({ - isMoneyAccountFeatureEnabled: false, - hasMoneyAccount: true, - primaryMoneyAccount: { - address: '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', - }, - } as ReturnType); - - const { getByTestId, queryByTestId } = renderWithProvider( - , - ); - - expect( - getByTestId(MoneyBalanceSummaryTestIds.BALANCE_FEATURE_DISABLED), - ).toBeOnTheScreen(); - expect( - queryByTestId(MoneyEarningsTestIds.CONTAINER), - ).not.toBeOnTheScreen(); - }); - it('noAccount — renders no-account message, hides MoneyEarnings', () => { mockUseMoneyAccountInfo.mockReturnValue({ - isMoneyAccountFeatureEnabled: true, hasMoneyAccount: false, primaryMoneyAccount: undefined, + isMoneyAccountFeatureEnabled: true, }); const { getByTestId, queryByTestId } = renderWithProvider( @@ -475,25 +454,6 @@ describe('MoneyHomeView', () => { ).not.toBeOnTheScreen(); }); - it('featureDisabled takes precedence over noAccount', () => { - mockUseMoneyAccountInfo.mockReturnValue({ - isMoneyAccountFeatureEnabled: false, - hasMoneyAccount: false, - primaryMoneyAccount: undefined, - }); - - const { getByTestId, queryByTestId } = renderWithProvider( - , - ); - - expect( - getByTestId(MoneyBalanceSummaryTestIds.BALANCE_FEATURE_DISABLED), - ).toBeOnTheScreen(); - expect( - queryByTestId(MoneyBalanceSummaryTestIds.BALANCE_NO_ACCOUNT), - ).not.toBeOnTheScreen(); - }); - it('error takes precedence over loading and balance', () => { mockUseMoneyAccountBalance.mockReturnValue({ totalFiatFormatted: undefined, @@ -570,11 +530,11 @@ describe('MoneyHomeView', () => { }); }); - it('MoneyHowItWorks stays mounted in featureDisabled state (empty tx count)', () => { + it('MoneyHowItWorks stays mounted in noAccount state (empty tx count)', () => { mockUseMoneyAccountInfo.mockReturnValue({ - isMoneyAccountFeatureEnabled: false, hasMoneyAccount: false, primaryMoneyAccount: undefined, + isMoneyAccountFeatureEnabled: true, }); mockUseMoneyAccountTransactions.mockReturnValue({ allTransactions: [], @@ -671,7 +631,10 @@ describe('MoneyHomeView', () => { fireEvent.press(getByTestId(MoneyActionButtonRowTestIds.CARD_BUTTON)); - expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT); + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { + screen: Routes.CARD.HOME, + params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + }); }); it('opens the APY info sheet when the APY info button is pressed', () => { @@ -710,7 +673,10 @@ describe('MoneyHomeView', () => { fireEvent.press(getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW)); - expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT); + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { + screen: Routes.CARD.HOME, + params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + }); }); it('navigates to potential earnings screen when View potential earnings is pressed', () => { @@ -1182,7 +1148,10 @@ describe('MoneyHomeView', () => { fireEvent.press(getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW)); - expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT); + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { + screen: Routes.CARD.HOME, + params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + }); }); }); @@ -1297,7 +1266,10 @@ describe('MoneyHomeView', () => { fireEvent.press(getByTestId(MoneyMetaMaskCardTestIds.MANAGE_BUTTON)); - expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT); + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { + screen: Routes.CARD.HOME, + params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + }); }); }); @@ -1309,7 +1281,10 @@ describe('MoneyHomeView', () => { fireEvent.press(getByText(strings('money.metamask_card.get_now'))); - expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT); + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { + screen: Routes.CARD.HOME, + params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + }); }); it('navigates to the card sign-up flow when the metal card Get now button is pressed', () => { @@ -1320,7 +1295,10 @@ describe('MoneyHomeView', () => { fireEvent.press(buttons[1]); - expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT); + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { + screen: Routes.CARD.HOME, + params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + }); }); }); }); diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx index d1c489ab1653..b47ec8068f98 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx @@ -39,6 +39,7 @@ import AppConstants from '../../../../../core/AppConstants'; import NavigationService from '../../../../../core/NavigationService'; import { selectIsCardholder } from '../../../../../selectors/cardController'; import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountCardLinkage'; +import { MONEY_HOME_CARD_ORIGIN } from '../../../Card/hooks/useCardPostAuthRedirect'; import { getDetectedGeolocation } from '../../../../../reducers/fiatOrders'; import Logger from '../../../../../util/Logger'; import { useTheme } from '../../../../../util/theme'; @@ -88,8 +89,7 @@ const MoneyHomeView = () => { } }, [refetchBalance]); - const { isMoneyAccountFeatureEnabled, hasMoneyAccount } = - useMoneyAccountInfo(); + const { hasMoneyAccount } = useMoneyAccountInfo(); const { fiatBalanceAggregatedFormatted: musdFiatFormatted } = useMusdBalance(); @@ -113,9 +113,7 @@ const MoneyHomeView = () => { const isCardholderWithMilestone = isMilestone && isCardholder; let displayState: MoneyBalanceDisplayState; - if (!isMoneyAccountFeatureEnabled) { - displayState = { kind: 'featureDisabled' }; - } else if (!hasMoneyAccount) { + if (!hasMoneyAccount) { displayState = { kind: 'noAccount' }; } else if (isBalanceFetchError && isBalanceFetching) { displayState = { kind: 'retrying' }; @@ -181,7 +179,10 @@ const MoneyHomeView = () => { }, [navigation]); const handleCardPress = useCallback(() => { - navigation.navigate(Routes.CARD.ROOT); + navigation.navigate(Routes.CARD.ROOT, { + screen: Routes.CARD.HOME, + params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + }); }, [navigation]); const handleLinkCardPress = useCallback(() => { diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx index 2d38581fa7ba..e3179ed65f08 100644 --- a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx @@ -97,7 +97,6 @@ const createInfoMock = ( overrides: Partial> = {}, ): ReturnType => ({ - isMoneyAccountFeatureEnabled: true, hasMoneyAccount: true, primaryMoneyAccount: { address: '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', @@ -733,43 +732,10 @@ describe('MoneyBalanceCard', () => { }); }); - describe('featureDisabled state', () => { - beforeEach(() => { - mockUseMoneyAccountInfo.mockReturnValue( - createInfoMock({ isMoneyAccountFeatureEnabled: false }), - ); - }); - - it('renders the feature-disabled message in the balance slot', () => { - const { getByTestId } = renderWithProvider(); - - expect( - getByTestId(MoneyBalanceCardTestIds.BALANCE_FEATURE_DISABLED), - ).toHaveTextContent(strings('money.balance_feature_disabled')); - }); - - it('does not render the balance text', () => { - const { queryByTestId } = renderWithProvider(); - - expect( - queryByTestId(MoneyBalanceCardTestIds.BALANCE), - ).not.toBeOnTheScreen(); - }); - - it('does not render the balance error message', () => { - const { queryByTestId } = renderWithProvider(); - - expect( - queryByTestId(MoneyBalanceCardTestIds.BALANCE_ERROR), - ).not.toBeOnTheScreen(); - }); - }); - describe('noAccount state', () => { beforeEach(() => { mockUseMoneyAccountInfo.mockReturnValue( createInfoMock({ - isMoneyAccountFeatureEnabled: true, hasMoneyAccount: false, primaryMoneyAccount: undefined, }), diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts index a747ff0ec1ac..333c9aced670 100644 --- a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.testIds.ts @@ -11,7 +11,6 @@ export const MoneyBalanceCardTestIds = { BALANCE_ERROR: 'money-balance-card-balance-error', BALANCE_RETRY: 'money-balance-card-balance-retry', BALANCE_UNAVAILABLE: 'money-balance-card-balance-unavailable', - BALANCE_FEATURE_DISABLED: 'money-balance-card-balance-feature-disabled', BALANCE_NO_ACCOUNT: 'money-balance-card-balance-no-account', APY_TAG: 'money-balance-card-apy-tag', APY_TAG_SKELETON: 'money-balance-card-apy-tag-skeleton', diff --git a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx index 1bdd2ff30d68..0a4d8c09231e 100644 --- a/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx +++ b/app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx @@ -47,40 +47,28 @@ const MoneyBalanceCard = () => { refetchBalance, vaultApyQuery, } = useMoneyAccountBalance(); - const { isMoneyAccountFeatureEnabled, hasMoneyAccount } = - useMoneyAccountInfo(); + const { hasMoneyAccount } = useMoneyAccountInfo(); const { navigateToMoneyHome } = useMoneyNavigation(); const hasSeenMoneyOnboarding = useSelector(selectMoneyOnboardingSeen); const hasOtherPrimaryCtaOnHome = useSelector( selectWalletHomeOnboardingFlowVisible, ); - const isFeatureDisabled = !isMoneyAccountFeatureEnabled; - const isNoAccount = isMoneyAccountFeatureEnabled && !hasMoneyAccount; const isRetrying = - !isFeatureDisabled && - !isNoAccount && - isBalanceFetchError && - isBalanceFetching; - const isError = - !isFeatureDisabled && - !isNoAccount && - isBalanceFetchError && - !isBalanceFetching; + hasMoneyAccount && isBalanceFetchError && isBalanceFetching; + const isError = hasMoneyAccount && isBalanceFetchError && !isBalanceFetching; // Queries succeeded (no error, not loading) but a dependency required to // format the balance (e.g. musdFiatRate) is missing. const isUnavailable = - !isFeatureDisabled && - !isNoAccount && + hasMoneyAccount && !isBalanceFetchError && !isAggregatedBalanceLoading && totalFiatFormatted === undefined; // Genuinely zero balance — distinct from unavailable. const isEmpty = - !isFeatureDisabled && - !isNoAccount && + hasMoneyAccount && !isBalanceFetchError && !isUnavailable && totalFiatRaw === '0'; @@ -94,7 +82,7 @@ const MoneyBalanceCard = () => { let buttonTestId: string; let containerTestId: string; - if (isFeatureDisabled || isNoAccount || isError || isRetrying) { + if (!hasMoneyAccount || isError || isRetrying) { buttonVariant = ButtonVariant.Secondary; buttonLabel = strings('money.balance_card.add'); buttonTestId = MoneyBalanceCardTestIds.ADD_BUTTON; @@ -156,19 +144,7 @@ const MoneyBalanceCard = () => { }, [navigation]); const renderBalanceSlot = () => { - if (isFeatureDisabled) { - return ( - - {strings('money.balance_feature_disabled')} - - ); - } - if (isNoAccount) { + if (!hasMoneyAccount) { return ( { }); }); - describe('featureDisabled state', () => { - const featureDisabledState: MoneyBalanceDisplayState = { - kind: 'featureDisabled', - }; - - it('renders the feature-disabled message', () => { - const { getByTestId } = render( - , - ); - - expect( - getByTestId(MoneyBalanceSummaryTestIds.BALANCE_FEATURE_DISABLED), - ).toHaveTextContent(strings('money.balance_feature_disabled')); - }); - - it('does not render the balance text', () => { - const { queryByTestId } = render( - , - ); - - expect( - queryByTestId(MoneyBalanceSummaryTestIds.BALANCE), - ).not.toBeOnTheScreen(); - }); - - it('hides the APY row', () => { - const { queryByTestId } = render( - , - ); - - expect( - queryByTestId(MoneyBalanceSummaryTestIds.APY), - ).not.toBeOnTheScreen(); - expect( - queryByTestId(MoneyBalanceSummaryTestIds.APY_INFO_BUTTON), - ).not.toBeOnTheScreen(); - }); - }); - describe('noAccount state', () => { const noAccountState: MoneyBalanceDisplayState = { kind: 'noAccount' }; diff --git a/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.testIds.ts b/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.testIds.ts index 2bfe41663ca1..00d7ce80d20c 100644 --- a/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.testIds.ts +++ b/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.testIds.ts @@ -4,7 +4,6 @@ export const MoneyBalanceSummaryTestIds = { BALANCE_SKELETON: 'money-balance-summary-balance-skeleton', BALANCE_ERROR: 'money-balance-summary-balance-error', BALANCE_RETRY: 'money-balance-summary-balance-retry', - BALANCE_FEATURE_DISABLED: 'money-balance-summary-balance-feature-disabled', BALANCE_NO_ACCOUNT: 'money-balance-summary-balance-no-account', BALANCE_UNAVAILABLE: 'money-balance-summary-balance-unavailable', APY: 'money-balance-summary-apy', diff --git a/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx b/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx index a7d23d088f89..d895f38ad90b 100644 --- a/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx +++ b/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx @@ -133,17 +133,6 @@ const MoneyBalanceSummary = ({ {displayState.value} ); - case 'featureDisabled': - return ( - - {strings('money.balance_feature_disabled')} - - ); case 'noAccount': return ( { ).toBeOnTheScreen(); expect( getByText( - strings('money.metamask_card.link_card_sheet_description', { apy: 4 }), + strings('money.metamask_card.link_card_sheet_description_prefix'), + { exact: false }, ), ).toBeOnTheScreen(); + expect( + getByText(strings('money.apy_label', { percentage: 4 })), + ).toBeOnTheScreen(); expect( getByText(strings('money.metamask_card.link_card_sheet_cta')), ).toBeOnTheScreen(); @@ -129,13 +133,10 @@ describe('MoneyLinkCardSheet', () => { ); expect( - getByText( - strings('money.metamask_card.link_card_sheet_description', { apy: 7 }), - ), + getByText(strings('money.apy_label', { percentage: 7 })), ).toBeOnTheScreen(); - // Defence against regressions: the description must NEVER render the raw - // i18n placeholder (which is what happens if `apy` is not passed at all). expect(queryByText(/{{apy}}/)).toBeNull(); + expect(queryByText(/{{percentage}}/)).toBeNull(); }); it('falls back to no-APY copy when the vault APY query has not resolved yet', () => { diff --git a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx index a78923bb2cf0..76b676ed8105 100644 --- a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx +++ b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx @@ -10,7 +10,9 @@ import { BoxAlignItems, BoxJustifyContent, ButtonSize, + FontWeight, Text, + TextColor, TextVariant, type BottomSheetRef, } from '@metamask/design-system-react-native'; @@ -57,12 +59,23 @@ const MoneyLinkCardSheet = () => { }); }, [confirmLinkInBackground]); - const description = - apyPercent === undefined - ? strings('money.metamask_card.link_card_sheet_description_no_apy') - : strings('money.metamask_card.link_card_sheet_description', { - apy: apyPercent, - }); + const description: React.ReactNode = + apyPercent === undefined ? ( + strings('money.metamask_card.link_card_sheet_description_no_apy') + ) : ( + <> + {strings('money.metamask_card.link_card_sheet_description_prefix')} + + {' '} + {strings('money.apy_label', { percentage: apyPercent })} + + {strings('money.metamask_card.link_card_sheet_description_suffix')} + + ); return ( { diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx index 00fe8430e4c4..c7d585d2ec3e 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx @@ -132,7 +132,7 @@ describe('MoneyMetaMaskCard', () => { getByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_APY), ).toBeOnTheScreen(); expect(getByText('Get 1% mUSD back')).toBeOnTheScreen(); - expect(getByText('Earn up to 4% APY')).toBeOnTheScreen(); + expect(getByText('Earn up to 4% APY (variable)')).toBeOnTheScreen(); expect(queryByText('Get 3% mUSD back')).not.toBeOnTheScreen(); }); @@ -153,7 +153,7 @@ describe('MoneyMetaMaskCard', () => { getByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_APY), ).toBeOnTheScreen(); expect(getByText('Get 3% mUSD back')).toBeOnTheScreen(); - expect(getByText('Earn up to 4% APY')).toBeOnTheScreen(); + expect(getByText('Earn up to 4% APY (variable)')).toBeOnTheScreen(); expect(queryByText('Get 1% mUSD back')).not.toBeOnTheScreen(); }); @@ -251,7 +251,7 @@ describe('MoneyMetaMaskCard', () => { getByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_APY), ).toBeOnTheScreen(); expect(getByText('Get 1% mUSD back')).toBeOnTheScreen(); - expect(getByText('Earn up to 4% APY')).toBeOnTheScreen(); + expect(getByText('Earn up to 4% APY (variable)')).toBeOnTheScreen(); expect( getByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON), ).toBeOnTheScreen(); diff --git a/app/components/UI/Money/hooks/useMoneyAccount.ts b/app/components/UI/Money/hooks/useMoneyAccount.ts index cbbb2c6abfdc..bab89f948cb6 100644 --- a/app/components/UI/Money/hooks/useMoneyAccount.ts +++ b/app/components/UI/Money/hooks/useMoneyAccount.ts @@ -119,6 +119,7 @@ export function useMoneyAccountDeposit() { from: primaryMoneyAccount.address as Hex, networkClientId, origin: ORIGIN_METAMASK, + isInternal: true, disableHook: true, disableSequential: true, transactions: [approveTx, depositTx], @@ -193,6 +194,7 @@ export function useMoneyAccountWithdrawal() { from: primaryMoneyAccount.address as Hex, networkClientId, origin: ORIGIN_METAMASK, + isInternal: true, disableHook: true, disableSequential: true, transactions: [withdrawTx, transferTx], diff --git a/app/components/UI/Money/hooks/useMoneyAccountInfo.test.ts b/app/components/UI/Money/hooks/useMoneyAccountInfo.test.ts index 227a86d20728..a3423f5b73ef 100644 --- a/app/components/UI/Money/hooks/useMoneyAccountInfo.test.ts +++ b/app/components/UI/Money/hooks/useMoneyAccountInfo.test.ts @@ -15,7 +15,6 @@ jest.mock('../../../../selectors/moneyAccountController', () => ({ jest.mock('../selectors/featureFlags', () => ({ selectMoneyEnableMoneyAccountFlag: jest.fn(), - selectMoneyHomeScreenEnabledFlag: jest.fn(), selectMoneyActivityMockDataEnabledFlag: jest.fn(), selectMoneyHubEnabledFlag: jest.fn(), })); diff --git a/app/components/UI/Money/selectors/featureFlags.test.ts b/app/components/UI/Money/selectors/featureFlags.test.ts index 5b541fc3c1b0..5ea42e78ad60 100644 --- a/app/components/UI/Money/selectors/featureFlags.test.ts +++ b/app/components/UI/Money/selectors/featureFlags.test.ts @@ -2,7 +2,6 @@ import * as remoteFeatureFlagModule from '../../../../util/remoteFeatureFlag'; import { selectMoneyActivityMockDataEnabledFlag, - selectMoneyHomeScreenEnabledFlag, selectMoneyEnableMoneyAccountFlag, selectMoneyHubEnabledFlag, } from './featureFlags'; @@ -39,65 +38,6 @@ const createState = (remoteFeatureFlags: Record = {}) => ({ }, }); -describe('selectMoneyHomeScreenEnabledFlag', () => { - const originalEnv = process.env; - - beforeEach(() => { - jest.clearAllMocks(); - process.env = { ...originalEnv }; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it('returns true when remote flag is enabled and version requirement is met', () => { - mockedValidate.mockReturnValue(true); - - const state = createState({ - moneyHomeScreenEnabled: { enabled: true, minimumVersion: '1.0.0' }, - }); - - const result = selectMoneyHomeScreenEnabledFlag(state as never); - - expect(result).toBe(true); - }); - - it('returns false when remote flag is disabled', () => { - mockedValidate.mockReturnValue(false); - - const state = createState({ - moneyHomeScreenEnabled: { enabled: false, minimumVersion: '1.0.0' }, - }); - - const result = selectMoneyHomeScreenEnabledFlag(state as never); - - expect(result).toBe(false); - }); - - it('falls back to local env var when remote flag returns undefined', () => { - mockedValidate.mockReturnValue(undefined); - process.env.MM_MONEY_HOME_SCREEN_ENABLED = 'true'; - - const state = createState({ _unique: 'fallback-true' }); - - const result = selectMoneyHomeScreenEnabledFlag(state as never); - - expect(result).toBe(true); - }); - - it('returns false when both remote and local flags are unavailable', () => { - mockedValidate.mockReturnValue(undefined); - delete process.env.MM_MONEY_HOME_SCREEN_ENABLED; - - const state = createState({ _unique: 'fallback-false' }); - - const result = selectMoneyHomeScreenEnabledFlag(state as never); - - expect(result).toBe(false); - }); -}); - describe('selectMoneyActivityMockDataEnabledFlag', () => { const originalEnv = process.env; diff --git a/app/components/UI/Money/selectors/featureFlags.ts b/app/components/UI/Money/selectors/featureFlags.ts index 0fc2df833c2c..0475dbaa1917 100644 --- a/app/components/UI/Money/selectors/featureFlags.ts +++ b/app/components/UI/Money/selectors/featureFlags.ts @@ -6,17 +6,6 @@ import { } from '../../../../util/remoteFeatureFlag'; import { isMoneyAccountEnabled } from '../../../../lib/Money/feature-flags'; -export const selectMoneyHomeScreenEnabledFlag = createSelector( - selectRemoteFeatureFlags, - (remoteFeatureFlags) => { - const localFlag = process.env.MM_MONEY_HOME_SCREEN_ENABLED === 'true'; - const remoteFlag = - remoteFeatureFlags?.moneyHomeScreenEnabled as unknown as VersionGatedFeatureFlag; - - return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; - }, -); - /** Temporary flag: remote value is a boolean only. */ export const selectMoneyActivityMockDataEnabledFlag = createSelector( selectRemoteFeatureFlags, diff --git a/app/components/UI/Money/types.ts b/app/components/UI/Money/types.ts index 564e77c27204..4c51812ee061 100644 --- a/app/components/UI/Money/types.ts +++ b/app/components/UI/Money/types.ts @@ -3,7 +3,7 @@ * Exactly one kind is active at a time. * * Precedence (highest → lowest): - * featureDisabled > noAccount > error > retrying > loading > unavailable > balance + * noAccount > error > retrying > loading > unavailable > balance * * `unavailable` covers the case where balance queries succeeded but a * dependency required to render the fiat balance (e.g. `musdFiatRate`) @@ -12,7 +12,6 @@ * their respective controllers and hydrate on their own tick. */ export type MoneyBalanceDisplayState = - | { kind: 'featureDisabled' } | { kind: 'noAccount' } | { kind: 'error'; onRetry: () => void } | { kind: 'retrying' } diff --git a/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.test.ts b/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.test.ts index 9daf60ed8a1e..eab02205d789 100644 --- a/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.test.ts @@ -138,6 +138,7 @@ describe('usePerpsWithdrawConfirmation', () => { expect(mockAddTransactionBatch).toHaveBeenCalledWith({ from: MOCK_ACCOUNT, origin: ORIGIN_METAMASK, + isInternal: true, networkClientId: MOCK_NETWORK_CLIENT_ID, disableHook: true, disableSequential: true, diff --git a/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.ts b/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.ts index 77d313d60d45..90488dfc78f3 100644 --- a/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.ts +++ b/app/components/UI/Perps/hooks/usePerpsWithdrawConfirmation.ts @@ -89,6 +89,7 @@ export function usePerpsWithdrawConfirmation() { await addTransactionBatch({ from: selectedAccount as Hex, origin: ORIGIN_METAMASK, + isInternal: true, networkClientId, disableHook: true, disableSequential: true, diff --git a/app/components/UI/Predict/constants/btcUpDown5mSeries.ts b/app/components/UI/Predict/constants/btcUpDown5mSeries.ts index 27bbe2273c19..03d160b94960 100644 --- a/app/components/UI/Predict/constants/btcUpDown5mSeries.ts +++ b/app/components/UI/Predict/constants/btcUpDown5mSeries.ts @@ -1,12 +1,5 @@ import type { PredictSeries } from '../types'; -/** - * Temporary kill switch while the shared BTC up/down data hook lives on - * `predict/crypto-updown-feed-card`. Remove this flag when that branch is - * merged and the BTC row is ready to render from the shared hook. - */ -export const SHOW_BTC_UP_DOWN_5M_ROW = false; - /** Polymarket Gamma `series_id` for the BTC 5-minute up/down series. */ export const BTC_UP_DOWN_5M_SERIES_ID = '10684'; diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index 61a839fe2018..4f10afde7574 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -4007,6 +4007,7 @@ describe('PredictController', () => { expect(addTransactionBatch).toHaveBeenCalledWith({ from: '0x1234567890123456789012345678901234567890', origin: 'metamask', + isInternal: true, networkClientId: 'polygon-mainnet', disableHook: true, disableSequential: true, diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index fac9b995baf4..27c96b6fbf7a 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -1592,6 +1592,7 @@ export class PredictController extends BaseController< const batchResult = await addTransactionBatch({ from: signer.address as Hex, origin: ORIGIN_METAMASK, + isInternal: true, networkClientId, disableHook: true, disableSequential: true, @@ -2015,6 +2016,7 @@ export class PredictController extends BaseController< const batchResult = await addTransactionBatch({ from: signer.address as Hex, origin: ORIGIN_METAMASK, + isInternal: true, networkClientId, disableHook: true, disableSequential: true, @@ -2160,6 +2162,7 @@ export class PredictController extends BaseController< const batchResult = await addTransactionBatch({ from: signer.address as Hex, origin: ORIGIN_METAMASK, + isInternal: true, networkClientId, disableHook: true, disableSequential: true, @@ -2780,6 +2783,7 @@ export class PredictController extends BaseController< const { batchId } = await addTransactionBatch({ from: signer.address as Hex, origin: ORIGIN_METAMASK, + isInternal: true, networkClientId: this.messenger.call( 'NetworkController:findNetworkClientIdByChainId', chainId, diff --git a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.test.tsx b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.test.tsx index 0c75ffe63f0b..1e8994a29381 100644 --- a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.test.tsx @@ -416,6 +416,7 @@ describe('SendTransaction View', () => { }, { "deviceConfirmedOn": "metamask_mobile", + "isInternal": true, "networkClientId": "mainnet", "origin": "RAMPS_SEND", }, @@ -462,6 +463,7 @@ describe('SendTransaction View', () => { }, { "deviceConfirmedOn": "metamask_mobile", + "isInternal": true, "networkClientId": "mainnet", "origin": "RAMPS_SEND", }, diff --git a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.tsx b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.tsx index 1260934eb7de..b6d47e1831ba 100644 --- a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.tsx @@ -196,6 +196,7 @@ function SendTransaction() { deviceConfirmedOn: WalletDevice.MM_MOBILE, networkClientId, origin: RAMPS_SEND, + isInternal: true, }); const hash = await response.result; diff --git a/app/components/UI/Ramp/Aggregator/routes/index.test.tsx b/app/components/UI/Ramp/Aggregator/routes/index.test.tsx index 7c53b3606252..5ff70a4715ec 100644 --- a/app/components/UI/Ramp/Aggregator/routes/index.test.tsx +++ b/app/components/UI/Ramp/Aggregator/routes/index.test.tsx @@ -8,8 +8,8 @@ import { RampType } from '../types'; import Routes from '../../../../../constants/navigation/Routes'; import { backgroundState } from '../../../../../util/test/initial-root-state'; -jest.mock('@react-navigation/stack', () => ({ - createStackNavigator: jest.fn().mockReturnValue({ +jest.mock('@react-navigation/native-stack', () => ({ + createNativeStackNavigator: jest.fn().mockReturnValue({ Navigator: ({ children }: { children: React.ReactNode }) => children, Screen: ({ name, diff --git a/app/components/UI/Ramp/Aggregator/routes/index.tsx b/app/components/UI/Ramp/Aggregator/routes/index.tsx index 82e37a170e5f..d190cfcd4149 100644 --- a/app/components/UI/Ramp/Aggregator/routes/index.tsx +++ b/app/components/UI/Ramp/Aggregator/routes/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { createStackNavigator } from '@react-navigation/stack'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; import Quotes from '../Views/Quotes'; import CheckoutWebView from '../Views/Checkout'; import BuildQuote from '../Views/BuildQuote'; @@ -10,15 +10,23 @@ import FiatSelectorModal from '../components/FiatSelectorModal'; import { RampType } from '../types'; import { RampSDKProvider } from '../sdk'; import Routes from '../../../../../constants/navigation/Routes'; -import { colors } from '../../../../../styles/common'; import IncompatibleAccountTokenModal from '../components/IncompatibleAccountTokenModal'; import RegionSelectorModal from '../components/RegionSelectorModal'; import UnsupportedRegionModal from '../components/UnsupportedRegionModal'; import SettingsModal from '../Views/Modals/Settings'; -import { clearStackNavigatorOptions } from '../../../../../constants/navigation/clearStackNavigatorOptions'; +import { + clearNativeStackNavigatorOptions, + transparentModalScreenOptions, +} from '../../../../../constants/navigation/clearStackNavigatorOptions'; -const Stack = createStackNavigator(); -const ModalsStack = createStackNavigator(); +const Stack = createNativeStackNavigator(); +const ModalsStack = createNativeStackNavigator(); + +const overlayScreenOptions = { + ...clearNativeStackNavigatorOptions, + ...transparentModalScreenOptions, + gestureEnabled: false, +}; const MainRoutes = () => ( @@ -26,36 +34,27 @@ const MainRoutes = () => ( ); const RampModalsRoutes = () => ( ( name={Routes.RAMP.MODALS.ID} component={RampModalsRoutes} options={{ - ...clearStackNavigatorOptions, - detachPreviousScreen: false, + ...clearNativeStackNavigatorOptions, + ...transparentModalScreenOptions, }} /> diff --git a/app/components/UI/Stake/hooks/usePoolStakedClaim/index.ts b/app/components/UI/Stake/hooks/usePoolStakedClaim/index.ts index 229fa478b257..cbc10a0cbbac 100644 --- a/app/components/UI/Stake/hooks/usePoolStakedClaim/index.ts +++ b/app/components/UI/Stake/hooks/usePoolStakedClaim/index.ts @@ -49,6 +49,7 @@ const attemptMultiCallClaimTransaction = async ( deviceConfirmedOn: WalletDevice.MM_MOBILE, networkClientId, origin: ORIGIN_METAMASK, + isInternal: true, type: TransactionType.stakingClaim, }); }; @@ -94,6 +95,7 @@ const attemptSingleClaimTransaction = async ( deviceConfirmedOn: WalletDevice.MM_MOBILE, networkClientId, origin: ORIGIN_METAMASK, + isInternal: true, type: TransactionType.stakingClaim, }); }; diff --git a/app/components/UI/Stake/hooks/usePoolStakedDeposit/index.ts b/app/components/UI/Stake/hooks/usePoolStakedDeposit/index.ts index c13c42f35387..31f693b319e2 100644 --- a/app/components/UI/Stake/hooks/usePoolStakedDeposit/index.ts +++ b/app/components/UI/Stake/hooks/usePoolStakedDeposit/index.ts @@ -87,6 +87,7 @@ const attemptDepositTransaction = deviceConfirmedOn: WalletDevice.MM_MOBILE, networkClientId, origin: ORIGIN_METAMASK, + isInternal: true, type: TransactionType.stakingDeposit, }); } catch (e) { diff --git a/app/components/UI/Stake/hooks/usePoolStakedUnstake/index.ts b/app/components/UI/Stake/hooks/usePoolStakedUnstake/index.ts index 877dbf22280e..9a5deb0a7f62 100644 --- a/app/components/UI/Stake/hooks/usePoolStakedUnstake/index.ts +++ b/app/components/UI/Stake/hooks/usePoolStakedUnstake/index.ts @@ -71,6 +71,7 @@ const attemptUnstakeTransaction = deviceConfirmedOn: WalletDevice.MM_MOBILE, networkClientId, origin: ORIGIN_METAMASK, + isInternal: true, type: TransactionType.stakingUnstake, }); } catch (e) { diff --git a/app/components/Views/ActivityView/index.js b/app/components/Views/ActivityView/index.js index 96034fbda3ad..3062b8b0e5de 100644 --- a/app/components/Views/ActivityView/index.js +++ b/app/components/Views/ActivityView/index.js @@ -30,7 +30,7 @@ import { getNetworkImageSource } from '../../../util/networks'; import { useTheme } from '../../../util/theme'; import { TabsList } from '../../../component-library/components-temp/Tabs'; import { createNetworkManagerNavDetails } from '../../UI/NetworkManager'; -import { selectMoneyHomeScreenEnabledFlag } from '../../UI/Money/selectors/featureFlags'; +import { selectMoneyEnableMoneyAccountFlag } from '../../UI/Money/selectors/featureFlags'; import { selectPerpsEnabledFlag } from '../../UI/Perps'; import { selectPredictEnabledFlag } from '../../UI/Predict/selectors/featureFlags'; import PredictTransactionsView from '../../UI/Predict/views/PredictTransactionsView/PredictTransactionsView'; @@ -112,9 +112,7 @@ const ActivityView = () => { const currentNetworkName = getNetworkInfo(0)?.networkName; - const isMoneyHomeScreenEnabled = useSelector( - selectMoneyHomeScreenEnabledFlag, - ); + const isMoneyAccountEnabled = useSelector(selectMoneyEnableMoneyAccountFlag); const params = useParams(); const perpsEnabledFlag = useSelector(selectPerpsEnabledFlag); @@ -139,15 +137,15 @@ const ActivityView = () => { }, [navigation]); const handleBackPress = useCallback(() => { - if (isMoneyHomeScreenEnabled) { + if (isMoneyAccountEnabled) { handleNavigateHome(); } else if (navigation.canGoBack()) { navigation.goBack(); } - }, [isMoneyHomeScreenEnabled, navigation, handleNavigateHome]); + }, [isMoneyAccountEnabled, navigation, handleNavigateHome]); useEffect(() => { - if (!isMoneyHomeScreenEnabled) return; + if (!isMoneyAccountEnabled) return; const subscription = BackHandler.addEventListener( 'hardwareBackPress', @@ -158,9 +156,9 @@ const ActivityView = () => { ); return () => subscription.remove(); - }, [navigation, isMoneyHomeScreenEnabled, handleNavigateHome]); + }, [navigation, isMoneyAccountEnabled, handleNavigateHome]); - const showBackButton = params.showBackButton || isMoneyHomeScreenEnabled; + const showBackButton = params.showBackButton || isMoneyAccountEnabled; // Calculate dynamic tab indices based on which tabs are enabled // Tab order: Transactions (0), Orders (1), Perps (conditional), Predict (conditional) diff --git a/app/components/Views/ActivityView/index.test.tsx b/app/components/Views/ActivityView/index.test.tsx index 375c180eeffe..f88c2a9e7179 100644 --- a/app/components/Views/ActivityView/index.test.tsx +++ b/app/components/Views/ActivityView/index.test.tsx @@ -13,9 +13,9 @@ import { ActivitiesViewSelectorsIDs } from './ActivitiesView.testIds'; import { WalletViewSelectorsIDs } from '../Wallet/WalletView.testIds'; import Routes from '../../../constants/navigation/Routes'; -let mockMoneyHomeScreenEnabled = false; +let mockMoneyAccountEnabled = false; jest.mock('../../UI/Money/selectors/featureFlags', () => ({ - selectMoneyHomeScreenEnabledFlag: jest.fn(() => mockMoneyHomeScreenEnabled), + selectMoneyEnableMoneyAccountFlag: jest.fn(() => mockMoneyAccountEnabled), })); // Mock the Perps feature flag selector - will be controlled per test @@ -286,7 +286,7 @@ describe('ActivityView', () => { >); mockUseCurrentNetworkInfo.mockReturnValue(defaultNetworkInfo); mockIsEvmSelected = true; - mockMoneyHomeScreenEnabled = false; + mockMoneyAccountEnabled = false; mockPerpsEnabled = false; mockPredictEnabled = false; mockAreAllEvmPopularNetworksEnabled = false; @@ -443,8 +443,8 @@ describe('ActivityView', () => { expect(mockNavigation.goBack).not.toHaveBeenCalled(); }); - it('displays back button when Money home screen flag is enabled without showBackButton param', () => { - mockMoneyHomeScreenEnabled = true; + it('displays back button when Money account flag is enabled without showBackButton param', () => { + mockMoneyAccountEnabled = true; mockRoute.params = {}; const { getByTestId } = renderComponent(mockInitialState); @@ -452,8 +452,8 @@ describe('ActivityView', () => { expect(getByTestId('activity-view-back-button')).toBeOnTheScreen(); }); - it('calls navigation.navigate with HOME_TABS on back button press when Money flag is enabled', () => { - mockMoneyHomeScreenEnabled = true; + it('calls navigation.navigate with HOME_TABS on back button press when Money account flag is enabled', () => { + mockMoneyAccountEnabled = true; mockRoute.params = {}; const { getByTestId } = renderComponent(mockInitialState); @@ -464,7 +464,7 @@ describe('ActivityView', () => { }); it('calls navigation.navigate with HOME_TABS and not goBack when both flag and showBackButton param are true', () => { - mockMoneyHomeScreenEnabled = true; + mockMoneyAccountEnabled = true; mockRoute.params = { showBackButton: true }; const { getByTestId } = renderComponent(mockInitialState); @@ -475,7 +475,7 @@ describe('ActivityView', () => { }); it('registers hardwareBackPress handler when Money flag is enabled', () => { - mockMoneyHomeScreenEnabled = true; + mockMoneyAccountEnabled = true; mockRoute.params = {}; renderComponent(mockInitialState); @@ -487,7 +487,7 @@ describe('ActivityView', () => { }); it('navigates to HOME_TABS when hardwareBackPress fires with Money flag enabled', () => { - mockMoneyHomeScreenEnabled = true; + mockMoneyAccountEnabled = true; mockRoute.params = {}; renderComponent(mockInitialState); const [[, handler]] = (BackHandler.addEventListener as jest.Mock).mock @@ -500,7 +500,7 @@ describe('ActivityView', () => { }); it('does not navigate to HOME_TABS on hardwareBackPress when Money flag is disabled', () => { - mockMoneyHomeScreenEnabled = false; + mockMoneyAccountEnabled = false; mockRoute.params = {}; renderComponent(mockInitialState); @@ -577,8 +577,8 @@ describe('ActivityView', () => { ).toBeNull(); }); - it('renders HeaderCompactStandard when Money home screen flag is enabled', () => { - mockMoneyHomeScreenEnabled = true; + it('renders HeaderCompactStandard when Money account flag is enabled', () => { + mockMoneyAccountEnabled = true; mockRoute.params = {}; const { getByTestId, queryByTestId } = renderComponent(mockInitialState); diff --git a/app/components/Views/AddAsset/AddAsset.tsx b/app/components/Views/AddAsset/AddAsset.tsx index 39e6d3f13061..30eb6b5a566d 100644 --- a/app/components/Views/AddAsset/AddAsset.tsx +++ b/app/components/Views/AddAsset/AddAsset.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { SafeAreaView, useSafeAreaInsets, @@ -48,22 +48,6 @@ const AddAsset = () => { const sheetRef = useRef(null); - const renderNetworkSelector = useCallback( - () => ( - { - setSelectedNetwork(network); - }} - setOpenNetworkSelector={setOpenNetworkSelector} - sheetRef={sheetRef} - displayEvmNetworksOnly={assetType === 'collectible'} - /> - ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [openNetworkSelector, networkConfigurations, selectedNetwork, assetType], - ); - return ( { /> )} - {openNetworkSelector ? renderNetworkSelector() : null} + {openNetworkSelector ? ( + + ) : null} ); }; diff --git a/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.test.tsx b/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.test.tsx index 01fe8f3a070b..a08d59bbdb2f 100644 --- a/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.test.tsx +++ b/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.test.tsx @@ -4,7 +4,12 @@ import { backgroundState } from '../../../../../util/test/initial-root-state'; import renderWithProvider, { DeepPartial, } from '../../../../../util/test/renderWithProvider'; -import { userEvent } from '@testing-library/react-native'; +import { + act, + fireEvent, + userEvent, + waitFor, +} from '@testing-library/react-native'; import { RootState } from '../../../../../reducers'; import { mockNetworkState } from '../../../../../util/test/network'; import { CHAIN_IDS } from '@metamask/transaction-controller'; @@ -14,6 +19,7 @@ import { TESTID_BOTTOMSHEETFOOTER_BUTTON_SUBSEQUENT, } from '../../../../../component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter.constants'; import Routes from '../../../../../constants/navigation/Routes'; +import Logger from '../../../../../util/Logger'; const mockSetOptions = jest.fn(); const mockNavigate = jest.fn(); @@ -38,6 +44,15 @@ jest.mock('../../../../../util/navigation/navUtils', () => ({ createNavigationDetails: jest.fn(), })); +jest.mock('../../../../../util/Logger', () => ({ + __esModule: true, + default: { + error: jest.fn(), + }, +})); + +const mockLoggerError = Logger.error as jest.Mock; + const DEFAULT_ASSET = { address: '0xdac17f958d2ee523a2206206994597c13d831ec7', symbol: 'USDT', @@ -146,6 +161,78 @@ describe('ConfirmAddAsset', () => { }); }); + it('shows loading feedback and prevents duplicate import presses', async () => { + let resolveImport: () => void = jest.fn(); + const pendingImport = new Promise((resolve) => { + resolveImport = resolve; + }); + const addTokenList = jest.fn(() => pendingImport); + setupParams({ addTokenList }); + + const { getByTestId } = renderWithProvider(, { + state: mockInitialState, + }); + + fireEvent.press(getByTestId(TESTID_BOTTOMSHEETFOOTER_BUTTON_SUBSEQUENT)); + + expect(addTokenList).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect( + getByTestId(TESTID_BOTTOMSHEETFOOTER_BUTTON_SUBSEQUENT), + ).toBeDisabled(); + expect(getByTestId(TESTID_BOTTOMSHEETFOOTER_BUTTON)).toBeDisabled(); + }); + + fireEvent.press(getByTestId(TESTID_BOTTOMSHEETFOOTER_BUTTON_SUBSEQUENT)); + expect(addTokenList).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveImport(); + await pendingImport; + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + }); + }); + + it('resets loading state and logs when import fails', async () => { + const addTokenList = jest + .fn() + .mockRejectedValue(new Error('Import failed')); + setupParams({ addTokenList }); + + const { getByTestId } = renderWithProvider(, { + state: mockInitialState, + }); + + fireEvent.press(getByTestId(TESTID_BOTTOMSHEETFOOTER_BUTTON_SUBSEQUENT)); + + await waitFor(() => { + expect(mockLoggerError).toHaveBeenCalledWith( + expect.any(Error), + 'ConfirmAddAsset: failed to import tokens', + ); + }); + + expect( + getByTestId(TESTID_BOTTOMSHEETFOOTER_BUTTON_SUBSEQUENT), + ).toBeEnabled(); + expect(getByTestId(TESTID_BOTTOMSHEETFOOTER_BUTTON)).toBeEnabled(); + expect(mockNavigate).not.toHaveBeenCalledWith(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + }); + it('renders without crashing when asset has no image', () => { const assetWithoutImage = { address: '0xdac17f958d2ee523a2206206994597c13d831ec7', diff --git a/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.tsx b/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.tsx index 6d83d0bbc3c1..f777474687c7 100644 --- a/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.tsx +++ b/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Platform } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -6,12 +6,6 @@ import { useParams } from '../../../../../util/navigation/navUtils'; import { strings } from '../../../../../../locales/i18n'; import { useNavigation } from '@react-navigation/native'; import getHeaderCompactStandardNavbarOptions from '../../../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions'; -import Badge, { - BadgeVariant, -} from '../../../../../component-library/components/Badges/Badge'; -import BadgeWrapper, { - BadgePosition, -} from '../../../../../component-library/components/Badges/BadgeWrapper'; import { ButtonSize, ButtonVariants, @@ -19,44 +13,37 @@ import { import BottomSheetFooter, { ButtonsAlignment, } from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; +import ListItem from '../../../../../component-library/components/List/ListItem'; import Routes from '../../../../../constants/navigation/Routes'; import { ImportTokenViewSelectorsIDs } from '../../ImportAssetView.testIds'; -import { Hex } from '@metamask/utils'; -import { NetworkBadgeSource } from '../../../../UI/AssetOverview/Balance/Balance'; -import AvatarToken from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; -import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; import { FlashList } from '@shopify/flash-list'; -import { - Box, - BoxFlexDirection, - BoxAlignItems, - Text, - TextVariant, - TextColor, -} from '@metamask/design-system-react-native'; +import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; import { ImportAsset } from '../../utils/utils'; +import AddAssetTokenRow from '../../components/AddAssetTokenRow/AddAssetTokenRow'; +import Logger from '../../../../../util/Logger'; const ConfirmAddAsset = () => { const { selectedAsset, networkName, addTokenList } = useParams<{ selectedAsset: ImportAsset[]; networkName: string; - addTokenList: () => void; + addTokenList: () => Promise; }>(); const tw = useTailwind(); const navigation = useNavigation(); + const [isImporting, setIsImporting] = useState(false); /** * Go to wallet page */ - const goToWalletPage = () => { + const goToWalletPage = useCallback(() => { navigation.navigate(Routes.WALLET.HOME, { screen: Routes.WALLET.TAB_STACK_FLOW, params: { screen: Routes.WALLET_VIEW, }, }); - }; + }, [navigation]); const updateNavBar = useCallback(() => { navigation.setOptions( @@ -72,62 +59,47 @@ const ConfirmAddAsset = () => { updateNavBar(); }, [updateNavBar]); + const handleImport = useCallback(async () => { + if (isImporting) { + return; + } + + setIsImporting(true); + + try { + await addTokenList(); + goToWalletPage(); + } catch (error) { + Logger.error(error as Error, 'ConfirmAddAsset: failed to import tokens'); + setIsImporting(false); + } + }, [addTokenList, goToWalletPage, isImporting]); + return ( - - {selectedAsset.length > 1 - ? strings('wallet.import_tokens') - : strings('wallet.import_token')} - - - ( - - - - } - > - {asset.image && ( - - )} - - + + + {selectedAsset.length > 1 + ? strings('wallet.import_tokens') + : strings('wallet.import_token')} + - - {asset.name} - - {asset.symbol} - - - - )} - keyExtractor={(_, index) => `token-search-row-${index}`} - /> + ( + + + + )} + keyExtractor={(_, index) => `token-search-row-${index}`} + /> + { label: strings('confirmation_modal.cancel_cta'), variant: ButtonVariants.Secondary, size: ButtonSize.Lg, + isDisabled: isImporting, }, { - onPress: async () => { - await addTokenList(); - goToWalletPage(); - }, + onPress: handleImport, label: strings('swaps.Import'), variant: ButtonVariants.Primary, size: ButtonSize.Lg, + loading: isImporting, + isDisabled: isImporting, }, ]} buttonsAlignment={ButtonsAlignment.Horizontal} - style={tw.style('px-4 pt-6', Platform.OS !== 'android' && 'pb-4')} + style={tw.style('px-4 pt-4', Platform.OS !== 'android' && 'pb-4')} /> ); diff --git a/app/components/Views/AddAsset/components/AddAssetTokenRow/AddAssetTokenRow.tsx b/app/components/Views/AddAsset/components/AddAssetTokenRow/AddAssetTokenRow.tsx new file mode 100644 index 000000000000..fc60ef569db5 --- /dev/null +++ b/app/components/Views/AddAsset/components/AddAssetTokenRow/AddAssetTokenRow.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import BadgeNetwork from '../../../../../component-library/components/Badges/Badge/variants/BadgeNetwork'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../../component-library/components/Badges/BadgeWrapper'; +import AvatarToken from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; +import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { NetworkBadgeSource } from '../../../../UI/AssetOverview/Balance/Balance'; +import { ImportAsset } from '../../utils/utils'; + +interface AddAssetTokenRowProps { + asset: ImportAsset; + networkName?: string; +} + +const AddAssetTokenRow = ({ asset, networkName }: AddAssetTokenRowProps) => ( + + + + } + > + {asset.image && ( + + )} + + + + + {asset.name} + + + {asset.symbol} + + + +); + +export default AddAssetTokenRow; diff --git a/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.tsx b/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.tsx index 2fa9da46cde6..9ad32bd9abee 100644 --- a/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.tsx +++ b/app/components/Views/AddAsset/components/SearchTokenAutoComplete/SearchTokenAutocomplete.tsx @@ -1,11 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react'; -import { - InteractionManager, - TextInput, - TouchableOpacity, - LayoutAnimation, - Platform, -} from 'react-native'; +import { InteractionManager, LayoutAnimation, Platform } from 'react-native'; import { strings } from '../../../../../../locales/i18n'; import Engine from '../../../../../core/Engine'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; @@ -27,13 +21,8 @@ import { ButtonVariant, ButtonSize, Box, - BoxFlexDirection, - BoxAlignItems, Text, - Icon, - IconName, - IconSize, - IconColor, + TextFieldSearch, } from '@metamask/design-system-react-native'; import { ImportTokenViewSelectorsIDs } from '../../ImportAssetView.testIds'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -484,42 +473,24 @@ const SearchTokenAutocomplete = ({ navigation, selectedChainId }: Props) => { - - - + setSearchQuery('')} + clearButtonProps={{ + testID: ImportTokenViewSelectorsIDs.CLEAR_SEARCH_BAR, + }} + inputProps={{ + autoCapitalize: 'none', + keyboardAppearance: themeAppearance, + testID: ImportTokenViewSelectorsIDs.SEARCH_BAR, + }} onFocus={() => setFocusState(true)} onBlur={() => setFocusState(false)} + autoFocus={false} placeholder={strings('token.search_tokens_placeholder')} - placeholderTextColor={colors.text.muted} - onChangeText={setSearchQuery} - testID={ImportTokenViewSelectorsIDs.SEARCH_BAR} - keyboardAppearance={themeAppearance} /> - {searchQuery.length > 0 && ( - setSearchQuery('')} - testID={ImportTokenViewSelectorsIDs.CLEAR_SEARCH_BAR} - > - - - )} @@ -528,7 +499,6 @@ const SearchTokenAutocomplete = ({ navigation, selectedChainId }: Props) => { searchQuery={searchQuery} handleSelectAsset={handleSelectAsset} selectedAsset={selectedAssets} - chainId={selectedChainId ?? ''} networkName={networkName} alreadyAddedTokens={alreadyAddedTokens} isLoading={isLoading} diff --git a/app/components/Views/AddAsset/components/SearchTokenResults/SearchTokenResults.test.tsx b/app/components/Views/AddAsset/components/SearchTokenResults/SearchTokenResults.test.tsx index b6baee8cc5f8..16207b944bf4 100644 --- a/app/components/Views/AddAsset/components/SearchTokenResults/SearchTokenResults.test.tsx +++ b/app/components/Views/AddAsset/components/SearchTokenResults/SearchTokenResults.test.tsx @@ -35,7 +35,6 @@ const defaultProps = { handleSelectAsset: mockHandleSelectAsset, selectedAsset: [] as ImportAsset[], searchQuery: '', - chainId: '0x1', networkName: 'Ethereum', alreadyAddedTokens: undefined as Set | undefined, isLoading: false, diff --git a/app/components/Views/AddAsset/components/SearchTokenResults/SearchTokenResults.tsx b/app/components/Views/AddAsset/components/SearchTokenResults/SearchTokenResults.tsx index 15f23362bd79..67a31199d620 100644 --- a/app/components/Views/AddAsset/components/SearchTokenResults/SearchTokenResults.tsx +++ b/app/components/Views/AddAsset/components/SearchTokenResults/SearchTokenResults.tsx @@ -1,15 +1,8 @@ import React from 'react'; import ListItemMultiSelect from '../../../../../component-library/components/List/ListItemMultiSelect'; import { Image } from 'react-native'; -import Badge, { - BadgeVariant, -} from '../../../../../component-library/components/Badges/Badge'; -import BadgeWrapper, { - BadgePosition, -} from '../../../../../component-library/components/Badges/BadgeWrapper'; import { strings } from '../../../../../../locales/i18n'; import { ImportTokenViewSelectorsIDs } from '../../ImportAssetView.testIds'; -import { NetworkBadgeSource } from '../../../../UI/AssetOverview/Balance/Balance'; import { FlashList } from '@shopify/flash-list'; import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { useAssetFromTheme } from '../../../../../util/theme'; @@ -18,13 +11,14 @@ import emptyStateDefiDark from '../../../../../images/empty-state-defi-dark.png' import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, + BoxAlignItems, + BoxFlexDirection, Text, TextVariant, TextColor, } from '@metamask/design-system-react-native'; -import AvatarToken from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; -import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; import { ImportAsset } from '../../utils/utils'; +import AddAssetTokenRow from '../AddAssetTokenRow/AddAssetTokenRow'; interface Props { /** @@ -43,10 +37,6 @@ interface Props { * Search query that generated "searchResults" */ searchQuery: string; - /** - * ChainID of the network - */ - chainId: string; /** * Symbol of the network */ @@ -69,13 +59,28 @@ const TokenSkeleton = () => { const tw = useTailwind(); return ( - - - - - - - + + + + + + + + + + ); @@ -132,7 +137,7 @@ const SearchTokenResults = ({ { - const { symbol, name, address, image } = item || {}; + const { address } = item || {}; const isOnSelected = selectedAsset.some( (token) => token.address === address, ); @@ -146,37 +151,13 @@ const SearchTokenResults = ({ !isDisabled && handleSelectAsset(item)} testID={ImportTokenViewSelectorsIDs.SEARCH_TOKEN_RESULT} > - - - } - > - {image && ( - - )} - - - - {name} - {symbol} - + ); }} diff --git a/app/components/Views/Homepage/Sections/Cash/CashSection.test.tsx b/app/components/Views/Homepage/Sections/Cash/CashSection.test.tsx index 4ce1725523e0..1ba97f91d802 100644 --- a/app/components/Views/Homepage/Sections/Cash/CashSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Cash/CashSection.test.tsx @@ -19,7 +19,7 @@ jest.mock('../../../../UI/Earn/selectors/featureFlags', () => ({ })); jest.mock('../../../../UI/Money/selectors/featureFlags', () => ({ - selectMoneyHomeScreenEnabledFlag: jest.fn(() => false), + selectMoneyEnableMoneyAccountFlag: jest.fn(() => false), })); jest.mock('../../../../../reducers/user/selectors', () => ({ @@ -88,7 +88,7 @@ describe('CashSection', () => { .selectIsMusdConversionFlowEnabledFlag.mockReturnValue(true); jest .requireMock('../../../../UI/Money/selectors/featureFlags') - .selectMoneyHomeScreenEnabledFlag.mockReturnValue(false); + .selectMoneyEnableMoneyAccountFlag.mockReturnValue(false); mockUseMusdConversionEligibility.mockReturnValue({ isEligible: true }); mockUseMusdBalance.mockReturnValue({ hasMusdBalanceOnAnyChain: false, @@ -128,10 +128,10 @@ describe('CashSection', () => { expect(screen.getByText('Money')).toBeOnTheScreen(); }); - it('navigates to CASH_TOKENS_FULL_VIEW when Money home screen flag is disabled', () => { + it('navigates to CASH_TOKENS_FULL_VIEW when Money account flag is disabled', () => { jest .requireMock('../../../../UI/Money/selectors/featureFlags') - .selectMoneyHomeScreenEnabledFlag.mockReturnValue(false); + .selectMoneyEnableMoneyAccountFlag.mockReturnValue(false); renderWithProvider( , @@ -145,10 +145,10 @@ describe('CashSection', () => { ); }); - it('returns null when Money home screen flag is enabled', () => { + it('returns null when Money account flag is enabled', () => { jest .requireMock('../../../../UI/Money/selectors/featureFlags') - .selectMoneyHomeScreenEnabledFlag.mockReturnValue(true); + .selectMoneyEnableMoneyAccountFlag.mockReturnValue(true); const { queryByText } = renderWithProvider( , diff --git a/app/components/Views/Homepage/Sections/Cash/CashSection.tsx b/app/components/Views/Homepage/Sections/Cash/CashSection.tsx index cb4731065887..2c1648f2dc4a 100644 --- a/app/components/Views/Homepage/Sections/Cash/CashSection.tsx +++ b/app/components/Views/Homepage/Sections/Cash/CashSection.tsx @@ -18,7 +18,7 @@ import { useSectionPerformance } from '../../hooks/useSectionPerformance'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog import { WalletViewSelectorsIDs } from '../../../Wallet/WalletView.testIds'; import { selectIsMusdConversionFlowEnabledFlag } from '../../../../UI/Earn/selectors/featureFlags'; -import { selectMoneyHomeScreenEnabledFlag } from '../../../../UI/Money/selectors/featureFlags'; +import { selectMoneyEnableMoneyAccountFlag } from '../../../../UI/Money/selectors/featureFlags'; import { useMusdConversionEligibility } from '../../../../UI/Earn/hooks/useMusdConversionEligibility'; import { useMusdBalance } from '../../../../UI/Earn/hooks/useMusdBalance'; import MusdAggregatedRow from './MusdAggregatedRow'; @@ -45,13 +45,15 @@ const CashSection = forwardRef( const isMusdConversionEnabled = useSelector( selectIsMusdConversionFlowEnabledFlag, ); - const isMoneyHomeEnabled = useSelector(selectMoneyHomeScreenEnabledFlag); + const isMoneyAccountEnabled = useSelector( + selectMoneyEnableMoneyAccountFlag, + ); const { isEligible: isGeoEligible } = useMusdConversionEligibility(); const { hasMusdBalanceOnAnyChain } = useMusdBalance(); const { navigateToCash } = useCashNavigation(); const isCashSectionEnabled = - isMusdConversionEnabled && isGeoEligible && !isMoneyHomeEnabled; + isMusdConversionEnabled && isGeoEligible && !isMoneyAccountEnabled; const { onLayout } = useHomeViewedEvent({ sectionRef: sectionViewRef, @@ -83,7 +85,7 @@ const CashSection = forwardRef( reason = !isGeoEligible ? 'geo_ineligible' : 'money_home_on'; } Logger.log( - `[CashSection] not rendered flag=${isMusdConversionEnabled} geo=${isGeoEligible} moneyHome=${isMoneyHomeEnabled} reason=${reason}`, + `[CashSection] not rendered flag=${isMusdConversionEnabled} geo=${isGeoEligible} moneyHome=${isMoneyAccountEnabled} reason=${reason}`, ); return null; } diff --git a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.test.tsx b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.test.tsx index 3803f13320ac..f15d8215f667 100644 --- a/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.test.tsx +++ b/app/components/Views/Homepage/Sections/Cash/MusdAggregatedRow.test.tsx @@ -25,10 +25,10 @@ jest.mock('../../../../../selectors/preferencesController', () => ({ selectPrivacyMode: () => false, })); -const mockSelectMoneyHomeScreenEnabledFlag = jest.fn().mockReturnValue(false); +const mockSelectMoneyEnableMoneyAccountFlag = jest.fn().mockReturnValue(false); jest.mock('../../../../UI/Money/selectors/featureFlags', () => ({ - selectMoneyHomeScreenEnabledFlag: (state: unknown) => - mockSelectMoneyHomeScreenEnabledFlag(state), + selectMoneyEnableMoneyAccountFlag: (state: unknown) => + mockSelectMoneyEnableMoneyAccountFlag(state), })); const mockClaimRewards = jest.fn(); @@ -59,7 +59,7 @@ jest.mock('../../../../../reducers/user/selectors', () => ({ describe('MusdAggregatedRow', () => { beforeEach(() => { jest.clearAllMocks(); - mockSelectMoneyHomeScreenEnabledFlag.mockReturnValue(false); + mockSelectMoneyEnableMoneyAccountFlag.mockReturnValue(false); mockSelectMusdConversionEducationSeen.mockReturnValue(true); mockUseMusdBalance.mockReturnValue({ tokenBalanceAggregated: '1800.5', @@ -127,7 +127,7 @@ describe('MusdAggregatedRow', () => { describe('handleTokenRowPress', () => { it('navigates to Cash tokens full view when Money Home is disabled', () => { - mockSelectMoneyHomeScreenEnabledFlag.mockReturnValue(false); + mockSelectMoneyEnableMoneyAccountFlag.mockReturnValue(false); renderWithProvider(); @@ -140,7 +140,7 @@ describe('MusdAggregatedRow', () => { }); it('navigates to Money Home when Money Home is enabled', () => { - mockSelectMoneyHomeScreenEnabledFlag.mockReturnValue(true); + mockSelectMoneyEnableMoneyAccountFlag.mockReturnValue(true); renderWithProvider(); @@ -152,7 +152,7 @@ describe('MusdAggregatedRow', () => { }); it('navigates to education screen with returnTo when user has not seen education', () => { - mockSelectMoneyHomeScreenEnabledFlag.mockReturnValue(false); + mockSelectMoneyEnableMoneyAccountFlag.mockReturnValue(false); mockSelectMusdConversionEducationSeen.mockReturnValue(false); renderWithProvider(); @@ -168,7 +168,7 @@ describe('MusdAggregatedRow', () => { }); it('navigates directly to CashTokensFullView when education already seen', () => { - mockSelectMoneyHomeScreenEnabledFlag.mockReturnValue(false); + mockSelectMoneyEnableMoneyAccountFlag.mockReturnValue(false); mockSelectMusdConversionEducationSeen.mockReturnValue(true); renderWithProvider(); @@ -181,8 +181,8 @@ describe('MusdAggregatedRow', () => { ); }); - it('navigates to MONEY.ROOT when isMoneyHomeEnabled is true (regardless of education)', () => { - mockSelectMoneyHomeScreenEnabledFlag.mockReturnValue(true); + it('navigates to MONEY.ROOT when Money account flag is enabled (regardless of education)', () => { + mockSelectMoneyEnableMoneyAccountFlag.mockReturnValue(true); mockSelectMusdConversionEducationSeen.mockReturnValue(false); renderWithProvider(); diff --git a/app/components/Views/Homepage/Sections/Cash/useCashNavigation.test.ts b/app/components/Views/Homepage/Sections/Cash/useCashNavigation.test.ts index 22561675f627..55a30a49e96f 100644 --- a/app/components/Views/Homepage/Sections/Cash/useCashNavigation.test.ts +++ b/app/components/Views/Homepage/Sections/Cash/useCashNavigation.test.ts @@ -13,7 +13,7 @@ jest.mock('react-redux', () => ({ })); jest.mock('../../../../UI/Money/selectors/featureFlags', () => ({ - selectMoneyHomeScreenEnabledFlag: jest.fn(), + selectMoneyEnableMoneyAccountFlag: jest.fn(), })); jest.mock('../../../../../reducers/user/selectors', () => ({ @@ -21,21 +21,21 @@ jest.mock('../../../../../reducers/user/selectors', () => ({ })); import { useSelector } from 'react-redux'; -import { selectMoneyHomeScreenEnabledFlag } from '../../../../UI/Money/selectors/featureFlags'; +import { selectMoneyEnableMoneyAccountFlag } from '../../../../UI/Money/selectors/featureFlags'; import { selectMusdConversionEducationSeen } from '../../../../../reducers/user/selectors'; const mockUseSelector = useSelector as jest.Mock; const setupSelectors = ({ - isMoneyHomeEnabled = false, + isMoneyAccountEnabled = false, hasSeenEducation = false, }: { - isMoneyHomeEnabled?: boolean; + isMoneyAccountEnabled?: boolean; hasSeenEducation?: boolean; } = {}) => { mockUseSelector.mockImplementation((selector) => { - if (selector === selectMoneyHomeScreenEnabledFlag) - return isMoneyHomeEnabled; + if (selector === selectMoneyEnableMoneyAccountFlag) + return isMoneyAccountEnabled; if (selector === selectMusdConversionEducationSeen) return hasSeenEducation; return undefined; }); @@ -49,7 +49,7 @@ describe('useCashNavigation', () => { describe('navigateToCash', () => { it('navigates to education screen with Cash full view returnTo when education not seen', () => { setupSelectors({ - isMoneyHomeEnabled: false, + isMoneyAccountEnabled: false, hasSeenEducation: false, }); @@ -67,7 +67,7 @@ describe('useCashNavigation', () => { it('navigates to Cash full view when education already seen', () => { setupSelectors({ - isMoneyHomeEnabled: false, + isMoneyAccountEnabled: false, hasSeenEducation: true, }); @@ -81,9 +81,9 @@ describe('useCashNavigation', () => { ); }); - it('navigates to Money Home when isMoneyHomeEnabled and education already seen', () => { + it('navigates to Money Home when isMoneyAccountEnabled and education already seen', () => { setupSelectors({ - isMoneyHomeEnabled: true, + isMoneyAccountEnabled: true, hasSeenEducation: true, }); @@ -96,9 +96,9 @@ describe('useCashNavigation', () => { }); }); - it('navigates to education screen with Money Home returnTo when isMoneyHomeEnabled and education not seen', () => { + it('navigates to education screen with Money Home returnTo when isMoneyAccountEnabled and education not seen', () => { setupSelectors({ - isMoneyHomeEnabled: true, + isMoneyAccountEnabled: true, hasSeenEducation: false, }); @@ -119,12 +119,12 @@ describe('useCashNavigation', () => { }); describe('returned state', () => { - it('exposes isMoneyHomeEnabled derived from the feature flag selector', () => { - setupSelectors({ isMoneyHomeEnabled: true }); + it('exposes isMoneyAccountEnabled derived from the feature flag selector', () => { + setupSelectors({ isMoneyAccountEnabled: true }); const { result } = renderHook(() => useCashNavigation()); - expect(result.current.isMoneyHomeEnabled).toBe(true); + expect(result.current.isMoneyAccountEnabled).toBe(true); }); it('exposes hasSeenEducation derived from the user reducer selector', () => { diff --git a/app/components/Views/Homepage/Sections/Cash/useCashNavigation.ts b/app/components/Views/Homepage/Sections/Cash/useCashNavigation.ts index 7f1f3cc990a2..1cedf22276eb 100644 --- a/app/components/Views/Homepage/Sections/Cash/useCashNavigation.ts +++ b/app/components/Views/Homepage/Sections/Cash/useCashNavigation.ts @@ -3,7 +3,7 @@ import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import Routes from '../../../../../constants/navigation/Routes'; import { selectMusdConversionEducationSeen } from '../../../../../reducers/user/selectors'; -import { selectMoneyHomeScreenEnabledFlag } from '../../../../UI/Money/selectors/featureFlags'; +import { selectMoneyEnableMoneyAccountFlag } from '../../../../UI/Money/selectors/featureFlags'; import { MusdNavigationTarget } from '../../../../UI/Earn/types/musd.types'; /** @@ -11,11 +11,11 @@ import { MusdNavigationTarget } from '../../../../UI/Earn/types/musd.types'; */ export const useCashNavigation = () => { const navigation = useNavigation(); - const isMoneyHomeEnabled = useSelector(selectMoneyHomeScreenEnabledFlag); + const isMoneyAccountEnabled = useSelector(selectMoneyEnableMoneyAccountFlag); const hasSeenEducation = useSelector(selectMusdConversionEducationSeen); const navigateToCash = useCallback(() => { - const destination: MusdNavigationTarget = isMoneyHomeEnabled + const destination: MusdNavigationTarget = isMoneyAccountEnabled ? { screen: Routes.MONEY.ROOT, params: { screen: Routes.MONEY.HOME } } : { screen: Routes.WALLET.CASH_TOKENS_FULL_VIEW }; @@ -28,7 +28,7 @@ export const useCashNavigation = () => { } navigation.navigate(destination.screen, destination.params); - }, [isMoneyHomeEnabled, hasSeenEducation, navigation]); + }, [isMoneyAccountEnabled, hasSeenEducation, navigation]); - return { navigateToCash, isMoneyHomeEnabled, hasSeenEducation }; + return { navigateToCash, isMoneyAccountEnabled, hasSeenEducation }; }; diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx index e12ba28a8544..cea4918dcd70 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx @@ -153,6 +153,21 @@ jest.mock('../../../../UI/Predict/hooks/useLiveCryptoPrices', () => ({ })), })); +jest.mock( + '../../../../UI/Predict/hooks/useCurrentCryptoUpDownMarketData', + () => ({ + useCurrentCryptoUpDownMarketData: jest.fn(() => ({ + marketId: undefined, + market: undefined, + currentPrice: undefined, + priceToBeat: undefined, + countdown: '--:--', + isLoading: false, + isFetching: false, + })), + }), +); + jest.mock('../../../../UI/Predict/hooks/usePredictClaim', () => ({ usePredictClaim: () => ({ claim: mockClaim }), })); diff --git a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx index 78a162ae1a6e..ef93171d2300 100644 --- a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx @@ -9,13 +9,16 @@ import SectionHeader from '../../../../../../../component-library/components-tem import { WalletViewSelectorsIDs } from '../../../../../Wallet/WalletView.testIds'; import { PredictEntryPointProvider } from '../../../../../../UI/Predict/contexts'; import { PredictEventValues } from '../../../../../../UI/Predict/constants/eventNames'; -import { SHOW_BTC_UP_DOWN_5M_ROW } from '../../../../../../UI/Predict/constants/btcUpDown5mSeries'; +import { BTC_UP_OR_DOWN_5M_SERIES } from '../../../../../../UI/Predict/constants/btcUpDown5mSeries'; import { PREDICT_EMPTY_STATE_CTA_NAMES, type PredictEmptyStateCtaName, } from '../../../../abTestConfig'; import { PREDICT_WORLD_CUP_TAB_KEYS } from '../../../../../../UI/Predict/constants/worldCupTabs'; +import { useCurrentCryptoUpDownMarketData } from '../../../../../../UI/Predict/hooks/useCurrentCryptoUpDownMarketData'; +import { usePredictNavigation } from '../../../../../../UI/Predict/hooks/usePredictNavigation'; import { + selectPredictEnabledFlag, selectPredictHomepageDiscoveryNbaChampionEnabledFlag, selectPredictWorldCupScreenEnabledFlag, } from '../../../../../../UI/Predict/selectors/featureFlags'; @@ -61,12 +64,24 @@ const HomepagePredictWorldCupDiscovery: React.FC< onTreatmentCtaClick, }) => { const navigation = useNavigation(); + const { navigateToMarketDetails } = usePredictNavigation(); const worldCupScreenEnabled = useSelector( selectPredictWorldCupScreenEnabledFlag, ); + const isPredictEnabled = useSelector(selectPredictEnabledFlag); const showNbaChampionDiscoveryRow = useSelector( selectPredictHomepageDiscoveryNbaChampionEnabledFlag, ); + const { + marketId: btcMarketId, + market: btcWindowMarket, + currentPrice: btcSpotUsd, + priceToBeat, + countdown: btcCountdown, + } = useCurrentCryptoUpDownMarketData({ + series: BTC_UP_OR_DOWN_5M_SERIES, + enabled: isPredictEnabled, + }); const championshipRowKind = showNbaChampionDiscoveryRow ? 'nba' : 'world_cup_winner'; @@ -75,35 +90,6 @@ const HomepagePredictWorldCupDiscovery: React.FC< ? WORLD_CUP_CTA_CATEGORY_NAME : 'nba'; - /* - * TODO: When `predict/crypto-updown-feed-card` is merged, remove - * SHOW_BTC_UP_DOWN_5M_ROW and uncomment the shared hook wiring below. - * - * import { BTC_UP_OR_DOWN_5M_SERIES } from '../../../../../../UI/Predict/constants/btcUpDown5mSeries'; - * import { useCurrentCryptoUpDownMarketData } from '../../../../../../UI/Predict/hooks/useCurrentCryptoUpDownMarketData'; - * import { usePredictNavigation } from '../../../../../../UI/Predict/hooks/usePredictNavigation'; - * import { - * selectPredictEnabledFlag, - * selectPredictWorldCupScreenEnabledFlag, - * } from '../../../../../../UI/Predict/selectors/featureFlags'; - * - * const { navigateToMarketDetails } = usePredictNavigation(); - * const isPredictEnabled = useSelector(selectPredictEnabledFlag); - * const { - * marketId: btcMarketId, - * market: btcWindowMarket, - * currentPrice: btcSpotUsd, - * priceToBeat, - * countdown: btcCountdown, - * } = useCurrentCryptoUpDownMarketData({ - * series: BTC_UP_OR_DOWN_5M_SERIES, - * enabled: isPredictEnabled, - * }); - */ - const btcSpotUsd = undefined; - const priceToBeat = undefined; - const btcCountdown = '--:--'; - const { marketData, isFetching, hasMore } = worldCup; const { marketData: nbaMarketData, isFetching: isNbaFetching } = nbaChampion; @@ -160,24 +146,21 @@ const HomepagePredictWorldCupDiscovery: React.FC< PREDICT_EMPTY_STATE_CTA_NAMES.BROWSE_CATEGORY, 'crypto', ); - /* - * TODO: When `predict/crypto-updown-feed-card` is merged, uncomment this - * branch with the shared hook data above so the BTC row opens the live - * market directly. - * - * if (btcMarketId) { - * navigateToMarketDetails( - * { - * marketId: btcMarketId, - * entryPoint: PredictEventValues.ENTRY_POINT.HOME_SECTION, - * title: btcWindowMarket?.title ?? BTC_UP_OR_DOWN_5M_SERIES.title, - * image: btcWindowMarket?.image, - * }, - * { throughRoot: true }, - * ); - * return; - * } - */ + if (btcMarketId) { + navigateToMarketDetails( + { + marketId: btcMarketId, + entryPoint: PredictEventValues.ENTRY_POINT.HOME_SECTION, + title: btcWindowMarket?.title ?? BTC_UP_OR_DOWN_5M_SERIES.title, + image: btcWindowMarket?.image, + ...(transactionActiveAbTests?.length && { + transactionActiveAbTests, + }), + }, + { throughRoot: true }, + ); + return; + } navigation.navigate(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_LIST, params: { @@ -186,7 +169,15 @@ const HomepagePredictWorldCupDiscovery: React.FC< ...(transactionActiveAbTests?.length && { transactionActiveAbTests }), }, }); - }, [navigation, onTreatmentCtaClick, transactionActiveAbTests]); + }, [ + btcMarketId, + btcWindowMarket?.image, + btcWindowMarket?.title, + navigateToMarketDetails, + navigation, + onTreatmentCtaClick, + transactionActiveAbTests, + ]); const goToWorldCup = useCallback( (initialTab: string) => { @@ -255,14 +246,12 @@ const HomepagePredictWorldCupDiscovery: React.FC< entryPoint={PredictEventValues.ENTRY_POINT.HOME_SECTION} > - {SHOW_BTC_UP_DOWN_5M_ROW ? ( - - ) : null} + { const isMusdConversionEnabled = useSelector( selectIsMusdConversionFlowEnabledFlag, ); - const isMoneyHomeEnabled = useSelector(selectMoneyHomeScreenEnabledFlag); + const isMoneyAccountEnabled = useSelector(selectMoneyEnableMoneyAccountFlag); useEffect(() => { navigation.setOptions( @@ -69,7 +69,7 @@ const DeveloperOptions = () => { {isPerpsEnabled && } {isMusdConversionEnabled && } - {isMoneyHomeEnabled && } + {isMoneyAccountEnabled && } diff --git a/app/components/Views/TrendingView/TrendingView.view.test.tsx b/app/components/Views/TrendingView/TrendingView.view.test.tsx index 41fef42658a6..0bc64719a9f0 100644 --- a/app/components/Views/TrendingView/TrendingView.view.test.tsx +++ b/app/components/Views/TrendingView/TrendingView.view.test.tsx @@ -90,7 +90,7 @@ const navigateToExploreTab = async ( }; /** - * Navigates to the Crypto tab in the V2 tabbed Explore layout. + * Navigates to the Crypto tab in the tabbed Explore layout. * Trending tokens (and their "View All" button) live in the Crypto tab. */ const navigateToCryptoTab = async (getByTestId: RenderAPI['getByTestId']) => @@ -220,7 +220,7 @@ describeForPlatforms('ExploreFeed - Component Tests', () => { }); }); - it('user switches between Explore V2 tabs and sees tab-specific sections', async () => { + it('user switches between Explore tabs and sees tab-specific sections', async () => { const { getByTestId, getByText, queryAllByTestId } = renderTrendingViewWithRoutes(); @@ -262,7 +262,7 @@ describeForPlatforms('ExploreFeed - Component Tests', () => { }); }); - it('opens the requested Explore V2 tab from route params', async () => { + it('opens the requested Explore tab from route params', async () => { const { getByText, queryAllByTestId } = renderTrendingViewWithRoutes({ initialParams: { initialTab: EXPLORE_TAB_INDEX.SITES }, }); diff --git a/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.testIds.ts b/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.testIds.ts index f67f29dc0d1e..6ba57e1304eb 100644 --- a/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.testIds.ts +++ b/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.testIds.ts @@ -1,12 +1,12 @@ /** - * Test IDs for ExploreSearchScreen and its child ExploreSearchResultsV2. + * Test IDs for ExploreSearchScreen and its child ExploreSearchResults. * * Pill IDs are generated by PillRow using the pattern: * `${testIdPrefix}-pill-${pill.key}` where testIdPrefix="explore-search". * Feed keys map directly to SearchFeedId values. */ export const ExploreSearchScreenSelectorsIDs = { - /** FlashList in ExploreSearchResultsV2 (testID set directly on the component) */ + /** FlashList in ExploreSearchResults (testID set directly on the component) */ SEARCH_RESULTS_LIST: 'trending-search-results-list', /** Horizontal ScrollView wrapping all pills */ PILL_ROW: 'explore-search-pills', diff --git a/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.tsx b/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.tsx index 745388adb529..210dca5b5cc0 100644 --- a/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.tsx +++ b/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.tsx @@ -8,14 +8,12 @@ import React, { import { ActivityIndicator, Keyboard, Platform } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; -import { useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box } from '@metamask/design-system-react-native'; import { FlashList, FlashListRef, ListRenderItem } from '@shopify/flash-list'; import ExploreSearchBar from '../../components/ExploreSearchBar/ExploreSearchBar'; import PillRow, { type PillOption } from '../../components/PillRow'; import ExploreSearchResults from '../../search/ExploreSearchResults'; -import ExploreSearchResultsV2 from '../../search/ExploreSearchResultsV2'; import SearchFeedRow, { SearchFeedSkeleton, getItemId, @@ -26,13 +24,12 @@ import { type SearchFeedPill, } from '../../search/analytics'; import { - useExploreSearchV2, type SearchFeedId, -} from '../../search/useExploreSearchV2'; + useExploreSearch, +} from '../../search/useExploreSearch'; import PerpsSectionProvider from '../../feeds/perps/PerpsSectionProvider'; import SitesSearchFooter from '../../../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter'; import { strings } from '../../../../../../locales/i18n'; -import { selectExploreSearchV2Flag } from '../../../../../selectors/featureFlagController/exploreSearchV2'; import { MAX_ITEMS_PER_SECTION } from '../../search/viewMoreLabel'; const ALL_PILL_KEY = 'all' as const; @@ -140,19 +137,19 @@ const FullFeedList: React.FC = ({ ); }; -interface ExploreSearchV2ContentProps { +interface ExploreSearchContentProps { searchQuery: string; } /** - * Renders the pill filter row and content pane for the V2 search experience. - * Must be a child of PerpsSectionProvider because useExploreSearchV2 + * Renders the pill filter row and content pane for the search experience. + * Must be a child of PerpsSectionProvider because useExploreSearch * internally calls usePerpsFeed, which requires PerpsStreamProvider. * - * A single useExploreSearchV2 instance is shared across the pill row and the + * A single useExploreSearch instance is shared across the pill row and the * active content pane, so switching pills never triggers new API calls. */ -const ExploreSearchV2Content: React.FC = ({ +const ExploreSearchContent: React.FC = ({ searchQuery, }) => { const [activePill, setActivePill] = useState(ALL_PILL_KEY); @@ -161,7 +158,9 @@ const ExploreSearchV2Content: React.FC = ({ const searchQueryRef = useRef(searchQuery); searchQueryRef.current = searchQuery; - const { sections } = useExploreSearchV2(searchQuery); + const { sections } = useExploreSearch(searchQuery, { + exposePagination: true, + }); const pills = useMemo( () => [ @@ -189,7 +188,7 @@ const ExploreSearchV2Content: React.FC = ({ setActivePill(key as ActivePill); }, []); - // Used by ExploreSearchResultsV2's "View all" button — the analytics event is + // Used by ExploreSearchResults' "View all" button — the analytics event is // already fired inside handleViewMore there, so we only update state here. const handleViewMoreSelect = useCallback((key: string) => { setActivePill(key as ActivePill); @@ -228,7 +227,7 @@ const ExploreSearchV2Content: React.FC = ({ hasMore={activeSection?.hasMore} /> ) : ( - { const insets = useSafeAreaInsets(); const navigation = useNavigation(); const [searchQuery, setSearchQuery] = useState(''); - const isExploreSearchV2Enabled = useSelector(selectExploreSearchV2Flag); const handleSearchCancel = useCallback(() => { setSearchQuery(''); @@ -267,11 +265,7 @@ const ExploreSearchScreen: React.FC = () => { - {isExploreSearchV2Enabled ? ( - - ) : ( - - )} + ); diff --git a/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.view.test.tsx b/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.view.test.tsx index 75b7f4ea375e..5b1df337cdd4 100644 --- a/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.view.test.tsx +++ b/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.view.test.tsx @@ -29,7 +29,7 @@ const actButtonPress = async (elem: ReactTestInstance) => { } }; -describeForPlatforms('ExploreSearchScreen V2 - Component Tests', () => { +describeForPlatforms('ExploreSearchScreen - Component Tests', () => { beforeEach(() => { setupTrendingApiFetchMock(mockTrendingTokensData); }); @@ -204,7 +204,7 @@ describeForPlatforms('ExploreSearchScreen V2 - Component Tests', () => { it('"All" pill is selected by default and pill row is present on mount', async () => { const { getByTestId } = renderExploreSearchScreenWithRoutes(); - // The pill row is mounted immediately when V2 is enabled — it does not require + // The pill row is mounted immediately — it does not require // a search query. The "All" pill must be selected (active) by default. await waitFor(() => { const allPill = getByTestId(ExploreSearchScreenSelectorsIDs.PILL_ALL); diff --git a/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.test.tsx b/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.test.tsx deleted file mode 100644 index 9636c9eb9a8f..000000000000 --- a/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.test.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; -import ExploreSectionResultsFullView from './ExploreSectionResultsFullView'; -import { analytics } from '../../../../../util/analytics/analytics'; -import { AnalyticsEventBuilder } from '../../../../../util/analytics/AnalyticsEventBuilder'; - -const mockGoBack = jest.fn(); -const mockNavigate = jest.fn(); -const mockTokenData = [ - { assetId: '1', symbol: 'BTC', name: 'Bitcoin' }, - { assetId: '2', symbol: 'ETH', name: 'Ethereum' }, - { assetId: '3', symbol: 'SOL', name: 'Solana' }, - { assetId: '4', symbol: 'USDC', name: 'USD Coin' }, -]; - -const mockRouteParams: { - feedId: string; - title: string; - searchQuery: string; - data: unknown[]; -} = { - feedId: 'tokens', - title: 'Trending tokens', - searchQuery: 'bitcoin', - data: mockTokenData, -}; - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - goBack: mockGoBack, - navigate: mockNavigate, - }), - useRoute: () => ({ - params: mockRouteParams, - }), -})); - -const mockBuild = jest.fn().mockReturnValue({}); -const mockAddProperties = jest.fn().mockReturnThis(); - -jest.mock('../../../../../util/analytics/analytics', () => { - const { createAnalyticsMockModule } = jest.requireActual( - '../../../../../util/test/analyticsMock', - ); - return createAnalyticsMockModule(); -}); - -jest.mock('../../../../../util/analytics/AnalyticsEventBuilder', () => ({ - AnalyticsEventBuilder: { - createEventBuilder: jest.fn().mockReturnValue({ - addProperties: jest.fn().mockReturnThis(), - build: jest.fn().mockReturnValue({}), - }), - }, -})); - -const mockAnalyticsTrackEvent = analytics.trackEvent as jest.MockedFunction< - typeof analytics.trackEvent ->; -const mockCreateEventBuilder = - AnalyticsEventBuilder.createEventBuilder as jest.MockedFunction< - typeof AnalyticsEventBuilder.createEventBuilder - >; - -// Replace the search row dispatcher with a stub that exposes the item id so -// taps on a specific row are testable. -jest.mock('../../search/SearchFeedRow', () => { - const { View } = jest.requireActual('react-native'); - const TapView = jest.requireActual('../../search/TapView').default; - return { - __esModule: true, - default: ({ - feedId, - item, - tabName, - searchQuery, - index, - }: { - feedId: string; - item: { assetId: string }; - tabName: string; - searchQuery: string; - index: number; - }) => { - const { trackExploreSearchEvent } = jest.requireActual( - '../../search/analytics', - ); - return ( - - trackExploreSearchEvent({ - interaction_type: 'result_clicked', - search_query: searchQuery, - ...(tabName === 'all' ? { section_name: feedId } : {}), - tab_name: tabName, - item_clicked: item.assetId, - position: index, - }) - } - > - - - ); - }, - SearchFeedSkeleton: () => , - }; -}); - -describe('ExploreSectionResultsFullView', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockRouteParams.feedId = 'tokens'; - mockRouteParams.title = 'Trending tokens'; - mockRouteParams.searchQuery = 'bitcoin'; - mockRouteParams.data = mockTokenData; - - mockAddProperties.mockReturnThis(); - mockCreateEventBuilder.mockReturnValue({ - addProperties: mockAddProperties, - build: mockBuild, - } as never); - }); - - it('renders the title from route params', () => { - const { getByText } = render(); - expect(getByText('Trending tokens')).toBeOnTheScreen(); - }); - - it('navigates back when back button is pressed', () => { - const { getByLabelText } = render(); - fireEvent.press(getByLabelText('Go back')); - expect(mockGoBack).toHaveBeenCalledTimes(1); - }); - - it('renders all items from the section data', () => { - const { getByTestId } = render(); - expect(getByTestId('row-item-1')).toBeOnTheScreen(); - expect(getByTestId('row-item-2')).toBeOnTheScreen(); - expect(getByTestId('row-item-3')).toBeOnTheScreen(); - expect(getByTestId('row-item-4')).toBeOnTheScreen(); - }); - - it('renders empty list when section data is empty', () => { - mockRouteParams.data = []; - const { queryByTestId } = render(); - expect(queryByTestId('row-item-1')).toBeNull(); - }); - - it('fires analytics event when an item is tapped', () => { - const { getByTestId } = render(); - - const item = getByTestId('row-item-1'); - fireEvent(item, 'touchStart', { nativeEvent: { pageY: 100 } }); - fireEvent(item, 'touchEnd', {}); - - expect(mockCreateEventBuilder).toHaveBeenCalled(); - expect(mockAddProperties).toHaveBeenCalledWith( - expect.objectContaining({ - interaction_type: 'result_clicked', - search_query: 'bitcoin', - section_name: 'tokens', - tab_name: 'all', - item_clicked: '1', - position: 0, - }), - ); - expect(mockAnalyticsTrackEvent).toHaveBeenCalled(); - }); -}); diff --git a/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.tsx b/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.tsx deleted file mode 100644 index 966246122f23..000000000000 --- a/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import React, { useCallback } from 'react'; -import { Platform } from 'react-native'; -import { FlashList, ListRenderItem } from '@shopify/flash-list'; -import { - useNavigation, - useRoute, - RouteProp, - NavigationProp, -} from '@react-navigation/native'; -import type { RootStackParamList } from '../../../../../core/NavigationService/types'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { - Box, - Text, - TextVariant, - ButtonIcon, - ButtonIconSize, - IconName, - BoxFlexDirection, - BoxAlignItems, - FontWeight, -} from '@metamask/design-system-react-native'; -import type { TrendingAsset } from '@metamask/assets-controllers'; -import type { PerpsMarketData } from '@metamask/perps-controller'; -import type { PredictMarket as PredictMarketType } from '../../../../UI/Predict/types'; -import type { SiteData } from '../../../../UI/Sites/components/SiteRowItem/SiteRowItem'; -import PerpsSectionProvider from '../../feeds/perps/PerpsSectionProvider'; -import SearchFeedRow from '../../search/SearchFeedRow'; -import { useScrollTracking } from '../../search/analytics'; -import type { SearchFeedId } from '../../search/useExploreSearch'; - -const SectionContent: React.FC<{ - feedId: SearchFeedId; - searchQuery: string; - data: unknown[]; -}> = ({ feedId, searchQuery, data }) => { - const tw = useTailwind(); - const { onScrollBeginDrag } = useScrollTracking('scrolled', searchQuery, { - tab_name: feedId, - }); - - const renderItem: ListRenderItem = useCallback( - ({ item, index }) => ( - - ), - [feedId, searchQuery], - ); - - const keyExtractor = useCallback( - (item: unknown, index: number) => { - switch (feedId) { - case 'tokens': - case 'stocks': - return `${feedId}-${(item as TrendingAsset).assetId ?? index}`; - case 'perps': - return `${feedId}-${(item as PerpsMarketData).symbol ?? index}`; - case 'predictions': - return `${feedId}-${(item as PredictMarketType).id ?? index}`; - case 'sites': - return `${feedId}-${(item as SiteData).url ?? index}`; - } - }, - [feedId], - ); - - return ( - - ); -}; - -const ExploreSectionResultsFullView: React.FC = () => { - const insets = useSafeAreaInsets(); - const navigation = useNavigation>(); - const route = - useRoute>(); - - const { feedId, title, searchQuery, data } = route.params; - const Wrapper = feedId === 'perps' ? PerpsSectionProvider : React.Fragment; - - const handleGoBack = useCallback(() => { - navigation.goBack(); - }, [navigation]); - - return ( - - - - - {title} - - - - - - - - ); -}; - -export default ExploreSectionResultsFullView; diff --git a/app/components/Views/TrendingView/search/ExploreSearchResultsV2.test.ts b/app/components/Views/TrendingView/search/ExploreSearchResults.test.ts similarity index 97% rename from app/components/Views/TrendingView/search/ExploreSearchResultsV2.test.ts rename to app/components/Views/TrendingView/search/ExploreSearchResults.test.ts index 0df2a1802321..9a1d9fb4214f 100644 --- a/app/components/Views/TrendingView/search/ExploreSearchResultsV2.test.ts +++ b/app/components/Views/TrendingView/search/ExploreSearchResults.test.ts @@ -1,5 +1,5 @@ /** - * ExploreSearchResultsV2 — unit tests for getViewMoreLabel and LOCAL_SEARCH_FEEDS + * ExploreSearchResults — unit tests for getViewMoreLabel and LOCAL_SEARCH_FEEDS * * Tests the pure label-derivation logic that determines what text the * "View X more" button shows for each feed section, or null when the button diff --git a/app/components/Views/TrendingView/search/ExploreSearchResults.tsx b/app/components/Views/TrendingView/search/ExploreSearchResults.tsx index bcb09cb83eaf..efeee644a494 100644 --- a/app/components/Views/TrendingView/search/ExploreSearchResults.tsx +++ b/app/components/Views/TrendingView/search/ExploreSearchResults.tsx @@ -1,9 +1,9 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { Pressable, StyleSheet } from 'react-native'; -import { useNavigation, type NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { Box, + TabEmptyState, Text, TextVariant, TextColor, @@ -19,18 +19,39 @@ import { import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { FlashList, FlashListRef, ListRenderItem } from '@shopify/flash-list'; import type { TrendingAsset } from '@metamask/assets-controllers'; -import type { RootStackParamList } from '../../../../core/NavigationService/types'; import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; import SitesSearchFooter from '../../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter'; import { useSearchTracking } from '../../../UI/Trending/hooks/useSearchTracking/useSearchTracking'; import { TimeOption } from '../../../UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet'; -import Routes from '../../../../constants/navigation/Routes'; import { strings } from '../../../../../locales/i18n'; -import { trackExploreSearchEvent, useScrollTracking } from './analytics'; -import { useExploreSearch, type SearchFeedSection } from './useExploreSearch'; +import { + trackExploreSearchEvent, + useScrollTracking, + type SearchFeedPill, +} from './analytics'; +import { type SearchFeedId, type SearchFeedSection } from './useExploreSearch'; import SearchFeedRow, { SearchFeedSkeleton, getItemId } from './SearchFeedRow'; -import { MAX_ITEMS_PER_SECTION } from './viewMoreLabel'; +import { MAX_ITEMS_PER_SECTION, getViewMoreLabel } from './viewMoreLabel'; import type { FlatListItem, ListItemHeader } from './searchTypes'; +import CryptoMoversPillItem from '../feeds/tokens/CryptoMoversPillItem'; + +const POPULAR_ASSETS: TrendingAsset[] = [ + { + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + symbol: 'BTC', + name: 'Bitcoin', + }, + { + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + name: 'Ethereum', + }, + { + assetId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + symbol: 'SOL', + name: 'Solana', + }, +] as TrendingAsset[]; const pressedStyle = StyleSheet.create({ pressable: { @@ -42,17 +63,27 @@ const pressedStyle = StyleSheet.create({ interface ExploreSearchResultsProps { searchQuery: string; + sections: SearchFeedSection[]; + onViewMore: (feedId: SearchFeedId) => void; + /** When set, renders a "No {title} found" header above the all-results list. */ + emptyFeedTitle?: string; + /** + * The pill that was active when this component was rendered. + * Defaults to 'all'. When an empty-feed fallback is shown (emptyFeedTitle is + * set), this will be the specific feed pill the user tapped — analytics must + * reflect that, not 'all'. + */ + activeTab?: SearchFeedPill; } const ExploreSearchResults: React.FC = ({ searchQuery, + sections, + onViewMore, + emptyFeedTitle, + activeTab = 'all', }) => { - const navigation = useNavigation>(); const tw = useTailwind(); - const { sections } = useExploreSearch(searchQuery, { - truncateWithoutQuery: true, - titleVariant: 'v1', - }); const flashListRef = useRef>(null); const isBasicFunctionalityEnabled = useSelector( selectBasicFunctionalityEnabled, @@ -61,12 +92,12 @@ const ExploreSearchResults: React.FC = ({ const { onScrollBeginDrag, resetScrollTracking } = useScrollTracking( 'scrolled', searchQuery, - { tab_name: 'all' }, + { tab_name: activeTab }, ); useEffect(() => { resetScrollTracking(); - }, [searchQuery, resetScrollTracking]); + }, [searchQuery, activeTab, resetScrollTracking]); const handleViewMore = useCallback( (section: SearchFeedSection) => { @@ -74,61 +105,66 @@ const ExploreSearchResults: React.FC = ({ interaction_type: 'tab_switched', search_query: searchQuery, tab_name: section.feedId, - previous_tab: 'all', + previous_tab: activeTab, comes_from_view_all_tap: true, }); - navigation.navigate(Routes.EXPLORE_SECTION_RESULTS_FULL_VIEW, { - feedId: section.feedId, - title: section.title, - searchQuery, - data: section.items, - }); + onViewMore(section.feedId); }, - [navigation, searchQuery], + [onViewMore, searchQuery, activeTab], ); const renderSectionHeader = useCallback( - (item: ListItemHeader, section: SearchFeedSection) => ( - - { + const viewMoreLabel = section.isLoading + ? null + : getViewMoreLabel( + section.feedId, + section.items.length, + searchQuery, + section.total, + ); + return ( + - {item.title} - - {item.hasMore && ( - handleViewMore(section)} - hitSlop={8} - accessibilityRole="button" - accessibilityLabel={`${strings('trending.view_all')} ${item.title}`} - style={({ pressed }) => [ - pressedStyle.pressable, - pressed && { opacity: 0.5 }, - ]} + - + {viewMoreLabel !== null && ( + handleViewMore(section)} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={`${viewMoreLabel} ${item.title}`} + style={({ pressed }) => [ + pressedStyle.pressable, + pressed && { opacity: 0.5 }, + ]} > - {strings('trending.view_all')} - - - - )} - - ), - [handleViewMore], + + {viewMoreLabel} + + + + )} + + ); + }, + [handleViewMore, searchQuery], ); const flatData = useMemo(() => { @@ -139,15 +175,15 @@ const ExploreSearchResults: React.FC = ({ const { feedId, title, items, isLoading } = section; if (!isLoading && items.length === 0) return; - const hasMore = !isLoading && items.length > MAX_ITEMS_PER_SECTION; - result.push({ type: 'header', feedId, title, hasMore }); + result.push({ type: 'header', feedId, title }); if (isLoading) { for (let i = 0; i < MAX_ITEMS_PER_SECTION; i++) { result.push({ type: 'skeleton', feedId, index: i }); } } else { - items.slice(0, MAX_ITEMS_PER_SECTION).forEach((data, sectionIndex) => { + const visibleItems = items.slice(0, MAX_ITEMS_PER_SECTION); + visibleItems.forEach((data, sectionIndex) => { result.push({ type: 'item', feedId, title, data, sectionIndex }); }); } @@ -157,10 +193,8 @@ const ExploreSearchResults: React.FC = ({ }, [isBasicFunctionalityEnabled, sections]); useEffect(() => { - if (flatData.length > 0) { - flashListRef.current?.scrollToIndex({ index: 0, animated: false }); - } - }, [searchQuery, flatData.length]); + flashListRef.current?.scrollToOffset({ offset: 0, animated: false }); + }, [searchQuery, flatData.length, emptyFeedTitle]); const tokensSection = sections.find((s) => s.feedId === 'tokens'); useSearchTracking({ @@ -194,11 +228,11 @@ const ExploreSearchResults: React.FC = ({ item={item.data} index={item.sectionIndex} searchQuery={searchQuery} - tabName="all" + tabName={activeTab} /> ); }, - [renderSectionHeader, sections, searchQuery], + [renderSectionHeader, sections, searchQuery, activeTab], ); const keyExtractor = useCallback((item: FlatListItem) => { @@ -208,6 +242,73 @@ const ExploreSearchResults: React.FC = ({ return `${item.feedId}-${getItemId(item.feedId, item.data)}`; }, []); + const listHeader = useMemo(() => { + const isLoading = sections.some((s) => s.isLoading); + const allSectionsEmpty = + searchQuery.trim().length > 0 && !isLoading && flatData.length === 0; + + if (!emptyFeedTitle && !allSectionsEmpty) return null; + const showOtherResults = flatData.length > 0 && !isLoading; + const otherResultsCount = sections.reduce( + (sum, s) => sum + (s.total ?? s.items.length), + 0, + ); + return ( + + + + {!isLoading && !showOtherResults && ( + <> + + {strings('trending.no_results_check_popular')} + + + {POPULAR_ASSETS.map((token, index) => ( + + ))} + + + )} + + {showOtherResults && ( + + {strings('trending.showing_all_results_for', { + count: otherResultsCount, + query: searchQuery, + })} + + )} + + ); + }, [emptyFeedTitle, searchQuery, flatData.length, sections]); + return ( = ({ keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" testID="trending-search-results-list" + ListHeaderComponent={listHeader} ListFooterComponent={renderFooter} onScrollBeginDrag={onScrollBeginDrag} /> diff --git a/app/components/Views/TrendingView/search/ExploreSearchResultsV2.tsx b/app/components/Views/TrendingView/search/ExploreSearchResultsV2.tsx deleted file mode 100644 index 1798f3fd57d6..000000000000 --- a/app/components/Views/TrendingView/search/ExploreSearchResultsV2.tsx +++ /dev/null @@ -1,332 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; -import { Pressable, StyleSheet } from 'react-native'; -import { useSelector } from 'react-redux'; -import { - Box, - TabEmptyState, - Text, - TextVariant, - TextColor, - FontWeight, - Icon, - IconName, - IconSize, - IconColor, - BoxFlexDirection, - BoxAlignItems, - BoxJustifyContent, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { FlashList, FlashListRef, ListRenderItem } from '@shopify/flash-list'; -import type { TrendingAsset } from '@metamask/assets-controllers'; -import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; -import SitesSearchFooter from '../../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter'; -import { useSearchTracking } from '../../../UI/Trending/hooks/useSearchTracking/useSearchTracking'; -import { TimeOption } from '../../../UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet'; -import { strings } from '../../../../../locales/i18n'; -import { - trackExploreSearchEvent, - useScrollTracking, - type SearchFeedPill, -} from './analytics'; -import { type SearchFeedId, type SearchFeedSection } from './useExploreSearch'; -import SearchFeedRow, { SearchFeedSkeleton, getItemId } from './SearchFeedRow'; -import { MAX_ITEMS_PER_SECTION, getViewMoreLabel } from './viewMoreLabel'; -import type { FlatListItem, ListItemHeader } from './searchTypes'; -import CryptoMoversPillItem from '../feeds/tokens/CryptoMoversPillItem'; - -const POPULAR_ASSETS: TrendingAsset[] = [ - { - assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', - symbol: 'BTC', - name: 'Bitcoin', - }, - { - assetId: 'eip155:1/slip44:60', - symbol: 'ETH', - name: 'Ethereum', - }, - { - assetId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', - symbol: 'SOL', - name: 'Solana', - }, -] as TrendingAsset[]; - -const pressedStyle = StyleSheet.create({ - pressable: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - }, -}); - -interface ExploreSearchResultsV2Props { - searchQuery: string; - sections: SearchFeedSection[]; - onViewMore: (feedId: SearchFeedId) => void; - /** When set, renders a "No {title} found" header above the all-results list. */ - emptyFeedTitle?: string; - /** - * The pill that was active when this component was rendered. - * Defaults to 'all'. When an empty-feed fallback is shown (emptyFeedTitle is - * set), this will be the specific feed pill the user tapped — analytics must - * reflect that, not 'all'. - */ - activeTab?: SearchFeedPill; -} - -const ExploreSearchResultsV2: React.FC = ({ - searchQuery, - sections, - onViewMore, - emptyFeedTitle, - activeTab = 'all', -}) => { - const tw = useTailwind(); - const flashListRef = useRef>(null); - const isBasicFunctionalityEnabled = useSelector( - selectBasicFunctionalityEnabled, - ); - - const { onScrollBeginDrag, resetScrollTracking } = useScrollTracking( - 'scrolled', - searchQuery, - { tab_name: activeTab }, - ); - - useEffect(() => { - resetScrollTracking(); - }, [searchQuery, activeTab, resetScrollTracking]); - - const handleViewMore = useCallback( - (section: SearchFeedSection) => { - trackExploreSearchEvent({ - interaction_type: 'tab_switched', - search_query: searchQuery, - tab_name: section.feedId, - previous_tab: activeTab, - comes_from_view_all_tap: true, - }); - onViewMore(section.feedId); - }, - [onViewMore, searchQuery, activeTab], - ); - - const renderSectionHeader = useCallback( - (item: ListItemHeader, section: SearchFeedSection) => { - const viewMoreLabel = section.isLoading - ? null - : getViewMoreLabel( - section.feedId, - section.items.length, - searchQuery, - section.total, - ); - return ( - - - {item.title} - - {viewMoreLabel !== null && ( - handleViewMore(section)} - hitSlop={8} - accessibilityRole="button" - accessibilityLabel={`${viewMoreLabel} ${item.title}`} - style={({ pressed }) => [ - pressedStyle.pressable, - pressed && { opacity: 0.5 }, - ]} - > - - {viewMoreLabel} - - - - )} - - ); - }, - [handleViewMore, searchQuery], - ); - - const flatData = useMemo(() => { - const result: FlatListItem[] = []; - const visibleSections = isBasicFunctionalityEnabled ? sections : []; - - visibleSections.forEach((section) => { - const { feedId, title, items, isLoading } = section; - if (!isLoading && items.length === 0) return; - - result.push({ type: 'header', feedId, title }); - - if (isLoading) { - for (let i = 0; i < MAX_ITEMS_PER_SECTION; i++) { - result.push({ type: 'skeleton', feedId, index: i }); - } - } else { - const visibleItems = items.slice(0, MAX_ITEMS_PER_SECTION); - visibleItems.forEach((data, sectionIndex) => { - result.push({ type: 'item', feedId, title, data, sectionIndex }); - }); - } - }); - - return result; - }, [isBasicFunctionalityEnabled, sections]); - - useEffect(() => { - flashListRef.current?.scrollToOffset({ offset: 0, animated: false }); - }, [searchQuery, flatData.length, emptyFeedTitle]); - - const tokensSection = sections.find((s) => s.feedId === 'tokens'); - useSearchTracking({ - searchQuery, - resultsCount: - (tokensSection?.items as TrendingAsset[] | undefined)?.length ?? 0, - isLoading: tokensSection?.isLoading ?? false, - timeFilter: TimeOption.TwentyFourHours, - sortOption: 'relevance', - networkFilter: 'all', - }); - - const renderFooter = - searchQuery.length > 0 ? ( - - ) : null; - - const renderFlatItem: ListRenderItem = useCallback( - ({ item }) => { - if (item.type === 'header') { - const section = sections.find((s) => s.feedId === item.feedId); - if (!section) return null; - return renderSectionHeader(item, section); - } - if (item.type === 'skeleton') { - return ; - } - return ( - - ); - }, - [renderSectionHeader, sections, searchQuery, activeTab], - ); - - const keyExtractor = useCallback((item: FlatListItem) => { - if (item.type === 'header') return `header-${item.feedId}`; - if (item.type === 'skeleton') - return `skeleton-${item.feedId}-${item.index}`; - return `${item.feedId}-${getItemId(item.feedId, item.data)}`; - }, []); - - const listHeader = useMemo(() => { - const isLoading = sections.some((s) => s.isLoading); - const allSectionsEmpty = - searchQuery.trim().length > 0 && !isLoading && flatData.length === 0; - - if (!emptyFeedTitle && !allSectionsEmpty) return null; - const showOtherResults = flatData.length > 0 && !isLoading; - const otherResultsCount = sections.reduce( - (sum, s) => sum + (s.total ?? s.items.length), - 0, - ); - return ( - - - - {!isLoading && !showOtherResults && ( - <> - - {strings('trending.no_results_check_popular')} - - - {POPULAR_ASSETS.map((token, index) => ( - - ))} - - - )} - - {showOtherResults && ( - - {strings('trending.showing_all_results_for', { - count: otherResultsCount, - query: searchQuery, - })} - - )} - - ); - }, [emptyFeedTitle, searchQuery, flatData.length, sections]); - - return ( - - - - ); -}; - -export default ExploreSearchResultsV2; diff --git a/app/components/Views/TrendingView/search/searchTypes.ts b/app/components/Views/TrendingView/search/searchTypes.ts index be1200580057..7ab3f505a848 100644 --- a/app/components/Views/TrendingView/search/searchTypes.ts +++ b/app/components/Views/TrendingView/search/searchTypes.ts @@ -4,8 +4,6 @@ export interface ListItemHeader { type: 'header'; feedId: SearchFeedId; title: string; - /** True when a "View all" button should render (V1 only). */ - hasMore?: boolean; } export interface ListItemData { diff --git a/app/components/Views/TrendingView/search/useExploreSearchV2.test.ts b/app/components/Views/TrendingView/search/useExploreSearch.test.ts similarity index 90% rename from app/components/Views/TrendingView/search/useExploreSearchV2.test.ts rename to app/components/Views/TrendingView/search/useExploreSearch.test.ts index 5151ae8061ff..e9cf56010467 100644 --- a/app/components/Views/TrendingView/search/useExploreSearchV2.test.ts +++ b/app/components/Views/TrendingView/search/useExploreSearch.test.ts @@ -1,5 +1,5 @@ /** - * useExploreSearchV2 — unit tests + * useExploreSearch — unit tests * * Covers: * 1. Section order: tokens first, perps (when enabled), then stocks/predictions/sites. @@ -10,7 +10,7 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { useExploreSearchV2 } from './useExploreSearchV2'; +import { useExploreSearch } from './useExploreSearch'; // --------------------------------------------------------------------------- // Feed hook mocks @@ -91,13 +91,14 @@ import { useStocksFeed } from '../feeds/stocks/useStocksFeed'; import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; import { useSitesFeed } from '../feeds/sites/useSitesFeed'; -const renderV2 = (query = '') => renderHook(() => useExploreSearchV2(query)); +const renderExploreSearch = (query = '') => + renderHook(() => useExploreSearch(query, { exposePagination: true })); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describe('useExploreSearchV2', () => { +describe('useExploreSearch', () => { beforeEach(() => { jest.clearAllMocks(); mockIsPerpsEnabled = true; @@ -112,7 +113,7 @@ describe('useExploreSearchV2', () => { describe('section order', () => { it('includes tokens, perps, stocks, predictions, sites when perps is enabled', () => { - const { result } = renderV2(); + const { result } = renderExploreSearch(); const feedIds = result.current.sections.map((s) => s.feedId); expect(feedIds).toEqual([ 'tokens', @@ -125,7 +126,7 @@ describe('useExploreSearchV2', () => { it('omits perps when selectPerpsEnabledFlag is false', () => { mockIsPerpsEnabled = false; - const { result } = renderV2(); + const { result } = renderExploreSearch(); const feedIds = result.current.sections.map((s) => s.feedId); expect(feedIds).toEqual(['tokens', 'stocks', 'predictions', 'sites']); expect(feedIds).not.toContain('perps'); @@ -134,7 +135,7 @@ describe('useExploreSearchV2', () => { describe('section items', () => { it('maps feed data to section items correctly', () => { - const { result } = renderV2(); + const { result } = renderExploreSearch(); const tokensSection = result.current.sections.find( (s) => s.feedId === 'tokens', ); @@ -161,7 +162,8 @@ describe('useExploreSearchV2', () => { jest.useFakeTimers(); const { result, rerender } = renderHook( - ({ q }: { q: string }) => useExploreSearchV2(q), + ({ q }: { q: string }) => + useExploreSearch(q, { exposePagination: true }), { initialProps: { q: '' } }, ); @@ -192,7 +194,7 @@ describe('useExploreSearchV2', () => { isLoading: true, }); - const { result } = renderV2(); + const { result } = renderExploreSearch(); const tokensSection = result.current.sections.find( (s) => s.feedId === 'tokens', ); @@ -202,7 +204,7 @@ describe('useExploreSearchV2', () => { describe('predictions pagination fields', () => { it('exposes fetchMore, isFetchingMore, and hasMore on the predictions section without a query', () => { - const { result } = renderV2(); + const { result } = renderExploreSearch(); const predictionsSection = result.current.sections.find( (s) => s.feedId === 'predictions', ); @@ -220,7 +222,7 @@ describe('useExploreSearchV2', () => { hasMore: false, }); - const { result } = renderV2(); + const { result } = renderExploreSearch(); const predictionsSection = result.current.sections.find( (s) => s.feedId === 'predictions', ); @@ -240,7 +242,9 @@ describe('useExploreSearchV2', () => { hasMore: true, }); - const { result } = renderHook(() => useExploreSearchV2('bitcoin')); + const { result } = renderHook(() => + useExploreSearch('bitcoin', { exposePagination: true }), + ); act(() => { jest.advanceTimersByTime(250); @@ -260,7 +264,7 @@ describe('useExploreSearchV2', () => { describe('tokens pagination fields', () => { it('exposes fetchMore, isFetchingMore, and hasMore on the tokens section', () => { - const { result } = renderV2(); + const { result } = renderExploreSearch(); const tokensSection = result.current.sections.find( (s) => s.feedId === 'tokens', ); @@ -278,7 +282,7 @@ describe('useExploreSearchV2', () => { hasMore: false, }); - const { result } = renderV2(); + const { result } = renderExploreSearch(); const tokensSection = result.current.sections.find( (s) => s.feedId === 'tokens', ); @@ -295,7 +299,7 @@ describe('useExploreSearchV2', () => { totalCount: 2101, }); - const { result } = renderV2(); + const { result } = renderExploreSearch(); const tokensSection = result.current.sections.find( (s) => s.feedId === 'tokens', ); @@ -303,7 +307,7 @@ describe('useExploreSearchV2', () => { }); it('passes undefined total when the tokens feed has no totalCount', () => { - const { result } = renderV2(); + const { result } = renderExploreSearch(); const tokensSection = result.current.sections.find( (s) => s.feedId === 'tokens', ); @@ -315,7 +319,7 @@ describe('useExploreSearchV2', () => { it.each(['perps', 'stocks', 'sites'] as const)( '%s section does not carry fetchMore or hasMore', (feedId) => { - const { result } = renderV2(); + const { result } = renderExploreSearch(); const section = result.current.sections.find( (s) => s.feedId === feedId, ); @@ -328,12 +332,16 @@ describe('useExploreSearchV2', () => { describe('query is passed to feed hooks after debounce', () => { it('passes empty string to feeds on initial render', () => { - renderV2(''); + renderExploreSearch(''); expect(useTokensFeed).toHaveBeenCalledWith({ query: '' }); expect(usePerpsFeed).toHaveBeenCalledWith({ query: '' }); expect(useStocksFeed).toHaveBeenCalledWith({ query: '' }); expect(usePredictionsFeed).toHaveBeenCalledWith( - expect.objectContaining({ variant: 'trending', query: '' }), + expect.objectContaining({ + variant: 'trending', + query: '', + pageSize: 20, + }), ); expect(useSitesFeed).toHaveBeenCalledWith({ query: '' }); }); diff --git a/app/components/Views/TrendingView/search/useExploreSearch.ts b/app/components/Views/TrendingView/search/useExploreSearch.ts index e123cf9939bf..468f73c9ec3d 100644 --- a/app/components/Views/TrendingView/search/useExploreSearch.ts +++ b/app/components/Views/TrendingView/search/useExploreSearch.ts @@ -17,7 +17,7 @@ export type SearchFeedId = | 'sites'; const DEBOUNCE_MS = 200; -const TOP_ITEMS_WITHOUT_QUERY = 3; +const PREDICTIONS_SEARCH_PAGE_SIZE = 20; export interface SearchFeedSection { feedId: SearchFeedId; @@ -35,25 +35,14 @@ export interface ExploreSearchResult { } export interface UseExploreSearchOptions { - /** Limit each section to TOP_ITEMS_WITHOUT_QUERY when there is no query. */ - truncateWithoutQuery?: boolean; - /** Page size passed to usePredictionsFeed. Defaults to 20. */ - predictionsPageSize?: number; /** Forward fetchMore / hasMore / total on sections that support it. */ exposePagination?: boolean; - /** 'v1' uses trending.* keys; 'v2' uses trending.search_tabs.* keys. */ - titleVariant?: 'v1' | 'v2'; } export const useExploreSearch = ( query: string, options: UseExploreSearchOptions = {}, ): ExploreSearchResult => { - const { - truncateWithoutQuery = false, - predictionsPageSize = 20, - exposePagination = false, - titleVariant = 'v2', - } = options; + const { exposePagination = false } = options; const [debouncedQuery, setDebouncedQuery] = useState(query); const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); @@ -71,23 +60,16 @@ export const useExploreSearch = ( const predictions = usePredictionsFeed({ variant: 'trending', query: debouncedQuery, - pageSize: predictionsPageSize, + pageSize: PREDICTIONS_SEARCH_PAGE_SIZE, }); const sites = useSitesFeed({ query: debouncedQuery }); return useMemo(() => { - const showTopItems = truncateWithoutQuery && !debouncedQuery.trim(); - const trim = (arr: T[]) => - showTopItems ? arr.slice(0, TOP_ITEMS_WITHOUT_QUERY) : arr; - - const t = (v2Key: string, v1Key: string) => - strings(titleVariant === 'v2' ? v2Key : v1Key); - const sections: SearchFeedSection[] = [ { feedId: 'tokens', - title: t('trending.search_tabs.crypto', 'trending.crypto'), - items: trim(tokens.data), + title: strings('trending.search_tabs.crypto'), + items: tokens.data, isLoading: isDebouncing || tokens.isLoading, ...(exposePagination && { fetchMore: tokens.loadMore, @@ -101,8 +83,8 @@ export const useExploreSearch = ( if (isPerpsEnabled) { sections.push({ feedId: 'perps', - title: t('trending.search_tabs.perps', 'trending.perps'), - items: trim(perps.data.map((d) => d.market)), + title: strings('trending.search_tabs.perps'), + items: perps.data.map((d) => d.market), isLoading: isDebouncing || perps.isLoading, }); } @@ -110,14 +92,14 @@ export const useExploreSearch = ( sections.push( { feedId: 'stocks', - title: t('trending.search_tabs.stocks', 'trending.stocks'), - items: trim(stocks.data), + title: strings('trending.search_tabs.stocks'), + items: stocks.data, isLoading: isDebouncing || stocks.isLoading, }, { feedId: 'predictions', - title: t('trending.search_tabs.predictions', 'wallet.predict'), - items: trim(predictions.data), + title: strings('trending.search_tabs.predictions'), + items: predictions.data, isLoading: isDebouncing || predictions.isLoading, ...(exposePagination && { fetchMore: predictions.fetchMore, @@ -128,20 +110,17 @@ export const useExploreSearch = ( }, { feedId: 'sites', - title: t('trending.search_tabs.sites', 'trending.sites'), - items: trim(sites.data), + title: strings('trending.search_tabs.sites'), + items: sites.data, isLoading: isDebouncing || sites.isLoading, }, ); return { sections }; }, [ - debouncedQuery, isDebouncing, isPerpsEnabled, - truncateWithoutQuery, exposePagination, - titleVariant, tokens.data, tokens.isLoading, tokens.loadMore, diff --git a/app/components/Views/TrendingView/search/useExploreSearchV2.ts b/app/components/Views/TrendingView/search/useExploreSearchV2.ts deleted file mode 100644 index bed97dea0f4d..000000000000 --- a/app/components/Views/TrendingView/search/useExploreSearchV2.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { - useExploreSearch, - type ExploreSearchResult, - type SearchFeedId, - type SearchFeedSection, -} from './useExploreSearch'; - -/** - * Search V2: all results (no top-N cap), pagination on tokens/predictions, - * search_tabs.* title keys. - */ -export const useExploreSearchV2 = (query: string): ExploreSearchResult => - useExploreSearch(query, { - exposePagination: true, - titleVariant: 'v2', - }); - -export type { SearchFeedId, SearchFeedSection, ExploreSearchResult }; diff --git a/app/components/Views/TrendingView/search/viewMoreLabel.test.ts b/app/components/Views/TrendingView/search/viewMoreLabel.test.ts index 6f8a26a2e2de..a83b9cb1cd17 100644 --- a/app/components/Views/TrendingView/search/viewMoreLabel.test.ts +++ b/app/components/Views/TrendingView/search/viewMoreLabel.test.ts @@ -63,7 +63,7 @@ describe('getViewMoreLabel', () => { }); describe('loading state — component skips getViewMoreLabel entirely (section.isLoading guard)', () => { - // ExploreSearchResultsV2 now returns null directly when section.isLoading is true + // ExploreSearchResults now returns null directly when section.isLoading is true // without calling getViewMoreLabel. These tests verify that if it were called with // 0 items and no serverTotal, it would correctly return null (nothing to show). it.each(['perps', 'stocks', 'sites', 'tokens', 'predictions'] as const)( diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx index 29276ea3218b..39547572e7a8 100644 --- a/app/components/Views/Wallet/index.test.tsx +++ b/app/components/Views/Wallet/index.test.tsx @@ -95,10 +95,10 @@ jest.mock('../../../selectors/featureFlagController/homepage', () => ({ ), })); -// Control Money home screen feature flag per test (default false so existing tests are unaffected) -let mockMoneyHomeScreenEnabled = false; +// Control Money account feature flag per test (default false so existing tests are unaffected) +let mockMoneyAccountEnabled = false; jest.mock('../../UI/Money/selectors/featureFlags', () => ({ - selectMoneyHomeScreenEnabledFlag: jest.fn(() => mockMoneyHomeScreenEnabled), + selectMoneyEnableMoneyAccountFlag: jest.fn(() => mockMoneyAccountEnabled), })); // Mock MoneyBalanceCard so the integration test does not depend on its hooks/contexts. @@ -2052,12 +2052,12 @@ describe('MoneyBalanceCard slot', () => { }); afterEach(() => { - mockMoneyHomeScreenEnabled = false; + mockMoneyAccountEnabled = false; mockHomepageSectionsEnabled = false; }); it('renders the MoneyBalanceCard when both feature flags are enabled', () => { - mockMoneyHomeScreenEnabled = true; + mockMoneyAccountEnabled = true; mockHomepageSectionsEnabled = true; //@ts-expect-error navigation params intentionally omitted (same as render(Wallet)) @@ -2067,7 +2067,7 @@ describe('MoneyBalanceCard slot', () => { }); it('does not render the MoneyBalanceCard when only the Money flag is enabled', () => { - mockMoneyHomeScreenEnabled = true; + mockMoneyAccountEnabled = true; mockHomepageSectionsEnabled = false; //@ts-expect-error navigation params intentionally omitted (same as render(Wallet)) @@ -2077,7 +2077,7 @@ describe('MoneyBalanceCard slot', () => { }); it('does not render the MoneyBalanceCard when only the Homepage sections flag is enabled', () => { - mockMoneyHomeScreenEnabled = false; + mockMoneyAccountEnabled = false; mockHomepageSectionsEnabled = true; //@ts-expect-error navigation params intentionally omitted (same as render(Wallet)) @@ -2087,7 +2087,7 @@ describe('MoneyBalanceCard slot', () => { }); it('does not render the MoneyBalanceCard when both feature flags are disabled', () => { - mockMoneyHomeScreenEnabled = false; + mockMoneyAccountEnabled = false; mockHomepageSectionsEnabled = false; //@ts-expect-error navigation params intentionally omitted (same as render(Wallet)) diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 5595f3cfae86..2ec1c16b5215 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -57,7 +57,7 @@ import HeaderRoot from '../../../component-library/components-temp/HeaderRoot'; import PickerAccount from '../../../component-library/components/Pickers/PickerAccount'; import AddressCopy from '../../UI/AddressCopy'; import CardButton from '../../UI/Card/components/CardButton'; -import { selectMoneyHomeScreenEnabledFlag } from '../../UI/Money/selectors/featureFlags'; +import { selectMoneyEnableMoneyAccountFlag } from '../../UI/Money/selectors/featureFlags'; import MoneyBalanceCard from '../../UI/Money/components/MoneyBalanceCard'; // eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog import { createAccountSelectorNavDetails } from '../AccountSelector'; @@ -719,9 +719,7 @@ const Wallet = ({ */ const selectedInternalAccount = useSelector(selectSelectedInternalAccount); - const isMoneyHomeScreenEnabled = useSelector( - selectMoneyHomeScreenEnabledFlag, - ); + const isMoneyAccountEnabled = useSelector(selectMoneyEnableMoneyAccountFlag); /** * Provider configuration for the current selected network @@ -1385,7 +1383,7 @@ const Wallet = ({ {walletHomeMainAssetDetailsActions} {homeGrowthBannerContent} - {isMoneyHomeScreenEnabled && } + {isMoneyAccountEnabled && } ); @@ -1397,7 +1395,7 @@ const Wallet = ({ {walletHomeMainAssetDetailsActions} {homeGrowthBannerContent} - {isMoneyHomeScreenEnabled && } + {isMoneyAccountEnabled && } ); @@ -1510,7 +1508,7 @@ const Wallet = ({ style={styles.headerActionButtonsContainer} accessible={false} > - {isMoneyHomeScreenEnabled && ( + {isMoneyAccountEnabled && ( { expect(mockAddTransactionBatch).toHaveBeenCalledWith({ from: MOCK_ACCOUNT, origin: ORIGIN_METAMASK, + isInternal: true, networkClientId: MOCK_NETWORK_CLIENT_ID, disableHook: true, disableSequential: true, diff --git a/app/components/Views/confirmations/utils/send.ts b/app/components/Views/confirmations/utils/send.ts index 6b13374fa088..b30ac8fecb9d 100644 --- a/app/components/Views/confirmations/utils/send.ts +++ b/app/components/Views/confirmations/utils/send.ts @@ -308,6 +308,7 @@ export const submitEvmTransaction = async ({ await addTransaction(trxnParams, { origin: MMM_ORIGIN, + isInternal: true, networkClientId, type: transactionType, securityAlertResponse, diff --git a/app/components/Views/confirmations/utils/transaction.ts b/app/components/Views/confirmations/utils/transaction.ts index 934fa9e49ce2..fa651c354caa 100644 --- a/app/components/Views/confirmations/utils/transaction.ts +++ b/app/components/Views/confirmations/utils/transaction.ts @@ -85,6 +85,7 @@ export async function addMMOriginatedTransaction( const { transactionMeta } = await addTransaction(txParams, { ...options, origin: ORIGIN_METAMASK, + isInternal: true, }); const id = transactionMeta.id; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index a05f3d080b35..164fe349a620 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -132,7 +132,6 @@ const Routes = { WHATS_HAPPENING_DETAIL: 'WhatsHappeningDetailView', SITES_FULL_VIEW: 'SitesFullView', EXPLORE_SEARCH: 'ExploreSearch', - EXPLORE_SECTION_RESULTS_FULL_VIEW: 'ExploreSectionResultsFullView', REWARDS_ONBOARDING_FLOW: 'RewardsOnboardingFlow', REWARDS_ONBOARDING_INTRO: 'RewardsOnboardingIntro', REWARD_BENEFITS_FULL_VIEW: 'BenefitsFullView', diff --git a/app/core/NavigationService/types.ts b/app/core/NavigationService/types.ts index 93259262e577..be841ca52490 100644 --- a/app/core/NavigationService/types.ts +++ b/app/core/NavigationService/types.ts @@ -227,8 +227,6 @@ export interface NestedNavigationParams { [key: string]: unknown; } -import type { SearchFeedId } from '../../components/Views/TrendingView/search/useExploreSearch'; - type TraderPositionViewParams = | { traderId: string; @@ -366,12 +364,6 @@ export interface RootStackParamList extends ParamListBase { | undefined; SitesFullView: { mode?: 'favorites' } | undefined; ExploreSearch: undefined; - ExploreSectionResultsFullView: { - feedId: SearchFeedId; - title: string; - searchQuery: string; - data: unknown[]; - }; RewardsOnboardingFlow: undefined; RewardsOnboardingIntro: undefined; BenefitFullView: BenefitFullViewRouteParams; diff --git a/app/core/redux/slices/bridge/index.test.ts b/app/core/redux/slices/bridge/index.test.ts index 53d020377cfb..7b9adbb84fac 100644 --- a/app/core/redux/slices/bridge/index.test.ts +++ b/app/core/redux/slices/bridge/index.test.ts @@ -31,6 +31,8 @@ import reducer, { selectBatchSellDestToken, selectBatchSellDestStablecoins, selectBatchSellDestStablecoinsByChain, + selectHardwareWalletsSwaps, + updateHardwareWalletsSwaps, selectBatchSellQuotes, selectBatchSellSlippages, setBatchSellTokenSlippage, @@ -50,6 +52,11 @@ import { import { RootState } from '../../../../reducers'; import { cloneDeep } from 'lodash'; import { BridgeTokenMetadata } from '../../../../components/UI/Bridge/constants/tokens'; +import { + HardwareWalletsSwapsEventType, + HardwareWalletsSwapsStatus, + initialHardwareWalletsSwapsState, +} from '../../../../components/UI/HardwareWallet/Swaps/HardwareWalletsSwaps.state'; import { formatAddressToAssetId } from '@metamask/bridge-controller'; describe('bridge slice', () => { @@ -121,6 +128,7 @@ describe('bridge slice', () => { visiblePillChainIds: undefined, selectedQuoteRequestId: undefined, abTestContext: undefined, + hardwareWalletsSwaps: initialHardwareWalletsSwapsState, batchSellSourceTokens: [], batchSellSourceTokenAmounts: {}, batchSellDestToken: undefined, @@ -1072,6 +1080,39 @@ describe('bridge slice', () => { }); }); + describe('selectHardwareWalletsSwaps', () => { + it('returns initial hardware wallet swaps state from bridge state', () => { + const mockState = { + bridge: initialState, + } as RootState; + + expect(selectHardwareWalletsSwaps(mockState)).toEqual( + initialHardwareWalletsSwapsState, + ); + }); + + it('returns updated hardware wallet swaps state after reducer action', () => { + const bridgeState = reducer( + initialState, + updateHardwareWalletsSwaps({ + type: HardwareWalletsSwapsEventType.Start, + payload: { totalSteps: 1 }, + }), + ); + const mockState = { + bridge: bridgeState, + } as RootState; + + expect(selectHardwareWalletsSwaps(mockState)).toEqual({ + ...initialHardwareWalletsSwapsState, + status: HardwareWalletsSwapsStatus.Waiting, + currentStep: 1, + totalSteps: 1, + steps: expect.any(Array), + }); + }); + }); + describe('resetBridgeState with selectedQuoteRequestId', () => { it('resets selectedQuoteRequestId when bridge state resets', () => { const stateWithSelection = { diff --git a/app/core/redux/slices/bridge/index.ts b/app/core/redux/slices/bridge/index.ts index c080f70dbeb5..ee5f2819ccb6 100644 --- a/app/core/redux/slices/bridge/index.ts +++ b/app/core/redux/slices/bridge/index.ts @@ -33,6 +33,12 @@ import { BridgeToken, BridgeViewMode, } from '../../../../components/UI/Bridge/types'; +import { + HardwareWalletsSwapsEvent, + HardwareWalletsSwapsState, + hardwareWalletsSwapsReducer, + initialHardwareWalletsSwapsState, +} from '../../../../components/UI/HardwareWallet/Swaps/HardwareWalletsSwaps.state'; import { analytics } from '../../../../util/analytics/analytics'; import { selectRemoteFeatureFlags } from '../../../../selectors/featureFlagController'; import { getTokenExchangeRate } from '../../../../components/UI/Bridge/utils/exchange-rates'; @@ -95,6 +101,7 @@ export interface BridgeState { * When undefined, the recommended quote (best quote) is used. */ selectedQuoteRequestId: string | undefined; + hardwareWalletsSwaps: HardwareWalletsSwapsState; batchSellSourceTokens: BridgeToken[]; batchSellSourceTokenAmounts: Partial< Record @@ -124,6 +131,7 @@ export const initialState: BridgeState = { tokenSelectorNetworkFilter: undefined, visiblePillChainIds: undefined, selectedQuoteRequestId: undefined, + hardwareWalletsSwaps: initialHardwareWalletsSwapsState, // Batch Sell batchSellSourceTokens: [], @@ -262,6 +270,18 @@ const slice = createSlice({ ) => { state.selectedQuoteRequestId = action.payload; }, + updateHardwareWalletsSwaps: ( + state, + action: PayloadAction, + ) => { + state.hardwareWalletsSwaps = hardwareWalletsSwapsReducer( + state.hardwareWalletsSwaps, + action.payload, + ); + }, + resetHardwareWalletsSwaps: (state) => { + state.hardwareWalletsSwaps = initialHardwareWalletsSwapsState; + }, setBatchSellSourceTokens: (state, action: PayloadAction) => { state.batchSellSourceTokens = action.payload.map(normalizeBridgeToken); }, @@ -831,6 +851,11 @@ export const selectIsSubmittingTx = createSelector( (bridgeState) => bridgeState.isSubmittingTx, ); +export const selectHardwareWalletsSwaps = createSelector( + selectBridgeState, + (bridgeState) => bridgeState.hardwareWalletsSwaps, +); + export const selectIsSelectingRecipient = createSelector( selectBridgeState, (bridgeState) => bridgeState.isSelectingRecipient, @@ -947,6 +972,8 @@ export const { setTokenSelectorNetworkFilter, setVisiblePillChainIds, setSelectedQuoteRequestId, + updateHardwareWalletsSwaps, + resetHardwareWalletsSwaps, setBatchSellSourceTokens, setBatchSellSourceTokenAmount, setBatchSellSourceTokenAmounts, diff --git a/app/lib/Money/feature-flags.test.ts b/app/lib/Money/feature-flags.test.ts index f3ff43c8d0bc..702405922f8f 100644 --- a/app/lib/Money/feature-flags.test.ts +++ b/app/lib/Money/feature-flags.test.ts @@ -15,11 +15,6 @@ const mockedValidate = describe('isMoneyAccountEnabled', () => { beforeEach(() => { jest.clearAllMocks(); - delete process.env.MM_MONEY_ENABLE_MONEY_ACCOUNT; - }); - - afterEach(() => { - delete process.env.MM_MONEY_ENABLE_MONEY_ACCOUNT; }); it('returns true when remote flag is enabled and version requirement is met', () => { @@ -42,16 +37,7 @@ describe('isMoneyAccountEnabled', () => { expect(result).toBe(false); }); - it('falls back to local env var when remote flag returns undefined', () => { - mockedValidate.mockReturnValue(undefined); - process.env.MM_MONEY_ENABLE_MONEY_ACCOUNT = 'true'; - - const result = isMoneyAccountEnabled({}); - - expect(result).toBe(true); - }); - - it('returns false when both remote and local flags are unavailable', () => { + it('returns false when remote flag returns undefined', () => { mockedValidate.mockReturnValue(undefined); const result = isMoneyAccountEnabled({}); diff --git a/app/lib/Money/feature-flags.ts b/app/lib/Money/feature-flags.ts index 736ed109e077..dc128f5ee2fb 100644 --- a/app/lib/Money/feature-flags.ts +++ b/app/lib/Money/feature-flags.ts @@ -13,9 +13,8 @@ import { export function isMoneyAccountEnabled( remoteFeatureFlags: Record | undefined, ): boolean { - const localFlag = process.env.MM_MONEY_ENABLE_MONEY_ACCOUNT === 'true'; const remoteFlag = remoteFeatureFlags?.moneyEnableMoneyAccount as VersionGatedFeatureFlag; - return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; } diff --git a/app/selectors/featureFlagController/exploreSearchV2/index.test.ts b/app/selectors/featureFlagController/exploreSearchV2/index.test.ts deleted file mode 100644 index ee6cbe13fff3..000000000000 --- a/app/selectors/featureFlagController/exploreSearchV2/index.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { selectExploreSearchV2Flag } from '.'; -import mockedEngine from '../../../core/__mocks__/MockedEngine'; -// eslint-disable-next-line import-x/no-namespace -import * as remoteFeatureFlagModule from '../../../util/remoteFeatureFlag'; - -jest.mock('../../../core/Engine', () => ({ - init: () => mockedEngine.init(), -})); - -jest.mock('react-native-device-info', () => ({ - getVersion: jest.fn().mockReturnValue('7.79.0'), -})); - -describe('Explore Search V2 feature flag selector', () => { - let mockHasMinimumRequiredVersion: jest.SpyInstance; - - beforeEach(() => { - jest.clearAllMocks(); - mockHasMinimumRequiredVersion = jest.spyOn( - remoteFeatureFlagModule, - 'hasMinimumRequiredVersion', - ); - mockHasMinimumRequiredVersion.mockReturnValue(true); - }); - - afterEach(() => { - mockHasMinimumRequiredVersion?.mockRestore(); - }); - - it('returns true when flag is enabled and version requirement is met', () => { - const result = selectExploreSearchV2Flag.resultFunc({ - exploreSearchV2: { enabled: true, minimumVersion: '7.79.0' }, - }); - expect(result).toBe(true); - }); - - it('returns false when flag is disabled', () => { - const result = selectExploreSearchV2Flag.resultFunc({ - exploreSearchV2: { enabled: false, minimumVersion: '7.79.0' }, - }); - expect(result).toBe(false); - }); - - it('returns false when version requirement is not met', () => { - mockHasMinimumRequiredVersion.mockReturnValue(false); - const result = selectExploreSearchV2Flag.resultFunc({ - exploreSearchV2: { enabled: true, minimumVersion: '99.0.0' }, - }); - expect(result).toBe(false); - }); - - it('returns false when flag is missing', () => { - const result = selectExploreSearchV2Flag.resultFunc({}); - expect(result).toBe(false); - }); - - it('returns false when flag has an invalid shape', () => { - const result = selectExploreSearchV2Flag.resultFunc({ - exploreSearchV2: true, - }); - expect(result).toBe(false); - }); -}); diff --git a/app/selectors/featureFlagController/exploreSearchV2/index.ts b/app/selectors/featureFlagController/exploreSearchV2/index.ts deleted file mode 100644 index 1545a753f230..000000000000 --- a/app/selectors/featureFlagController/exploreSearchV2/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createSelector } from 'reselect'; -import { selectRemoteFeatureFlags } from '..'; -import { validatedVersionGatedFeatureFlag } from '../../../util/remoteFeatureFlag'; - -/** Remote client-config key; LaunchDarkly alias should match for ops. */ -export const FEATURE_FLAG_NAME = 'exploreSearchV2'; - -/** - * When true, the Explore search uses Search V2 (tabbed); when false, V1. - * Gated by both a remote `enabled` boolean and a `minimumVersion` semver string. - */ -export const selectExploreSearchV2Flag = createSelector( - selectRemoteFeatureFlags, - (remoteFeatureFlags) => - validatedVersionGatedFeatureFlag(remoteFeatureFlags[FEATURE_FLAG_NAME]) ?? - false, -); diff --git a/locales/languages/en.json b/locales/languages/en.json index a3a4899c76fc..cc789ba1818e 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6788,7 +6788,6 @@ "deposit_tooltip_description": "Add funds to your Money account and earn up to {{percentage}}% APY automatically. You can add funds by converting your existing stablecoin balance or depositing directly from your bank or card.", "balance_unavailable": "Balance unavailable", "balance_retry": "Retry", - "balance_feature_disabled": "Money account disabled", "balance_no_account": "Money account not found", "onboarding": { "step_1": { @@ -6874,13 +6873,14 @@ "link_subtitle": "Spend your Money balance and earn on purchases. Plus, up to {{apy}}% APY (variable) on your balance.", "link_subtitle_no_apy": "Spend your Money balance and earn on purchases.", "link_bullet_cashback": "Get {{percentage}}% mUSD back", - "link_bullet_apy": "Earn up to {{apy}}% APY", + "link_bullet_apy": "Earn up to {{apy}}% APY (variable)", "link_card": "Link card", "link_pending_title": "Linking your card", "link_success_title": "Your card is ready to use", "link_error": "Something went wrong linking your card", "link_card_sheet_title": "Spend and earn", - "link_card_sheet_description": "Link your card so you can spend your Money balance and earn mUSD back on purchases—all while earning up to {{apy}}% APY.", + "link_card_sheet_description_prefix": "Link your card so you can spend your Money balance and earn mUSD back on purchases—all while earning up to", + "link_card_sheet_description_suffix": " (variable).", "link_card_sheet_description_no_apy": "Link your card so you can spend your Money balance and earn mUSD back on purchases.", "link_card_sheet_cta": "Link card", "manage_card": "Manage", @@ -8389,10 +8389,12 @@ "money_account_label": "Money account", "money_account_token_symbol": "mUSD", "use_money_account_cta": "Use Money account", - "spend_and_earn_title": "Spend while you earn", - "spend_and_earn_description": "Spend with your Money account and earn up to {{apy}}% APY on your balance. Also get {{cashback}}% mUSD back.", - "spend_and_earn_description_no_apy": "Spend with your Money account and earn APY on your balance. Also get {{cashback}}% mUSD back.", - "spend_and_earn_cta": "Link to Money account" + "spend_and_earn_title": "Spend and earn", + "spend_and_earn_description_prefix": "Link your balance to your card and get mUSD back on purchases. Plus, earn up to ", + "spend_and_earn_description_apy": "{{apy}}% APY", + "spend_and_earn_description_suffix": " (variable) on your balance.", + "spend_and_earn_description_no_apy": "Link your balance to your card and get mUSD back on purchases.", + "spend_and_earn_cta": "Link card" }, "cashback_screen": { "title": "mUSD Back", diff --git a/package.json b/package.json index b358e4b15291..86588bd19624 100644 --- a/package.json +++ b/package.json @@ -213,7 +213,6 @@ "@metamask/messenger": "^1.2.0", "@metamask/keyring-internal-api": "^11.0.1", "@metamask/accounts-controller": "^38.0.0", - "@metamask/transaction-controller@^63.0.0": "^65.0.0", "@metamask/keyring-api@npm:^21.3.0": "23.1.0", "@metamask/keyring-api@npm:^21.4.0": "23.1.0", "@metamask/keyring-api@npm:^21.6.0": "23.1.0", @@ -221,7 +220,8 @@ "@metamask/bridge-status-controller@npm:^71.0.0": "patch:@metamask/bridge-status-controller@npm%3A71.1.0#~/.yarn/patches/@metamask-bridge-status-controller-npm-71.1.0-6140a0bdf3.patch", "react-native-quick-base64@npm:^2.0.5": "patch:react-native-quick-base64@npm%3A2.2.0#~/.yarn/patches/react-native-quick-base64-npm-2.2.0-9083eb316a.patch", "@metamask/permission-controller": "^13.1.1", - "react-native-ble-plx@npm:3.4.0": "patch:react-native-ble-plx@npm%3A3.4.0#~/.yarn/patches/react-native-ble-plx-npm-3.4.0-401e8b3343.patch" + "react-native-ble-plx@npm:3.4.0": "patch:react-native-ble-plx@npm%3A3.4.0#~/.yarn/patches/react-native-ble-plx-npm-3.4.0-401e8b3343.patch", + "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A66.0.0#~/.yarn/patches/@metamask-transaction-controller-npm-66.0.0-23fe4d7dfe.patch" }, "dependencies": { "@braze/react-native-sdk": "patch:@braze/react-native-sdk@npm%3A19.1.0#~/.yarn/patches/@braze-react-native-sdk-npm-19.1.0-076-reactmoduleinfo.patch", @@ -352,7 +352,7 @@ "@metamask/storage-service": "^1.0.0", "@metamask/superstruct": "^3.2.1", "@metamask/swappable-obj-proxy": "^2.1.0", - "@metamask/transaction-controller": "^65.4.0", + "@metamask/transaction-controller": "^66.0.0", "@metamask/transaction-pay-controller": "^22.7.0", "@metamask/tron-wallet-snap": "^1.25.6", "@metamask/utils": "^11.11.0", diff --git a/tests/component-view/presets/trending.ts b/tests/component-view/presets/trending.ts index 1b8c7254384b..985ea62cab1e 100644 --- a/tests/component-view/presets/trending.ts +++ b/tests/component-view/presets/trending.ts @@ -30,10 +30,6 @@ export const initialStateTrending = (options?: InitialStateTrendingOptions) => { featureVersion: '1.0.0', minimumVersion: '0.0.1', }, - exploreSearchV2: { - enabled: true, - minimumVersion: '7.79.0', - }, }) .withOverrides({ browser: { diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index d92e86f4fd5b..7cc9305f3408 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -3368,17 +3368,6 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, - moneyHomeScreenEnabled: { - name: 'moneyHomeScreenEnabled', - type: FeatureFlagType.Remote, - inProd: true, - productionDefault: { - minimumVersion: '0.0.0', - enabled: false, - }, - status: FeatureFlagStatus.Active, - }, - nonZeroUnusedApprovals: { name: 'nonZeroUnusedApprovals', type: FeatureFlagType.Remote, @@ -4759,17 +4748,6 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, - exploreSearchV2: { - name: 'exploreSearchV2', - type: FeatureFlagType.Remote, - inProd: true, - productionDefault: { - minimumVersion: '7.79.0', - enabled: false, - }, - status: FeatureFlagStatus.Active, - }, - assetsASSETS3205AbtestAmbientPriceColor: { name: 'assetsASSETS3205AbtestAmbientPriceColor', type: FeatureFlagType.Remote, diff --git a/tests/locators/Trending/TrendingView.selectors.ts b/tests/locators/Trending/TrendingView.selectors.ts index 7bb1ec6413e6..9ab6f2c651fb 100644 --- a/tests/locators/Trending/TrendingView.selectors.ts +++ b/tests/locators/Trending/TrendingView.selectors.ts @@ -1,4 +1,5 @@ import { TrendingViewSelectorsIDs as AppTrendingViewSelectorsIDs } from '../../../app/components/Views/TrendingView/TrendingView.testIds'; +import { ExploreSearchScreenSelectorsIDs } from '../../../app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.testIds'; import { CommonSelectorsIDs } from '../../../app/util/Common.testIds'; export const TrendingViewSelectorsIDs = { @@ -7,11 +8,12 @@ export const TrendingViewSelectorsIDs = { SEARCH_INPUT: 'explore-view-search-input', SEARCH_TEXT_INPUT: 'explore-view-search-text-input', SEARCH_CANCEL_BUTTON: 'explore-search-cancel-button', + SEARCH_PILL_ALL: ExploreSearchScreenSelectorsIDs.PILL_ALL, + SEARCH_PILL_CRYPTOS: ExploreSearchScreenSelectorsIDs.PILL_CRYPTOS, TOKEN_ROW_ITEM_PREFIX: 'trending-token-row-item-', PERPS_ROW_ITEM_PREFIX: 'perps-market-tile-card-', PREDICTIONS_ROW_ITEM_PREFIX: 'predict-market-row-item-', SITE_ROW_ITEM_PREFIX: 'site-row-item-', - SEARCH_FOOTER_SEARCH_LINK: 'trending-search-footer-search-link', NOW_SCROLL_VIEW: AppTrendingViewSelectorsIDs.EXPLORE_NOW_SCROLL_VIEW, RWAS_SCROLL_VIEW: AppTrendingViewSelectorsIDs.EXPLORE_RWAS_SCROLL_VIEW, CRYPTO_SCROLL_VIEW: AppTrendingViewSelectorsIDs.EXPLORE_CRYPTO_SCROLL_VIEW, diff --git a/tests/page-objects/Trending/TrendingView.ts b/tests/page-objects/Trending/TrendingView.ts index 1d76b592c392..48877e9a7987 100644 --- a/tests/page-objects/Trending/TrendingView.ts +++ b/tests/page-objects/Trending/TrendingView.ts @@ -40,9 +40,19 @@ class TrendingView { ); } - get searchEngineButton(): DetoxElement { + get searchAllPill(): DetoxElement { + return Matchers.getElementByID(TrendingViewSelectorsIDs.SEARCH_PILL_ALL); + } + + get searchCryptosPill(): DetoxElement { + return Matchers.getElementByID( + TrendingViewSelectorsIDs.SEARCH_PILL_CRYPTOS, + ); + } + + get searchResultsList(): DetoxElement { return Matchers.getElementByID( - TrendingViewSelectorsIDs.SEARCH_FOOTER_SEARCH_LINK, + TrendingViewSelectorsIDs.SEARCH_RESULTS_LIST, ); } @@ -450,24 +460,22 @@ class TrendingView { ); } - /** - * Scroll down in search results to ensure the search engine option is visible - */ - async scrollToSearchEngineOption(): Promise { - await Gestures.scrollToElement( - this.searchEngineButton, - Matchers.getIdentifier(TrendingViewSelectorsIDs.SEARCH_RESULTS_LIST), - { - direction: 'down', - scrollAmount: 300, - elemDescription: 'Scroll to search engine option', - }, - ); + async verifySearchPillsVisible(): Promise { + await Assertions.expectElementToBeVisible(this.searchAllPill, { + description: 'All search pill should be visible', + timeout: 10000, + }); + + await Assertions.expectElementToBeVisible(this.searchCryptosPill, { + description: 'Crypto search pill should be visible', + timeout: 10000, + }); } - async verifySearchEngineOptionVisible(): Promise { - await Assertions.expectElementToBeVisible(this.searchEngineButton, { - description: 'Search engine option should be visible', + async verifySearchResultsListVisible(): Promise { + await Assertions.expectElementToBeVisible(this.searchResultsList, { + description: 'Search results list should be visible', + timeout: 10000, }); } diff --git a/tests/smoke/trending/trending-search.spec.ts b/tests/smoke/trending/trending-search.spec.ts index ee33fede077c..c928db0f07a3 100644 --- a/tests/smoke/trending/trending-search.spec.ts +++ b/tests/smoke/trending/trending-search.spec.ts @@ -60,11 +60,10 @@ describe(SmokeWalletPlatform('Trending Search Smoke Test'), () => { // 6. Type a query await TrendingView.typeSearchQuery('test'); - // 6.5. Scroll down to ensure Search Engine Option is visible - await TrendingView.scrollToSearchEngineOption(); + // 7. Verify pill row and aggregated results are visible + await TrendingView.verifySearchPillsVisible(); - // 7. Verify Search Engine Option is visible - await TrendingView.verifySearchEngineOptionVisible(); + await TrendingView.verifySearchResultsListVisible(); // 8. Verify Cancel button is visible await Assertions.expectElementToBeVisible( diff --git a/yarn.lock b/yarn.lock index ef52264ca56e..8148c323f21f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7501,15 +7501,15 @@ __metadata: languageName: node linkType: hard -"@ledgerhq/devices@npm:8.14.0, @ledgerhq/devices@npm:^8.4.5": - version: 8.14.0 - resolution: "@ledgerhq/devices@npm:8.14.0" +"@ledgerhq/devices@npm:8.14.2, @ledgerhq/devices@npm:^8.4.5": + version: 8.14.2 + resolution: "@ledgerhq/devices@npm:8.14.2" dependencies: - "@ledgerhq/errors": "npm:^6.33.0" + "@ledgerhq/errors": "npm:^6.34.1" "@ledgerhq/logs": "npm:^6.17.0" rxjs: "npm:7.8.2" semver: "npm:7.7.3" - checksum: 10/8ae8e44e44ed4b6eca1ac626bdced01a753217ffc10dd2d4afa00f26bfd6a3efd26e7f1a86fede8e63646776e34d31c8b458b247e52a0a45ea675670040bd61c + checksum: 10/c81f9b327874603126c0cd6342179e0b978262e5e6938a6363acaae4ed6f7f738d009ca4f000999fdf8263a13266085606dca5251d8ec22ec182dbba359fc69d languageName: node linkType: hard @@ -7540,10 +7540,10 @@ __metadata: languageName: node linkType: hard -"@ledgerhq/errors@npm:^6.19.1, @ledgerhq/errors@npm:^6.21.0, @ledgerhq/errors@npm:^6.29.0, @ledgerhq/errors@npm:^6.33.0": - version: 6.33.0 - resolution: "@ledgerhq/errors@npm:6.33.0" - checksum: 10/129b8d1d571c9c09a9ee131fdd07880ac06ebb2a3d718ba48e9653fe14839eb3c6876e6809cf2f5737efad53b4ffd691b5d6425e059b275c5db9ec0d3f677112 +"@ledgerhq/errors@npm:^6.19.1, @ledgerhq/errors@npm:^6.21.0, @ledgerhq/errors@npm:^6.29.0, @ledgerhq/errors@npm:^6.34.1": + version: 6.35.0 + resolution: "@ledgerhq/errors@npm:6.35.0" + checksum: 10/1bbc4a314d22aca480a5433c9d4b5aded9907c86073ac8678c3f6bb50b6627973f12a5f93040993bf313aed8e8db72ce67758f5ae7b51b5302d8a05cf8055a15 languageName: node linkType: hard @@ -7619,14 +7619,14 @@ __metadata: linkType: hard "@ledgerhq/hw-transport@npm:^6.31.3, @ledgerhq/hw-transport@npm:^6.31.4, @ledgerhq/hw-transport@npm:^6.31.5, @ledgerhq/hw-transport@npm:^6.31.6": - version: 6.35.0 - resolution: "@ledgerhq/hw-transport@npm:6.35.0" + version: 6.35.2 + resolution: "@ledgerhq/hw-transport@npm:6.35.2" dependencies: - "@ledgerhq/devices": "npm:8.14.0" - "@ledgerhq/errors": "npm:^6.33.0" + "@ledgerhq/devices": "npm:8.14.2" + "@ledgerhq/errors": "npm:^6.34.1" "@ledgerhq/logs": "npm:^6.17.0" events: "npm:^3.3.0" - checksum: 10/099a7058486e33b42542f89241f823659f692038d5d3530cbca0f273d1fa81d1d0896fc2cf163371d4fd6e540b1233dcb3e030757fb0f0877eeca4e69f8f5393 + checksum: 10/601e745510051230c33b0ec745f647b042b145813070dd69a61264b443708b4ea680271d1693b6bebac18de2debf99395fbad777c7906b8952f64d5e5284ac4d languageName: node linkType: hard @@ -7866,19 +7866,6 @@ __metadata: languageName: node linkType: hard -"@metamask/approval-controller@npm:^8.0.0": - version: 8.0.0 - resolution: "@metamask/approval-controller@npm:8.0.0" - dependencies: - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/utils": "npm:^11.8.1" - nanoid: "npm:^3.3.8" - checksum: 10/356fa411f2b077a31ea7565ffafaa4ecd68100ed93c26027ef4c30c55f7bf49a9f76a819bee05925756b0d4890d01a0ea0983b3b57bab3bf5b9ec2336f1a40e9 - languageName: node - linkType: hard - "@metamask/approval-controller@npm:^9.0.0, @metamask/approval-controller@npm:^9.0.1": version: 9.0.1 resolution: "@metamask/approval-controller@npm:9.0.1" @@ -8335,7 +8322,7 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^11.14.1, @metamask/controller-utils@npm:^11.15.0, @metamask/controller-utils@npm:^11.19.0, @metamask/controller-utils@npm:^11.20.0": +"@metamask/controller-utils@npm:^11.14.1, @metamask/controller-utils@npm:^11.19.0, @metamask/controller-utils@npm:^11.20.0": version: 11.20.0 resolution: "@metamask/controller-utils@npm:11.20.0" dependencies: @@ -8377,7 +8364,7 @@ __metadata: languageName: node linkType: hard -"@metamask/core-backend@npm:^6.1.1, @metamask/core-backend@npm:^6.2.1, @metamask/core-backend@npm:^6.2.2, @metamask/core-backend@npm:^6.3.0": +"@metamask/core-backend@npm:^6.2.2, @metamask/core-backend@npm:^6.3.0": version: 6.3.0 resolution: "@metamask/core-backend@npm:6.3.0" dependencies: @@ -8929,7 +8916,7 @@ __metadata: languageName: node linkType: hard -"@metamask/gas-fee-controller@npm:^26.0.3, @metamask/gas-fee-controller@npm:^26.1.0, @metamask/gas-fee-controller@npm:^26.1.1, @metamask/gas-fee-controller@npm:^26.2.1, @metamask/gas-fee-controller@npm:^26.2.2": +"@metamask/gas-fee-controller@npm:^26.1.0, @metamask/gas-fee-controller@npm:^26.2.1, @metamask/gas-fee-controller@npm:^26.2.2": version: 26.2.2 resolution: "@metamask/gas-fee-controller@npm:26.2.2" dependencies: @@ -9934,7 +9921,7 @@ __metadata: languageName: node linkType: hard -"@metamask/remote-feature-flag-controller@npm:^4.1.0, @metamask/remote-feature-flag-controller@npm:^4.2.0, @metamask/remote-feature-flag-controller@npm:^4.2.1": +"@metamask/remote-feature-flag-controller@npm:^4.2.0, @metamask/remote-feature-flag-controller@npm:^4.2.1": version: 4.2.1 resolution: "@metamask/remote-feature-flag-controller@npm:4.2.1" dependencies: @@ -10449,124 +10436,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^61.0.0": - version: 61.3.0 - resolution: "@metamask/transaction-controller@npm:61.3.0" - dependencies: - "@ethereumjs/common": "npm:^4.4.0" - "@ethereumjs/tx": "npm:^5.4.0" - "@ethereumjs/util": "npm:^9.1.0" - "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" - "@ethersproject/providers": "npm:^5.7.0" - "@ethersproject/wallet": "npm:^5.7.0" - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.15.0" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/nonce-tracker": "npm:^6.0.0" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/utils": "npm:^11.8.1" - async-mutex: "npm:^0.5.0" - bignumber.js: "npm:^9.1.2" - bn.js: "npm:^5.2.1" - eth-method-registry: "npm:^4.0.0" - fast-json-patch: "npm:^3.1.1" - lodash: "npm:^4.17.21" - uuid: "npm:^8.3.2" - peerDependencies: - "@babel/runtime": ^7.0.0 - "@metamask/accounts-controller": ^34.0.0 - "@metamask/approval-controller": ^8.0.0 - "@metamask/eth-block-tracker": ">=9" - "@metamask/gas-fee-controller": ^25.0.0 - "@metamask/network-controller": ^25.0.0 - "@metamask/remote-feature-flag-controller": ^2.0.0 - checksum: 10/99bbf130b33d0c8f241d2d79006281a2ddc5910ee0b0d6efa806339533ed191430013c1eb29de082b89f002ee0cfd33626b39fbd9cff77a5fe79731e258f47d3 - languageName: node - linkType: hard - -"@metamask/transaction-controller@npm:^62.22.0": - version: 62.22.0 - resolution: "@metamask/transaction-controller@npm:62.22.0" - dependencies: - "@ethereumjs/common": "npm:^4.4.0" - "@ethereumjs/tx": "npm:^5.4.0" - "@ethereumjs/util": "npm:^9.1.0" - "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" - "@ethersproject/providers": "npm:^5.7.0" - "@ethersproject/wallet": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^37.0.0" - "@metamask/approval-controller": "npm:^8.0.0" - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/core-backend": "npm:^6.1.1" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/gas-fee-controller": "npm:^26.0.3" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^30.0.0" - "@metamask/nonce-tracker": "npm:^6.0.0" - "@metamask/remote-feature-flag-controller": "npm:^4.1.0" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/utils": "npm:^11.9.0" - async-mutex: "npm:^0.5.0" - bignumber.js: "npm:^9.1.2" - bn.js: "npm:^5.2.1" - eth-method-registry: "npm:^4.0.0" - fast-json-patch: "npm:^3.1.1" - lodash: "npm:^4.17.21" - uuid: "npm:^8.3.2" - peerDependencies: - "@babel/runtime": ^7.0.0 - "@metamask/eth-block-tracker": ">=9" - checksum: 10/84d7fffb169bcb7b97844339f167972161f1f3ea14b396f6888e709269d4e126a4a896fcc526a32980a624b934a5afbf5e482a8263cf3be692d9ba6e159dca29 - languageName: node - linkType: hard - -"@metamask/transaction-controller@npm:^64.2.0": - version: 64.4.0 - resolution: "@metamask/transaction-controller@npm:64.4.0" - dependencies: - "@ethereumjs/common": "npm:^4.4.0" - "@ethereumjs/tx": "npm:^5.4.0" - "@ethereumjs/util": "npm:^9.1.0" - "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" - "@ethersproject/providers": "npm:^5.7.0" - "@ethersproject/wallet": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^37.2.0" - "@metamask/approval-controller": "npm:^9.0.1" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^11.20.0" - "@metamask/core-backend": "npm:^6.2.1" - "@metamask/gas-fee-controller": "npm:^26.1.1" - "@metamask/messenger": "npm:^1.1.1" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/nonce-tracker": "npm:^6.0.0" - "@metamask/remote-feature-flag-controller": "npm:^4.2.0" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/utils": "npm:^11.9.0" - async-mutex: "npm:^0.5.0" - bignumber.js: "npm:^9.1.2" - bn.js: "npm:^5.2.1" - eth-method-registry: "npm:^4.0.0" - fast-json-patch: "npm:^3.1.1" - lodash: "npm:^4.17.21" - uuid: "npm:^8.3.2" - peerDependencies: - "@babel/runtime": ^7.0.0 - "@metamask/eth-block-tracker": ">=9" - checksum: 10/3e9bc687cb0858d6034d38de9be23d1659a42af89658480b223c2da216dc586050efe55383ce949abc93797d5c792200e4418f3ec738f0788a531b8fa90b466f - languageName: node - linkType: hard - -"@metamask/transaction-controller@npm:^65.3.0, @metamask/transaction-controller@npm:^65.4.0": - version: 65.4.0 - resolution: "@metamask/transaction-controller@npm:65.4.0" +"@metamask/transaction-controller@npm:66.0.0": + version: 66.0.0 + resolution: "@metamask/transaction-controller@npm:66.0.0" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -10579,8 +10451,8 @@ __metadata: "@metamask/approval-controller": "npm:^9.0.1" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.1.0" - "@metamask/core-backend": "npm:^6.2.2" - "@metamask/gas-fee-controller": "npm:^26.2.1" + "@metamask/core-backend": "npm:^6.3.0" + "@metamask/gas-fee-controller": "npm:^26.2.2" "@metamask/messenger": "npm:^1.2.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^32.0.0" @@ -10598,13 +10470,13 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/e9273b3dec6837dd33a7ed40fa2569794d71f6580691f40950e94ade1ca4e1d9f31178070218398935068f8193cafb06ba4769c77483286c989a42c2c72232fd + checksum: 10/3b8a6606dd4b5005818764eb193dd4cf9f77c5fb7b1295cb5f049413adedced4f22d3fba7653bc23519195946d8f111775993ae12d3965cbef5cca12fad2e97d languageName: node linkType: hard -"@metamask/transaction-controller@npm:^66.0.0": +"@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A66.0.0#~/.yarn/patches/@metamask-transaction-controller-npm-66.0.0-23fe4d7dfe.patch": version: 66.0.0 - resolution: "@metamask/transaction-controller@npm:66.0.0" + resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A66.0.0#~/.yarn/patches/@metamask-transaction-controller-npm-66.0.0-23fe4d7dfe.patch::version=66.0.0&hash=1bffbe" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -10636,7 +10508,7 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/3b8a6606dd4b5005818764eb193dd4cf9f77c5fb7b1295cb5f049413adedced4f22d3fba7653bc23519195946d8f111775993ae12d3965cbef5cca12fad2e97d + checksum: 10/ef278009e2ea066f7afb415c659b8b66b22ce2bb42af01a1e87d49d69441fc758b6b32d70b2cd09f880cdc592e404f51fa1c82fd31aa8f79518480ae1c9d7b83 languageName: node linkType: hard @@ -10670,9 +10542,9 @@ __metadata: linkType: hard "@metamask/tron-wallet-snap@npm:^1.25.6": - version: 1.25.6 - resolution: "@metamask/tron-wallet-snap@npm:1.25.6" - checksum: 10/8701c9cdcaa13d183f359963be9696b19151e723bfa682e76bbb03517f435ccdaa152e8e698ca4c18ab884f1e07463f91976fd4ec08f296f8496176e0a97d0dc + version: 1.25.8 + resolution: "@metamask/tron-wallet-snap@npm:1.25.8" + checksum: 10/c533b566360b1587865b2e8f74125a301c348e6baa5a721e5629080b03fc47067b0275bec26183863ab5575a812bac8946c4fa1ac5c3daa645faec16b7ab3368 languageName: node linkType: hard @@ -29825,7 +29697,7 @@ __metadata: languageName: node linkType: hard -"fast-xml-builder@npm:^1.1.7": +"fast-xml-builder@npm:^1.2.0": version: 1.2.0 resolution: "fast-xml-builder@npm:1.2.0" dependencies: @@ -29847,16 +29719,17 @@ __metadata: linkType: hard "fast-xml-parser@npm:^5.3.3, fast-xml-parser@npm:^5.5.6, fast-xml-parser@npm:^5.7.2": - version: 5.7.3 - resolution: "fast-xml-parser@npm:5.7.3" + version: 5.8.0 + resolution: "fast-xml-parser@npm:5.8.0" dependencies: "@nodable/entities": "npm:^2.1.0" - fast-xml-builder: "npm:^1.1.7" + fast-xml-builder: "npm:^1.2.0" path-expression-matcher: "npm:^1.5.0" - strnum: "npm:^2.2.3" + strnum: "npm:^2.3.0" + xml-naming: "npm:^0.1.0" bin: fxparser: src/cli/cli.js - checksum: 10/00a58655d0d58c1f914c7fd8e3a94e88799c3d473e29a6d2231dc02103df069e8c6043137cbec8df1cda6525a39914d1b84455a79530f63be266876a2211251c + checksum: 10/0167d17d5275c95e005639f8fca7b4d88fec3fd013063725280f4e982313b1c798e4565d5ced7f61ce10e8f0d876a1976492cc8ac27da3080915ff549fd00705 languageName: node linkType: hard @@ -35663,7 +35536,7 @@ __metadata: "@metamask/test-dapp": "npm:9.5.0" "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" - "@metamask/transaction-controller": "npm:^65.4.0" + "@metamask/transaction-controller": "npm:^66.0.0" "@metamask/transaction-pay-controller": "npm:^22.7.0" "@metamask/tron-wallet-snap": "npm:^1.25.6" "@metamask/utils": "npm:^11.11.0" @@ -44407,7 +44280,7 @@ __metadata: languageName: node linkType: hard -"strnum@npm:^2.2.3": +"strnum@npm:^2.3.0": version: 2.3.0 resolution: "strnum@npm:2.3.0" checksum: 10/ce79c86bb2b96f053eb28e14924c13604e22977dcdece9aa914c25e16cc5c4bbe048976fe0b2a4decf08a1e13600b820749cea25463fc0e5fee3078339e0a457