From 420c835ca6c0bbe12128fcfde768fa120b766192 Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Fri, 29 May 2026 14:24:00 +0200 Subject: [PATCH 01/10] chore: remove inconsistencies on token import flow (#30791) ## **Description** - Reused a shared `AddAssetTokenRow` across import search results and confirmation. - Aligned token row icon, badge, title, subtitle, spacing, and skeleton sizing. - Updated the import search field to use the shared `TextFieldSearch` pattern. - Added loading/disabled feedback for the confirmation import action. - Added tests for import loading, duplicate press prevention, failure handling, and search behavior. - Kept Explore search bar unchanged. ## **Changelog** CHANGELOG entry: remove inconsistencies on token import flow ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3294 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > UI and UX changes in the add-token flow with no auth, security, or wallet-core logic changes; import behavior is guarded with loading and error recovery. > > **Overview** > Unifies how tokens look in the **add/import** flow and hardens the confirm step when users tap **Import**. > > A new **`AddAssetTokenRow`** component is shared by **search results** and **confirm import**, so avatar, network badge, name, and symbol use the same layout and typography (including **`BadgeNetwork`**). Search uses the design-system **`TextFieldSearch`** instead of a custom search bar; list rows and loading skeletons are aligned to the same **`h-16`** row spacing. **`SearchTokenResults`** no longer takes a **`chainId`** prop (network context comes from each asset). > > **`ConfirmAddAsset`** treats **`addTokenList`** as async: import shows **loading**, **disables** Cancel/Import to block double-taps, **logs** failures and re-enables buttons without navigating away on error. **`AddAsset`** inlines the network bottom sheet without a memoized render callback. > > Tests cover in-flight import, duplicate presses, and failed import recovery. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 99573ec8d9d48072d4ee28e6db588a5b96ecab31. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/components/Views/AddAsset/AddAsset.tsx | 28 ++-- .../ConfirmAddAsset.test.tsx | 89 +++++++++++- .../ConfirmAddTokenView/ConfirmAddAsset.tsx | 128 +++++++----------- .../AddAssetTokenRow/AddAssetTokenRow.tsx | 65 +++++++++ .../SearchTokenAutocomplete.tsx | 60 ++------ .../SearchTokenResults.test.tsx | 1 - .../SearchTokenResults/SearchTokenResults.tsx | 77 ++++------- 7 files changed, 257 insertions(+), 191 deletions(-) create mode 100644 app/components/Views/AddAsset/components/AddAssetTokenRow/AddAssetTokenRow.tsx 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} - + ); }} From e1dce2ce553fd376de1ff11f9c6703ae2b2c0ca4 Mon Sep 17 00:00:00 2001 From: cmd-ob Date: Fri, 29 May 2026 14:41:22 +0100 Subject: [PATCH 02/10] chore: raise smart-e2e-selection confidence threshold from 80% to 85% (#30749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Raises the AI confidence gate used by smart E2E selection from **80%** to **85%**. At the higher threshold the smart-selection path is more conservative: the AI must be more confident before its narrowed tag list is trusted. PRs where the AI confidence falls between 80–84% will now fall back to running the full E2E suite instead of the AI-selected subset. **Files changed:** - `.github/workflows/ci.yml` — four `>= 80` threshold expressions updated to `>= 85` (Android/iOS build-skip checks and selected-tags expressions) - `.github/guidelines/E2E_DECISION_TREE.md` — Mermaid diagram node updated to `Confidence >= 85%` ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** CI-only change — no manual testing steps required. ## **Screenshots/Recordings** N/A — CI configuration change only. ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.
Open in Web Open in Cursor 
Co-authored-by: Cursor Agent Co-authored-by: cmd-ob --- .github/guidelines/E2E_DECISION_TREE.md | 2 +- .github/workflows/ci.yml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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 }} From d577cfcdbcf8feacdce01d2edb86f0ca984b44ef Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Fri, 29 May 2026 09:59:47 -0400 Subject: [PATCH 03/10] feat: MUSD-864 consolidate money feature flags (#30772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** We had two flags gating Money surfaces (`moneyHomeScreenEnabled` and `moneyEnableMoneyAccount`). This was needed for independent development of features. We can simply rely `moneyEnableMoneyAccount` now. This PR drops `moneyHomeScreenEnabled` and routes every Money UI gate through `moneyEnableMoneyAccount`. This also removes the `featureDisabled` branch of `MoneyBalanceDisplayState` since this it's now unreachable. ## **Changelog** CHANGELOG entry: update moneyHomeScreenEnabled UI gating to use moneyEnableMoneyAccount; remove featureDisabled MoneyBalanceDisplayState code paths since they are now unreachable ## **Related issues** - Fixes: [MUSD-864](https://consensyssoftware.atlassian.net/browse/MUSD-864) ## **Manual testing steps** ```gherkin Feature: Money account feature flag Scenario: flag off hides Money surfaces Given Money account is disabled When the user opens the app Then the Activity tab is shown in the bottom navbar And the Money section is rendered instead of the MoneyBalanceCard on the Wallet home screen Scenario: flag on shows Money surfaces Given Money account is enabled When the user opens the app Then the Money tab replaces Activity in bottom navbar And the MoneyBalanceCard renders on the Wallet home screen ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [MUSD-864]: https://consensyssoftware.atlassian.net/browse/MUSD-864?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Medium Risk** > Wide but mechanical flag swap across tabs, wallet, and cash navigation; behavior changes if remote flags were previously mismatched, with no auth or payment logic touched. > > **Overview** > This PR **consolidates Money UI gating** onto a single remote flag, **`moneyEnableMoneyAccount`**, and removes the parallel **`moneyHomeScreenEnabled`** path (selector, registry entry, and `MM_MONEY_HOME_SCREEN_ENABLED` from `.js.env.example`). > > **Navigation and home surfaces** now read `selectMoneyEnableMoneyAccountFlag` instead of the home-screen flag: the bottom tab swaps Activity for Money when enabled, `MainNavigator` registers Money stacks conditionally, **Wallet** shows `MoneyBalanceCard` and the activity shortcut, **Activity** back/hardware behavior routes to home tabs when Money is on, and the homepage **Cash** section / `useCashNavigation` hide or deep-link to Money accordingly. > > **In-app Money balance UI** drops the **`featureDisabled`** branch from `MoneyBalanceDisplayState` and related components (`MoneyHomeView`, `MoneyBalanceCard`, `MoneyBalanceSummary`), test IDs, copy, and precedence docs—on the assumption users only reach these screens when the account flag is on. **`isMoneyAccountEnabled`** no longer falls back to `MM_MONEY_ENABLE_MONEY_ACCOUNT`; undefined remote evaluates to **false**. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8b0d2c0b8830622b16c0e55da54d0d916ad9a95c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .js.env.example | 3 +- app/components/Nav/Main/MainNavigator.js | 14 ++--- .../Nav/Main/MainNavigator.test.tsx | 14 ++--- .../MoneyHomeView/MoneyHomeView.test.tsx | 47 +-------------- .../Views/MoneyHomeView/MoneyHomeView.tsx | 7 +-- .../MoneyBalanceCard.test.tsx | 34 ----------- .../MoneyBalanceCard.testIds.ts | 1 - .../MoneyBalanceCard/MoneyBalanceCard.tsx | 38 +++--------- .../MoneyBalanceSummary.test.tsx | 43 ------------- .../MoneyBalanceSummary.testIds.ts | 1 - .../MoneyBalanceSummary.tsx | 11 ---- .../Money/hooks/useMoneyAccountInfo.test.ts | 1 - .../UI/Money/selectors/featureFlags.test.ts | 60 ------------------- .../UI/Money/selectors/featureFlags.ts | 11 ---- app/components/UI/Money/types.ts | 3 +- app/components/Views/ActivityView/index.js | 16 +++-- .../Views/ActivityView/index.test.tsx | 26 ++++---- .../Sections/Cash/CashSection.test.tsx | 12 ++-- .../Homepage/Sections/Cash/CashSection.tsx | 10 ++-- .../Sections/Cash/MusdAggregatedRow.test.tsx | 20 +++---- .../Sections/Cash/useCashNavigation.test.ts | 30 +++++----- .../Sections/Cash/useCashNavigation.ts | 10 ++-- .../Views/Settings/DeveloperOptions/index.tsx | 6 +- app/components/Views/Wallet/index.test.tsx | 16 ++--- app/components/Views/Wallet/index.tsx | 12 ++-- app/lib/Money/feature-flags.test.ts | 16 +---- app/lib/Money/feature-flags.ts | 3 +- locales/languages/en.json | 1 - tests/feature-flags/feature-flag-registry.ts | 11 ---- 29 files changed, 106 insertions(+), 371 deletions(-) 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/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index a989439d17f3..a456418eaa94 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -109,7 +109,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 +647,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 +880,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 +1252,7 @@ const MainNavigator = () => { presentation: 'transparentModal', }} /> - {isMoneyHomeScreenEnabled && ( + {isMoneyAccountEnabled && ( <> ({ 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/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx index 4245c2fdf6d5..a39da5ff64cb 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx @@ -255,7 +255,6 @@ describe('MoneyHomeView', () => { } as unknown as ReturnType); mockUseMoneyAccountInfo.mockReturnValue({ - isMoneyAccountFeatureEnabled: true, hasMoneyAccount: true, primaryMoneyAccount: { address: '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', @@ -435,32 +434,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 +453,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 +529,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: [], diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx index d1c489ab1653..6ea666684a45 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx @@ -88,8 +88,7 @@ const MoneyHomeView = () => { } }, [refetchBalance]); - const { isMoneyAccountFeatureEnabled, hasMoneyAccount } = - useMoneyAccountInfo(); + const { hasMoneyAccount } = useMoneyAccountInfo(); const { fiatBalanceAggregatedFormatted: musdFiatFormatted } = useMusdBalance(); @@ -113,9 +112,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' }; 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 ( ({ 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/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/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/Settings/DeveloperOptions/index.tsx b/app/components/Views/Settings/DeveloperOptions/index.tsx index 9562ca8e23d4..5d1e7a0852ff 100644 --- a/app/components/Views/Settings/DeveloperOptions/index.tsx +++ b/app/components/Views/Settings/DeveloperOptions/index.tsx @@ -22,7 +22,7 @@ import { ConfirmationsDeveloperOptions } from '../../confirmations/components/de import { selectIsMusdConversionFlowEnabledFlag } from '../../../UI/Earn/selectors/featureFlags'; import { MusdDeveloperOptionsSection } from '../../../UI/Earn/components/MusdDeveloperOptionsSection'; import { CardDeveloperOptionsSection } from '../../../UI/Card/components/CardDeveloperOptionsSection'; -import { selectMoneyHomeScreenEnabledFlag } from '../../../UI/Money/selectors/featureFlags'; +import { selectMoneyEnableMoneyAccountFlag } from '../../../UI/Money/selectors/featureFlags'; import { MoneyUiDeveloperOptionsSection } from '../../../UI/Money/components/MoneyUiDeveloperOptionsSection'; import NotificationsDeveloperOptionsSection from '../../../UI/Notification/DeveloperOptionsSection/NotificationsDeveloperOptionsSection'; @@ -39,7 +39,7 @@ const DeveloperOptions = () => { 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/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 && ( { 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/locales/languages/en.json b/locales/languages/en.json index a3a4899c76fc..3bbfe82a1443 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": { diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index d92e86f4fd5b..4bb774d3aae7 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, From dffcf7c1e7501bb9cc84a9e885867c29031b7960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Fri, 29 May 2026 16:03:07 +0200 Subject: [PATCH 04/10] refactor(Predict): remove temporary BTC up/down row flag and update imports cp-7.80.0 (#30754) ## **Description** Enables the live BTC 5-minute up/down row in the Predict homepage discovery section (HomepagePredictWorldCupDiscovery). The row was previously gated behind a temporary SHOW_BTC_UP_DOWN_5M_ROW kill switch while waiting on the shared useCurrentCryptoUpDownMarketData hook. That hook is now wired up, so the row shows live BTC spot price, price-to-beat, and a countdown. Tapping the row opens the active BTC market details when available; otherwise it falls back to the crypto category market list. **Why:** Surface live crypto up/down markets on the homepage discovery treatment and remove dead placeholder/TODO wiring. **Changes:** - Remove SHOW_BTC_UP_DOWN_5M_ROW from btcUpDown5mSeries.ts - Wire useCurrentCryptoUpDownMarketData + usePredictNavigation in HomepagePredictWorldCupDiscovery - Always render BtcLiveRow (no longer conditional on kill switch) - Navigate to live market on row tap when btcMarketId is available ## **Changelog** CHANGELOG entry: Added a live BTC up/down row to the Predict homepage discovery section with real-time price, price-to-beat, and countdown. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Predict homepage discovery BTC live row Scenario: BTC row displays live data when Predict is enabled Given Predict is enabled And the user is in the homepage discovery treatment (world cup discovery layout) When the user views the Predict section on the homepage Then the BTC live row is visible And it shows BTC spot price, price-to-beat, and a live countdown Scenario: Tapping BTC row opens the active market Given Predict is enabled And a live BTC 5-minute up/down market is available When the user taps the BTC live row Then the app navigates to that market's details screen And the entry point is HOME_SECTION Scenario: Tapping BTC row falls back when no live market Given Predict is enabled And no live BTC market is available When the user taps the BTC live row Then the app navigates to the Predict crypto category market list ``` ## **Screenshots/Recordings** ### **Before** Screenshot 2026-05-28 at 13 26 58 ### **After** Screenshot 2026-05-28 at 17 41 35 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > User-visible homepage Predict navigation and live market data depend on external feeds; misconfiguration could send users to the wrong market or show stale prices, but scope is limited to discovery UI. > > **Overview** > Removes the temporary **`SHOW_BTC_UP_DOWN_5M_ROW`** kill switch and turns on the **BTC 5-minute up/down** discovery row on the Predict homepage. > > **`HomepagePredictWorldCupDiscovery`** now loads live window data via **`useCurrentCryptoUpDownMarketData`** (series **`BTC_UP_OR_DOWN_5M_SERIES`**, gated by **`selectPredictEnabledFlag`**) and always renders **`BtcLiveRow`** with spot price, price-to-beat, and countdown. Tapping the row opens the active market through **`navigateToMarketDetails`** when **`btcMarketId`** exists (including **`transactionActiveAbTests`** when present); otherwise it still navigates to the crypto market list. Placeholder constants and commented TODO wiring are deleted. > > **`PredictionsSection.test.tsx`** mocks **`useCurrentCryptoUpDownMarketData`** so tests stay stable without live market data. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 3e187e45e0132c9e9aac198fcaf38ffef2f66115. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../UI/Predict/constants/btcUpDown5mSeries.ts | 7 -- .../Predictions/PredictionsSection.test.tsx | 15 +++ .../index.tsx | 103 ++++++++---------- 3 files changed, 61 insertions(+), 64 deletions(-) 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/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} + Date: Fri, 29 May 2026 22:21:14 +0800 Subject: [PATCH 05/10] feat: gasless hw swap hooks (#30354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This is the second pr related to gasless hardware wallet swap support. It introduces hooks that will be used. ## **Changelog** CHANGELOG entry: null ## **Related issues** Related to: https://consensyssoftware.atlassian.net/browse/MUL-1718 Depends on: https://github.com/MetaMask/metamask-mobile/pull/30182 ## **Manual testing steps** Not applicable ## **Screenshots/Recordings** Not applicable ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes bridge Redux and signing/disconnect/reject dispatch paths for hardware wallets; incorrect baseline or dedup logic could mis-handle swap failures, though behavior is heavily unit-tested and not yet wired to production UI. > > **Overview** > Adds **hardware-wallet swap state** to the bridge Redux slice (`hardwareWalletsSwaps`, `updateHardwareWalletsSwaps` / `resetHardwareWalletsSwaps`, `selectHardwareWalletsSwaps`) and two hooks meant for the gasless HW signing flow. > > **`useHwConnectionMonitoring`** watches device connection while status is `Waiting`. It dispatches `DEVICE_DISCONNECTED` or `REJECTED` when signing is active, with guards for pre-signing disconnect handoffs, stale baseline state on re-entry, deduplication, and recoverable transport errors. **`useHwQrState`** drives inline QR signing visibility, user cancel → `REJECTED`, and auto-cancels pending QR scans when the flow hits terminal statuses. > > Mocks and bridge slice tests are updated; hook behavior is covered by large unit test suites. No UI wiring in this diff—hooks and Redux plumbing only. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9be3ea4caf0c1abf6c5e60e81cf720a4c1ef9091. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../UI/Bridge/_mocks_/bridgeReducerState.ts | 2 + .../Swaps/useHwConnectionMonitoring.test.ts | 653 ++++++++++++++++++ .../Swaps/useHwConnectionMonitoring.ts | 156 +++++ .../HardwareWallet/Swaps/useHwQrState.test.ts | 329 +++++++++ .../UI/HardwareWallet/Swaps/useHwQrState.ts | 91 +++ app/core/redux/slices/bridge/index.test.ts | 41 ++ app/core/redux/slices/bridge/index.ts | 27 + 7 files changed, 1299 insertions(+) create mode 100644 app/components/UI/HardwareWallet/Swaps/useHwConnectionMonitoring.test.ts create mode 100644 app/components/UI/HardwareWallet/Swaps/useHwConnectionMonitoring.ts create mode 100644 app/components/UI/HardwareWallet/Swaps/useHwQrState.test.ts create mode 100644 app/components/UI/HardwareWallet/Swaps/useHwQrState.ts 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/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/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, From eb8dba82b78f63b22878ceb68c35206fb1bc41b8 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 29 May 2026 15:37:10 +0100 Subject: [PATCH 06/10] feat(transaction-controller): pass `isInternal: true` at internal call sites (#29525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adopts the explicit `isInternal: true` flag on internal `addTransaction` / `addTransactionBatch` call sites, replacing the old `origin === ORIGIN_METAMASK` trust check removed in `@metamask/transaction-controller@66.0.0`. A [yarn patch](https://yarnpkg.com/features/patching) on `@metamask/transaction-controller@66.0.0` is included to backport the `isInternal` field to the `ExtraTransactionsPublishHook` (needed for extra-transaction batches). ### Call sites stamped `isInternal: true` | Feature | Hook / Utility | |---------|---------------| | Send | `send.ts` `transaction.ts` | | Earn | `musdConversionTransaction.ts` `EarnInputView.tsx` `useMerklClaimTransaction.ts` | | Card | `useCardDelegation.ts` | | Money Account | `useMoneyAccount.ts` | | Perps | `usePerpsWithdrawConfirmation.ts` | | Predict | `PredictController.ts` | | Ramp | `SendTransaction.tsx` | | Pooled Staking | `usePoolStakedClaim/index.ts` `usePoolStakedDeposit/index.ts` `usePoolStakedUnstake/index.ts` | | Dev Tools | `confirmations-developer-options` | ### Not stamped — controller sets `isInternal` internally - **`BridgeStatusController`** (via `@metamask/bridge-status-controller@71.2.0`) — `bridge-status-controller-init.ts` is a passthrough; the controller sets the flag itself. - **`TransactionPayController`** (via `@metamask/transaction-pay-controller@22.6.0`) — sets `isInternal: true` in its own submit strategies. ### Excluded — external / dapp-originated paths - `BackgroundBridge` (`eth_sendTransaction`, `wallet_sendCalls`) - WalletConnect v2 session requests - Deeplink handlers ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: isInternal flag propagation Scenario: user submits an internal transaction Given the app is on a transaction submission flow (send, stake, earn, perps, etc.) When the transaction is submitted Then the transaction proceeds without requiring an external approval prompt ``` ## **Screenshots/Recordings** N/A — internal flag propagation, no UI changes. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. --- ...ion-controller-npm-66.0.0-23fe4d7dfe.patch | 24 ++ .../UI/Card/hooks/useCardDelegation.test.ts | 1 + .../UI/Card/hooks/useCardDelegation.ts | 1 + .../Views/EarnInputView/EarnInputView.tsx | 1 + .../hooks/useMerklClaimTransaction.ts | 1 + .../UI/Earn/hooks/useMusdConversion.test.ts | 1 + .../utils/musdConversionTransaction.test.ts | 2 + .../Earn/utils/musdConversionTransaction.ts | 2 + .../UI/Money/hooks/useMoneyAccount.ts | 2 + .../usePerpsWithdrawConfirmation.test.ts | 1 + .../hooks/usePerpsWithdrawConfirmation.ts | 1 + .../controllers/PredictController.test.ts | 1 + .../Predict/controllers/PredictController.ts | 4 + .../SendTransaction/SendTransaction.test.tsx | 2 + .../Views/SendTransaction/SendTransaction.tsx | 1 + .../Stake/hooks/usePoolStakedClaim/index.ts | 2 + .../Stake/hooks/usePoolStakedDeposit/index.ts | 1 + .../Stake/hooks/usePoolStakedUnstake/index.ts | 1 + .../confirmations-developer-options.test.tsx | 1 + .../Views/confirmations/utils/send.ts | 1 + .../Views/confirmations/utils/transaction.ts | 1 + package.json | 6 +- yarn.lock | 205 ++++-------------- 23 files changed, 94 insertions(+), 169 deletions(-) create mode 100644 .yarn/patches/@metamask-transaction-controller-npm-66.0.0-23fe4d7dfe.patch 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/UI/Card/hooks/useCardDelegation.test.ts b/app/components/UI/Card/hooks/useCardDelegation.test.ts index 95324d23a337..c63ac4049c41 100644 --- a/app/components/UI/Card/hooks/useCardDelegation.test.ts +++ b/app/components/UI/Card/hooks/useCardDelegation.test.ts @@ -1031,6 +1031,7 @@ describe('useCardDelegation', () => { { 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/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/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/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/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/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/confirmations/components/developer/confirmations-developer-options/confirmations-developer-options.test.tsx b/app/components/Views/confirmations/components/developer/confirmations-developer-options/confirmations-developer-options.test.tsx index 61d5aa95398f..6ba386544dd6 100644 --- a/app/components/Views/confirmations/components/developer/confirmations-developer-options/confirmations-developer-options.test.tsx +++ b/app/components/Views/confirmations/components/developer/confirmations-developer-options/confirmations-developer-options.test.tsx @@ -171,6 +171,7 @@ describe('ConfirmationsDeveloperOptions', () => { 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/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/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 From f4808a754516f23622625cb2a2d306e9bb3b04a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Loureiro?= <175489935+joaoloureirop@users.noreply.github.com> Date: Fri, 29 May 2026 15:53:19 +0100 Subject: [PATCH 07/10] ci(release): slack notif: do not include android lint report cp-7.80.0 (#30806) ## **Description** Remove android lint result from slack notification on release automatic RC builds. Adding back once test phase ends ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .github/workflows/slack-rc-notification.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From d287159dc6db41a6a65e99475d65a6f926ee07aa Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Fri, 29 May 2026 16:57:55 +0200 Subject: [PATCH 08/10] chore: remove explore search V1 (#30787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Remove explore search V1 ## **Changelog** CHANGELOG entry: remove explore search V1 ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3293 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Medium risk: large UX/navigation change in Explore search (removed full-view route, new default flows) with broad test updates; no auth or payment logic, but user-facing search behavior changes for all users once shipped. > > **Overview** > This PR **removes Explore search V1** and makes the tabbed, pill-based search the only path. The **`exploreSearchV2` remote feature flag** and its selector/registry entries are deleted, so search is no longer gated at runtime. > > **`ExploreSearchScreen`** always renders pill filters plus **`ExploreSearchContent`**, which uses **`useExploreSearch`** with pagination. The legacy **`ExploreSearchResultsV2`** duplicate and the **`ExploreSectionResultsFullView`** stack screen (and route/types) are removed; **“view more”** now switches the active pill and shows **`FullFeedList`** in-place instead of pushing a separate full-results screen. > > **`ExploreSearchResults`** is updated to the former V2 behavior: sections passed in from the parent, **`getViewMoreLabel`**, empty-state headers with popular asset pills, and analytics tied to **`activeTab`**. **`useExploreSearch`** drops V1-only options (truncate-without-query, title variants); **`useExploreSearchV2`** is deleted and tests/docs rename “V2” to the default experience. > > Tests and Detox/smoke flows now assert **search pills** and the **results list** instead of scrolling to a search-engine footer link. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 3b42ea9c75216395582274bfb33e70ecfa95c429. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/components/Nav/Main/MainNavigator.js | 6 - .../TrendingView/TrendingView.view.test.tsx | 6 +- .../ExploreSearchScreen.testIds.ts | 4 +- .../ExploreSearchScreen.tsx | 32 +- .../ExploreSearchScreen.view.test.tsx | 4 +- .../ExploreSectionResultsFullView.test.tsx | 170 --------- .../ExploreSectionResultsFullView.tsx | 126 ------- ...2.test.ts => ExploreSearchResults.test.ts} | 2 +- .../search/ExploreSearchResults.tsx | 238 +++++++++---- .../search/ExploreSearchResultsV2.tsx | 332 ------------------ .../Views/TrendingView/search/searchTypes.ts | 2 - ...rchV2.test.ts => useExploreSearch.test.ts} | 46 ++- .../TrendingView/search/useExploreSearch.ts | 47 +-- .../TrendingView/search/useExploreSearchV2.ts | 18 - .../TrendingView/search/viewMoreLabel.test.ts | 2 +- app/constants/navigation/Routes.ts | 1 - app/core/NavigationService/types.ts | 8 - .../exploreSearchV2/index.test.ts | 63 ---- .../exploreSearchV2/index.ts | 17 - tests/component-view/presets/trending.ts | 4 - tests/feature-flags/feature-flag-registry.ts | 11 - .../Trending/TrendingView.selectors.ts | 4 +- tests/page-objects/Trending/TrendingView.ts | 44 ++- tests/smoke/trending/trending-search.spec.ts | 7 +- 24 files changed, 264 insertions(+), 930 deletions(-) delete mode 100644 app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.test.tsx delete mode 100644 app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.tsx rename app/components/Views/TrendingView/search/{ExploreSearchResultsV2.test.ts => ExploreSearchResults.test.ts} (97%) delete mode 100644 app/components/Views/TrendingView/search/ExploreSearchResultsV2.tsx rename app/components/Views/TrendingView/search/{useExploreSearchV2.test.ts => useExploreSearch.test.ts} (90%) delete mode 100644 app/components/Views/TrendingView/search/useExploreSearchV2.ts delete mode 100644 app/selectors/featureFlagController/exploreSearchV2/index.test.ts delete mode 100644 app/selectors/featureFlagController/exploreSearchV2/index.ts diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index a456418eaa94..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'; @@ -1408,11 +1407,6 @@ const MainNavigator = () => { component={WhatsHappeningDetailView} options={{ headerShown: false, ...slideFromRightAnimation }} /> - @@ -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/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/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/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 4bb774d3aae7..7cc9305f3408 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -4748,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( From d95e80a931ab48736829f694b1508d0992713e48 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Fri, 29 May 2026 12:12:15 -0300 Subject: [PATCH 09/10] feat(card): redesign spend-and-earn promo on spending limit screen (#30709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Aligns the Spending Limit **Spend and earn** promo card with the updated Money account design (Figma `7749:21026`). **Why:** Product/design refreshed the copy and visual hierarchy for the Money account linkage CTA so it matches the broader Money account linkage experience and emphasizes APY as a highlighted benefit. **What changed:** - **SpendAndEarnPromoCard** — Title is now **Spend and earn** (bold). Body copy focuses on linking balance to the card and mUSD back on purchases, with the APY rate rendered in **success green** (`TextColor.SuccessDefault`, medium weight). CTA label is **Link card**. Button shimmer (`ShimmerOverlay`) is unchanged. - **Copy / i18n** — Replaced single-paragraph strings with split prefix / APY / suffix keys; removed explicit cashback % from the promo (Metal vs regular no longer differentiated in this banner). - **SpendingLimit** — Stops passing `cashbackPercent` into the promo card. - **useCardPostAuthRedirect** — New hook (with unit tests) to read `postAuthRedirect` from parent navigators when the user enters card flows from Money Home; supports nested onboarding params. Scaffolding for upcoming linkage / bottom-sheet navigation work. ## **Changelog** CHANGELOG entry: Updated the Spending Limit spend-and-earn promo card copy, styling, and Link card CTA to match the Money account design. ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Spending Limit spend-and-earn promo card Scenario: User sees the redesigned promo when linking Money account from card onboarding Given the user is on the Card Spending Limit screen during onboarding And a crypto token is selected (not Money account as source) And the Money account linkage CTA is available (funded Money balance) And a live APY rate is available When the user views the spend-and-earn promo card Then the title reads "Spend and earn" And the description mentions linking balance to the card and mUSD back on purchases And the APY rate (e.g. "4% APY") appears highlighted in green And the primary button label reads "Link card" And the button still shows the horizontal shimmer animation When the user taps the promo card or the "Link card" button Then the app switches the spending source to the Money account Scenario: User sees shortened copy when APY is unavailable Given the same Spending Limit onboarding context as above And no APY rate is returned for the Money account When the user views the spend-and-earn promo card Then the description reads "Link your balance to your card and get mUSD back on purchases." And no highlighted APY segment is shown ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Navigation param threading across Money and card onboarding/auth affects where users land after sign-in; regressions could strand users on the wrong screen, though changes are mostly UI copy plus tested redirect plumbing. > > **Overview** > **Spend-and-earn promo** on Spending Limit is redesigned to match Money account UX: title **Spend and earn**, new body copy with a **green-highlighted** APY segment, CTA **Link card**, and **no Metal vs regular cashback** in the banner (`SpendAndEarnPromoCard` drops `cashbackPercent`; i18n uses prefix / APY / suffix keys). > > **Return-to-Money after card flows:** new `useCardPostAuthRedirect` reads `postAuthRedirect` from parent navigators (including nested onboarding params). **Money Home** card entry points pass `MONEY_HOME_CARD_ORIGIN`; **Card Welcome**, **Sign Up**, and **money–card linkage** forward that param into onboarding or authentication. **Money Link Card sheet** and **MetaMask Card** promos get aligned copy and **“(variable)”** APY wording in `en.json`. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 031c060885bbbab7b5befcf86ad367a21caeb7c9. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../Views/CardWelcome/CardWelcome.test.tsx | 56 +++++++++++++++- .../UI/Card/Views/CardWelcome/CardWelcome.tsx | 20 +++++- .../SpendingLimit/SpendingLimit.test.tsx | 35 +++++----- .../Views/SpendingLimit/SpendingLimit.tsx | 1 - .../components/SpendAndEarnPromoCard.test.tsx | 40 +++++------ .../components/SpendAndEarnPromoCard.tsx | 66 +++++++++++-------- .../components/Onboarding/SignUp.test.tsx | 38 ++++++++++- .../UI/Card/components/Onboarding/SignUp.tsx | 14 +++- .../hooks/useCardPostAuthRedirect.test.ts | 64 ++++++++++++++++++ .../UI/Card/hooks/useCardPostAuthRedirect.ts | 50 ++++++++++++++ .../hooks/useMoneyAccountCardLinkage.test.tsx | 10 ++- .../Card/hooks/useMoneyAccountCardLinkage.tsx | 5 +- .../MoneyHomeView/MoneyHomeView.test.tsx | 31 +++++++-- .../Views/MoneyHomeView/MoneyHomeView.tsx | 6 +- .../MoneyLinkCardSheet.test.tsx | 13 ++-- .../MoneyLinkCardSheet/MoneyLinkCardSheet.tsx | 26 ++++++-- .../MoneyMetaMaskCard.test.tsx | 6 +- locales/languages/en.json | 15 +++-- 18 files changed, 386 insertions(+), 110 deletions(-) create mode 100644 app/components/UI/Card/hooks/useCardPostAuthRedirect.test.ts create mode 100644 app/components/UI/Card/hooks/useCardPostAuthRedirect.ts 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)} - > + ({ + 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/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx index a39da5ff64cb..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'; @@ -630,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', () => { @@ -669,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', () => { @@ -1141,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 }, + }); }); }); @@ -1256,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 }, + }); }); }); @@ -1268,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', () => { @@ -1279,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 6ea666684a45..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'; @@ -178,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/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx index 63b3ff30a3e6..8d588fd3dffb 100644 --- a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx +++ b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx @@ -111,9 +111,13 @@ describe('MoneyLinkCardSheet', () => { ).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/locales/languages/en.json b/locales/languages/en.json index 3bbfe82a1443..cc789ba1818e 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6873,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", @@ -8388,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", From f166abbaa6addbea5674a279ce7bd87e8da64714 Mon Sep 17 00:00:00 2001 From: Wei Sun Date: Fri, 29 May 2026 10:35:54 -0700 Subject: [PATCH 10/10] refactor(aggregator): migrate aggregator routes to native stack (#30611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Migrates the legacy aggregator routes (`Aggregator/routes/index.tsx`) from `@react-navigation/stack` (JS stack) to `@react-navigation/native-stack`, aligning with the rest of the Ramp routing tree (Unified V2 `Ramp/routes.tsx` already uses native stack). Native stack uses platform-native navigation primitives (`UINavigationController` on iOS, `FragmentTransaction` on Android), which yields better performance and a more native feel — especially noticeable on the modal/overlay transitions used by `Quotes` and `Checkout`. The migration is purely a wiring/config change. No screen behaviour, route names, or component imports change. Translations applied: - Navigator: `createStackNavigator` → `createNativeStackNavigator` - Per-screen overlay options: replaced inline `headerShown / cardStyle / animationEnabled / detachPreviousScreen` blocks for `Quotes` and `Checkout` with the shared `clearNativeStackNavigatorOptions` + `transparentModalScreenOptions` presets from `app/constants/navigation/clearStackNavigatorOptions.ts`. - Property renames: `cardStyle` → `contentStyle`, `animationEnabled: false` → `animation: 'none'`. - The `detachPreviousScreen: false` workaround used to keep the underlying screen mounted under transparent modals is no longer needed — native stack keeps the presenting screen mounted automatically when `presentation: 'transparentModal'` is used. - Removed the now-unused `colors` import (it was only used to set `cardStyle.backgroundColor: colors.transparent`). Test mock in `routes/index.test.tsx` updated to mock `@react-navigation/native-stack` (`createNativeStackNavigator`) instead of `@react-navigation/stack`. ## **Changelog** CHANGELOG entry:null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** Aggregator Before ### **After** Aggregator After Android https://github.com/user-attachments/assets/9df9f89d-3c4e-4e49-ac32-ba3896d0384f ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Navigation-only wiring in Ramp Aggregator routes; no business logic, auth, or data handling changes. > > **Overview** > **Aggregator Ramp routing** now uses `@react-navigation/native-stack` instead of the JS stack, matching the newer Ramp navigation setup while leaving screens and route names unchanged. > > **Quotes**, **Checkout**, and the nested **modals** stack pick up shared `clearNativeStackNavigatorOptions` and `transparentModalScreenOptions` instead of inline `cardStyle` / `animationEnabled` / `detachPreviousScreen` workarounds; `BUILD_QUOTE_HAS_STARTED` uses `animation: 'none'`. The route test mock was updated to `createNativeStackNavigator`. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 371f72565c38b03cd87469572e6b69adbfd51a5d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../UI/Ramp/Aggregator/routes/index.test.tsx | 4 +- .../UI/Ramp/Aggregator/routes/index.tsx | 45 +++++++++---------- 2 files changed, 24 insertions(+), 25 deletions(-) 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, }} />