From 7c87b2fb267a7c63d7f4c2523aeeb051b16d668b Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Tue, 16 Dec 2025 12:48:09 +0000 Subject: [PATCH 01/12] style: update TokenDetails Contract Field styles (#24063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Removed pill style for a simple text style to align with design changes ## **Changelog** CHANGELOG entry: style: update TokenDetails Contract Field styles ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-2130 ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** image-20251209-201047 ### **After** Screenshot 2025-12-16 at 10 59 11 ## **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. ## **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] > Simplifies the Token Details contract address from a pill to plain text with copy icon, migrates to Tailwind utilities and DS React Native Icon, and updates styles/snapshots. > > - **UI/Styling** > - Replace contract address "pill" with simple text + copy icon in `TokenDetailsList`. > - Use Tailwind utilities (`useTailwind`, `tw`) for spacing (`py-2`, `gap-1`). > - Switch to `@metamask/design-system-react-native` `Icon` and remove color props. > - **Styles Refactor** > - Simplify `styleSheet` in `TokenDetails.styles.tsx` (no theme param); remove unused `contentWrapper`, `icon`, `copyButton` styles. > - Adjust title padding to separate top/bottom in snapshots. > - **Tests** > - Update snapshots in `TokenDetailsList.test.tsx.snap`, `TokenDetails.test.tsx.snap`, and `AssetOverview.test.tsx.snap` to reflect new spacing, colors, and icon styling. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ed25af0b590387a2ce791293e02ae3aeb6470703. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../TokenDetails/TokenDetails.styles.tsx | 20 +----- .../TokenDetailsList/TokenDetailsList.tsx | 24 +++---- .../TokenDetailsList.test.tsx.snap | 26 ++++--- .../__snapshots__/TokenDetails.test.tsx.snap | 26 ++++--- .../__snapshots__/AssetOverview.test.tsx.snap | 3 +- .../Asset/__snapshots__/index.test.js.snap | 70 ++++++++++--------- 6 files changed, 72 insertions(+), 97 deletions(-) diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.styles.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.styles.tsx index 94c39b9a09d..fa0928b5379 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.styles.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.styles.tsx @@ -1,21 +1,14 @@ -import type { Theme } from '@metamask/design-tokens'; import { StyleSheet, TextStyle } from 'react-native'; -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - const { colors } = theme; - return StyleSheet.create({ +const styleSheet = () => + StyleSheet.create({ tokenDetailsContainer: { marginTop: 16, gap: 24, }, - contentWrapper: { - paddingVertical: 4, - }, title: { paddingVertical: 8, } as TextStyle, - icon: { marginLeft: 4 }, listWrapper: { paddingTop: 8, paddingBottom: 8, @@ -36,15 +29,6 @@ const styleSheet = (params: { theme: Theme }) => { lastChild: { paddingBottom: 0, }, - copyButton: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.primary.muted, - borderRadius: 20, - paddingHorizontal: 8, - marginLeft: 8, - }, }); -}; export default styleSheet; diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx index 931c882e609..7816e44bd75 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx @@ -1,19 +1,15 @@ import React from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useDispatch } from 'react-redux'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { Icon, IconName, IconSize } from '@metamask/design-system-react-native'; import { showAlert } from '../../../../../actions/alert'; import { strings } from '../../../../../../locales/i18n'; import { useStyles } from '../../../../../component-library/hooks'; import Text, { - TextColor, TextVariant, } from '../../../../../component-library/components/Texts/Text'; import styleSheet from '../TokenDetails.styles'; -import Icon, { - IconColor, - IconName, - IconSize, -} from '../../../../../component-library/components/Icons/Icon'; import ClipboardManager from '../../../../../core/ClipboardManager'; import { TokenDetails } from '../TokenDetails'; import TokenDetailsListItem from '../TokenDetailsListItem'; @@ -26,7 +22,8 @@ interface TokenDetailsListProps { const TokenDetailsList: React.FC = ({ tokenDetails, }) => { - const { styles } = useStyles(styleSheet, {}); + const tw = useTailwind(); + const { styles } = useStyles(styleSheet); const dispatch = useDispatch(); const handleShowAlert = (config: { @@ -49,7 +46,7 @@ const TokenDetailsList: React.FC = ({ return ( - + {strings('token.token_details')} @@ -59,18 +56,13 @@ const TokenDetailsList: React.FC = ({ style={[styles.listItem, styles.firstChild]} > - + {formatAddress(tokenDetails.contractAddress, 'short')} - + )} diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/__snapshots__/TokenDetailsList.test.tsx.snap b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/__snapshots__/TokenDetailsList.test.tsx.snap index 6c6c7cb4993..ab5c86e0d51 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/__snapshots__/TokenDetailsList.test.tsx.snap +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/__snapshots__/TokenDetailsList.test.tsx.snap @@ -11,7 +11,8 @@ exports[`TokenDetails should render correctly 1`] = ` "fontSize": 18, "letterSpacing": 0, "lineHeight": 24, - "paddingVertical": 8, + "paddingBottom": 8, + "paddingTop": 8, } } > @@ -58,11 +59,8 @@ exports[`TokenDetails should render correctly 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#4459ff1a", - "borderRadius": 20, "flexDirection": "row", - "marginLeft": 8, - "paddingHorizontal": 8, + "gap": 4, } } > @@ -70,7 +68,7 @@ exports[`TokenDetails should render correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#4459ff", + "color": "#121314", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -81,18 +79,18 @@ exports[`TokenDetails should render correctly 1`] = ` 0x935E7...05477 diff --git a/app/components/UI/AssetOverview/TokenDetails/__snapshots__/TokenDetails.test.tsx.snap b/app/components/UI/AssetOverview/TokenDetails/__snapshots__/TokenDetails.test.tsx.snap index 2684265ac15..1885ee380c4 100644 --- a/app/components/UI/AssetOverview/TokenDetails/__snapshots__/TokenDetails.test.tsx.snap +++ b/app/components/UI/AssetOverview/TokenDetails/__snapshots__/TokenDetails.test.tsx.snap @@ -19,7 +19,8 @@ exports[`TokenDetails should render correctly 1`] = ` "fontSize": 18, "letterSpacing": 0, "lineHeight": 24, - "paddingVertical": 8, + "paddingBottom": 8, + "paddingTop": 8, } } > @@ -66,11 +67,8 @@ exports[`TokenDetails should render correctly 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#4459ff1a", - "borderRadius": 20, "flexDirection": "row", - "marginLeft": 8, - "paddingHorizontal": 8, + "gap": 4, } } > @@ -78,7 +76,7 @@ exports[`TokenDetails should render correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#4459ff", + "color": "#121314", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -89,18 +87,18 @@ exports[`TokenDetails should render correctly 1`] = ` 0x6B175...71d0F diff --git a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap index b525ae8e8a3..b7c391893f8 100644 --- a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap +++ b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap @@ -854,7 +854,8 @@ exports[`AssetOverview should render native balances when non evm network is sel "fontSize": 18, "letterSpacing": 0, "lineHeight": 24, - "paddingVertical": 8, + "paddingBottom": 8, + "paddingTop": 8, } } > diff --git a/app/components/Views/Asset/__snapshots__/index.test.js.snap b/app/components/Views/Asset/__snapshots__/index.test.js.snap index 1c44e9dc41f..8f0b45364f1 100644 --- a/app/components/Views/Asset/__snapshots__/index.test.js.snap +++ b/app/components/Views/Asset/__snapshots__/index.test.js.snap @@ -1758,7 +1758,8 @@ exports[`Asset Multichain Functionality renders empty state when no multichain t "fontSize": 18, "letterSpacing": 0, "lineHeight": 24, - "paddingVertical": 8, + "paddingBottom": 8, + "paddingTop": 8, } } > @@ -3377,7 +3378,8 @@ exports[`Asset Multichain Functionality returns empty list for unknown SPL token "fontSize": 18, "letterSpacing": 0, "lineHeight": 24, - "paddingVertical": 8, + "paddingBottom": 8, + "paddingTop": 8, } } > @@ -3424,11 +3426,8 @@ exports[`Asset Multichain Functionality returns empty list for unknown SPL token style={ { "alignItems": "center", - "backgroundColor": "#4459ff1a", - "borderRadius": 20, "flexDirection": "row", - "marginLeft": 8, - "paddingHorizontal": 8, + "gap": 4, } } > @@ -3436,7 +3435,7 @@ exports[`Asset Multichain Functionality returns empty list for unknown SPL token accessibilityRole="text" style={ { - "color": "#4459ff", + "color": "#121314", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -3447,18 +3446,18 @@ exports[`Asset Multichain Functionality returns empty list for unknown SPL token Unknown...dress @@ -5099,7 +5098,8 @@ exports[`Asset Multichain Functionality should exclude mixed token/SOL transacti "fontSize": 18, "letterSpacing": 0, "lineHeight": 24, - "paddingVertical": 8, + "paddingBottom": 8, + "paddingTop": 8, } } > @@ -6749,7 +6749,8 @@ exports[`Asset Multichain Functionality should exclude transactions with empty a "fontSize": 18, "letterSpacing": 0, "lineHeight": 24, - "paddingVertical": 8, + "paddingBottom": 8, + "paddingTop": 8, } } > @@ -8368,7 +8369,8 @@ exports[`Asset Multichain Functionality should filter SPL token transactions cor "fontSize": 18, "letterSpacing": 0, "lineHeight": 24, - "paddingVertical": 8, + "paddingBottom": 8, + "paddingTop": 8, } } > @@ -8415,11 +8417,8 @@ exports[`Asset Multichain Functionality should filter SPL token transactions cor style={ { "alignItems": "center", - "backgroundColor": "#4459ff1a", - "borderRadius": 20, "flexDirection": "row", - "marginLeft": 8, - "paddingHorizontal": 8, + "gap": 4, } } > @@ -8427,7 +8426,7 @@ exports[`Asset Multichain Functionality should filter SPL token transactions cor accessibilityRole="text" style={ { - "color": "#4459ff", + "color": "#121314", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -8438,18 +8437,18 @@ exports[`Asset Multichain Functionality should filter SPL token transactions cor EPjFWdd...TDt1v @@ -10090,7 +10089,8 @@ exports[`Asset Multichain Functionality should filter native SOL transactions co "fontSize": 18, "letterSpacing": 0, "lineHeight": 24, - "paddingVertical": 8, + "paddingBottom": 8, + "paddingTop": 8, } } > @@ -12268,7 +12268,8 @@ exports[`Asset Multichain Functionality should render non-EVM assets with Multic "fontSize": 18, "letterSpacing": 0, "lineHeight": 24, - "paddingVertical": 8, + "paddingBottom": 8, + "paddingTop": 8, } } > @@ -13918,7 +13919,8 @@ exports[`Asset Multichain Functionality should sort filtered transactions by tim "fontSize": 18, "letterSpacing": 0, "lineHeight": 24, - "paddingVertical": 8, + "paddingBottom": 8, + "paddingTop": 8, } } > From ffa0acf3b7a1e76deaeacbb48f9a37115aae9742 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 16 Dec 2025 15:24:21 +0100 Subject: [PATCH 02/12] fix: fix tokenListController state init (#24020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** TokenListController was not being initialized with its persisted state on cold restart. This caused: * 7 unnecessary API calls to fetch token lists for all chains on every app cold start * Redundant cache rewrites (~4MB) even though the data was already persisted ## **Changelog** CHANGELOG entry: Fix TokenListController initialization. ## **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** https://github.com/user-attachments/assets/6dbd9427-0a6e-4b71-beea-72b10edad86f ### **After** https://github.com/user-attachments/assets/8333ecc4-86f3-40f5-91bf-1cc0f008dd28 ## **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. ## **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] > Initialize `TokenListController` with persisted state during engine startup. > > - **Engine controllers**: > - Update `app/core/Engine/controllers/token-list-controller-init.ts`: > - Extend `tokenListControllerInit` signature to accept `persistedState`. > - Pass `state: persistedState.TokenListController` to `new TokenListController(...)`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3ba322fef4eb0b23e618123d1cf4aafa6326241e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/core/Engine/controllers/token-list-controller-init.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/core/Engine/controllers/token-list-controller-init.ts b/app/core/Engine/controllers/token-list-controller-init.ts index f805e6152e3..2c4f3bf3d9e 100644 --- a/app/core/Engine/controllers/token-list-controller-init.ts +++ b/app/core/Engine/controllers/token-list-controller-init.ts @@ -17,12 +17,13 @@ export const tokenListControllerInit: ControllerInitFunction< TokenListController, TokenListControllerMessenger, TokenListControllerInitMessenger -> = ({ controllerMessenger, initMessenger, getController }) => { +> = ({ controllerMessenger, initMessenger, getController, persistedState }) => { const networkController = getController('NetworkController'); const controller = new TokenListController({ messenger: controllerMessenger, chainId: getGlobalChainId(networkController), + state: persistedState.TokenListController, onNetworkStateChange: (listener) => initMessenger.subscribe( 'NetworkController:stateChange', From b204becd72ddab5530101cad23df7b1a05007923 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Tue, 16 Dec 2025 11:18:28 -0330 Subject: [PATCH 03/12] ci: Revert "chore(infra): optimize Android E2E build for lg runner (16 vCPUs, 48GB)" (#24071) Reverts MetaMask/metamask-mobile#23869, which was found to be causing build failures. --- > [!NOTE] > Generates and uploads Android AABs in E2E builds, bumps the build runner to XL, and adjusts Gradle settings while cleaning up build flags. > > - **CI/Workflows** > - **`build-android-e2e.yml`**: > - Add AAB support: new outputs (`aab-uploaded`), paths (`aab-target-path`), cache entries, and artifact upload step. > - Bump runner to `24.04-xl`. > - Extend outputs to include AAB paths and upload outcome. > - **`run-e2e-workflow.yml`**: > - Propagate `aab-target-path` and create target dir for Android artifacts. > - **Build Script** (`scripts/build.sh`): > - Always generate AAB on Release builds (including E2E); remove Gradle debug flags. > - **Gradle Config** (`android/gradle.properties.github`): > - Increase heap to 16g, enable daemon, `configureondemand`, and set `workers.max=6`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cd23401333b0cd842ac756e4734cc9aef06816dc. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/build-android-e2e.yml | 21 ++++++++++++++++++++- .github/workflows/run-e2e-workflow.yml | 4 ++++ android/gradle.properties.github | 14 +++++++------- scripts/build.sh | 11 ++--------- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 7019ff20104..35d674bcee5 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -9,6 +9,9 @@ on: apk-uploaded: description: 'Whether the APK was successfully uploaded' value: ${{ jobs.build-android-apks.outputs.apk-uploaded }} + aab-uploaded: + description: 'Whether the AAB was successfully uploaded' + value: ${{ jobs.build-android-apks.outputs.aab-uploaded }} inputs: build_type: description: 'The type of build to perform' @@ -29,15 +32,17 @@ on: jobs: build-android-apks: name: Build Android E2E APKs - runs-on: ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg # lg runner: 16 vCPUs, 48GB RAM + runs-on: ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-xl # Bumped from lg to xl to prevent Daemon disappearance issue (Daemon OOM issue in CI) timeout-minutes: 40 env: GRADLE_USER_HOME: /home/admin/_work/.gradle CACHE_GENERATION: v1 # Increment this to bust the cache (v1, v2, v3, etc.) outputs: apk-uploaded: ${{ steps.upload-apk.outcome == 'success' }} + aab-uploaded: ${{ steps.upload-aab.outcome == 'success' }} apk-target-path: ${{ steps.determine-target-paths.outputs.apk-target-path }} test-apk-target-path: ${{ steps.determine-target-paths.outputs.test-apk-target-path }} + aab-target-path: ${{ steps.determine-target-paths.outputs.aab-target-path }} artifact_name: ${{ steps.determine-target-paths.outputs.artifact_name }} steps: @@ -83,12 +88,14 @@ jobs: { echo "apk-target-path=android/app/build/outputs/apk/flask/release" echo "test-apk-target-path=android/app/build/outputs/apk/androidTest/flask/release" + echo "aab-target-path=android/app/build/outputs/bundle/flaskRelease" echo "artifact_name=app-flask-release" } >> "$GITHUB_OUTPUT" elif [[ "${{ inputs.build_type }}" == "main" ]]; then { echo "apk-target-path=android/app/build/outputs/apk/prod/release" echo "test-apk-target-path=android/app/build/outputs/apk/androidTest/prod/release" + echo "aab-target-path=android/app/build/outputs/bundle/prodRelease" echo "artifact_name=app-prod-release" } >> "$GITHUB_OUTPUT" else @@ -103,6 +110,7 @@ jobs: path: | ${{ steps.determine-target-paths.outputs.apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.apk ${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk + ${{ steps.determine-target-paths.outputs.aab-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.aab # Include Gradle properties in key to force rebuild when properties change # Keep the `hashFiles` call for Gradle config in-sync with these steps: # - "Cache Gradle dependencies" @@ -233,6 +241,7 @@ jobs: path: | ${{ steps.determine-target-paths.outputs.apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.apk ${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk + ${{ steps.determine-target-paths.outputs.aab-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.aab # Keep the `hashFiles` call for Gradle config in-sync with these steps: # - "Check and restore cached APKs if Fingerprint is found" # - "Cache Gradle dependencies" @@ -255,3 +264,13 @@ jobs: path: ${{ steps.determine-target-paths.outputs.test-apk-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}-androidTest.apk retention-days: 7 if-no-files-found: error + + - name: Upload Android AAB + id: upload-aab + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.aab + path: ${{ steps.determine-target-paths.outputs.aab-target-path }}/${{ steps.determine-target-paths.outputs.artifact_name }}.aab + retention-days: 7 + if-no-files-found: warn + continue-on-error: true diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml index 6ec79db1012..fcc4a9e05e6 100644 --- a/.github/workflows/run-e2e-workflow.yml +++ b/.github/workflows/run-e2e-workflow.yml @@ -56,6 +56,7 @@ jobs: outputs: apk-target-path: ${{ steps.determine-target-paths.outputs.apk-target-path }} test-apk-target-path: ${{ steps.determine-target-paths.outputs.test-apk-target-path }} + aab-target-path: ${{ steps.determine-target-paths.outputs.aab-target-path }} env: PREBUILT_IOS_APP_PATH: artifacts/MetaMask.app @@ -130,12 +131,14 @@ jobs: { echo "apk-target-path=android/app/build/outputs/apk/flask/release" echo "test-apk-target-path=android/app/build/outputs/apk/androidTest/flask/release" + echo "aab-target-path=android/app/build/outputs/bundle/flaskRelease" echo "artifact_name=app-flask-release" } >> "$GITHUB_OUTPUT" elif [[ "${{ inputs.build_type }}" == "main" ]]; then { echo "apk-target-path=android/app/build/outputs/apk/prod/release" echo "test-apk-target-path=android/app/build/outputs/apk/androidTest/prod/release" + echo "aab-target-path=android/app/build/outputs/bundle/prodRelease" echo "artifact_name=app-prod-release" } >> "$GITHUB_OUTPUT" else @@ -149,6 +152,7 @@ jobs: echo "🏗 Setting up Android artifacts from build job..." mkdir -p ${{ steps.determine-target-paths.outputs.apk-target-path }} mkdir -p ${{ steps.determine-target-paths.outputs.test-apk-target-path }} + mkdir -p ${{ steps.determine-target-paths.outputs.aab-target-path }} - name: Download Android build artifacts if: ${{ inputs.platform == 'android' }} diff --git a/android/gradle.properties.github b/android/gradle.properties.github index f3582e40f7c..768591f0851 100644 --- a/android/gradle.properties.github +++ b/android/gradle.properties.github @@ -1,16 +1,16 @@ # GitHub Actions-specific Gradle settings # Optimized for E2E builds on GitHub Actions runners -# JVM configuration - tuned for 48GB runner to avoid OOM while maintaining performance -# Heap: 12GB to leave room for Node.js/Metro and native memory -# ExitOnOutOfMemoryError: fail-fast on OOM for CI -org.gradle.jvmargs=-Xmx12g -Xms4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:G1HeapRegionSize=16m -XX:+UseStringDeduplication -XX:MaxGCPauseMillis=500 -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8 +# JVM configuration - balanced settings to avoid OOM while maintaining performance +# Using 16GB heap to leave room for parallel workers and native memory +org.gradle.jvmargs=-Xmx16g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:G1HeapRegionSize=16m -XX:+UseStringDeduplication -XX:+OptimizeStringConcat # Enable performance optimizations but limit parallelism to prevent OOM org.gradle.parallel=true +org.gradle.configureondemand=true org.gradle.caching=true -org.gradle.daemon=false -org.gradle.workers.max=2 +org.gradle.daemon=true +org.gradle.workers.max=6 org.gradle.vfs.watch=false # CI-specific optimizations - enabled for GitHub Actions @@ -54,4 +54,4 @@ hermesEnabled=true android.disableResourceValidation=true # Use legacy packaging to compress native libraries in the resulting APK. -expo.useLegacyPackaging=false +expo.useLegacyPackaging=false \ No newline at end of file diff --git a/scripts/build.sh b/scripts/build.sh index 7c0c54a341f..54827d14451 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -547,8 +547,6 @@ generateAndroidBinary() { local reactNativeArchitecturesArg="" # Define Test build type arg local testBuildTypeArg="" - # Define Gradle debug flags - local gradleDebugFlags="" # Check if configuration is valid if [ "$configuration" != "Debug" ] && [ "$configuration" != "Release" ] ; then @@ -574,19 +572,14 @@ generateAndroidBinary() { if [ "$METAMASK_ENVIRONMENT" = "e2e" ] ; then # Only build for x86_64 for E2E builds reactNativeArchitecturesArg="-PreactNativeArchitectures=x86_64" - # Enable Gradle debugging flags for E2E builds to investigate Daemon disappearance issues - gradleDebugFlags="--stacktrace --info" - echo "📊 E2E build: Enabling Gradle debugging flags (--stacktrace --info)" fi fi # Generate Android APKs echo "Generating Android binary for ($flavor) flavor with ($configuration) configuration" - ./gradlew $assembleApkTask $assembleTestApkTask $testBuildTypeArg $reactNativeArchitecturesArg $gradleDebugFlags + ./gradlew $assembleApkTask $assembleTestApkTask $testBuildTypeArg $reactNativeArchitecturesArg - # Skip AAB bundle for E2E environments - AAB cannot be installed on emulators - # and is only needed for Play Store distribution - if [ "$configuration" = "Release" ] && [ "$METAMASK_ENVIRONMENT" != "e2e" ] ; then + if [ "$configuration" = "Release" ] ; then # Generate AAB bundle (not needed for E2E) bundleConfiguration="bundle${flavor}Release" echo "Generating AAB bundle for ($flavor) flavor with ($configuration) configuration" From 26a14120b4a841384d098ac2e965311e66e2dc39 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Tue, 16 Dec 2025 15:15:51 +0000 Subject: [PATCH 04/12] refactor: update token detail options bottom sheet (#24070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Updated to use the BottomSheet component, and removed unused styles ## **Changelog** CHANGELOG entry: refactor: update token detail options bottom sheet ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-2131 ## **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** tradedetail-viewactionmenu-bs ### **After** Screenshot 2025-12-16 at 12 32 19 ## **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. ## **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] > Replaces the AssetOptions modal with the new BottomSheet and design-system Text/Icon, simplifying styles and adding an i18n header title. > > - **UI**: > - Replace `ReusableModal` with `BottomSheet`/`BottomSheetHeader` in `AssetOptions.tsx`; switch `dismissModal` calls to `onCloseBottomSheet`. > - Use `@metamask/design-system-react-native` `Text`/`Icon`; streamline option row rendering and layout. > - Simplify styles in `AssetOptions.styles.ts` (remove theme deps; set `padding: 12`, `gap: 16`). > - **Tests**: > - Update mocks (e.g., add `useSafeAreaFrame`) to support new bottom sheet rendering. > - **i18n**: > - Add `asset_details.options.title` (“Token options”) for the bottom sheet header. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f5817061f358f46b53f3fb850e098ba198428697. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/AssetOptions/AssetOptions.styles.ts | 41 +++-------------- .../Views/AssetOptions/AssetOptions.test.tsx | 1 + .../Views/AssetOptions/AssetOptions.tsx | 44 +++++++++++-------- locales/languages/en.json | 1 + 4 files changed, 32 insertions(+), 55 deletions(-) diff --git a/app/components/Views/AssetOptions/AssetOptions.styles.ts b/app/components/Views/AssetOptions/AssetOptions.styles.ts index 2593ddd1800..bf1d411aa4d 100644 --- a/app/components/Views/AssetOptions/AssetOptions.styles.ts +++ b/app/components/Views/AssetOptions/AssetOptions.styles.ts @@ -1,44 +1,13 @@ -import type { Theme } from '@metamask/design-tokens'; -import { StyleSheet, TextStyle } from 'react-native'; -import { - getFontFamily, - TextVariant, -} from '../../../component-library/components/Texts/Text'; +import { StyleSheet } from 'react-native'; -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - const { colors, typography } = theme; - return StyleSheet.create({ - screen: { justifyContent: 'flex-end' }, - sheet: { - backgroundColor: colors.background.default, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - }, - notch: { - width: 48, - height: 5, - borderRadius: 4, - backgroundColor: colors.border.default, - marginTop: 12, - alignSelf: 'center', - marginBottom: 16, - }, +const styleSheet = () => + StyleSheet.create({ optionButton: { alignItems: 'center', flexDirection: 'row', - padding: 16, - }, - icon: { - marginRight: 16, - color: colors.text.default, + padding: 12, + gap: 16, }, - optionLabel: { - ...typography.sBodyLGMedium, - fontFamily: getFontFamily(TextVariant.BodyLGMedium), - color: colors.text.default, - } as TextStyle, }); -}; export default styleSheet; diff --git a/app/components/Views/AssetOptions/AssetOptions.test.tsx b/app/components/Views/AssetOptions/AssetOptions.test.tsx index 44bd50ca18d..8dbfbceec88 100644 --- a/app/components/Views/AssetOptions/AssetOptions.test.tsx +++ b/app/components/Views/AssetOptions/AssetOptions.test.tsx @@ -41,6 +41,7 @@ jest.mock('react-redux', () => ({ jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: jest.fn(() => ({ bottom: 10 })), + useSafeAreaFrame: jest.fn(() => ({ width: 390, height: 844 })), })); // Mock InteractionManager.runAfterInteractions to execute callbacks immediately diff --git a/app/components/Views/AssetOptions/AssetOptions.tsx b/app/components/Views/AssetOptions/AssetOptions.tsx index 8afc11d282d..1ad3c3487a5 100644 --- a/app/components/Views/AssetOptions/AssetOptions.tsx +++ b/app/components/Views/AssetOptions/AssetOptions.tsx @@ -1,7 +1,12 @@ import { useNavigation } from '@react-navigation/native'; import React, { useMemo, useRef } from 'react'; -import { Text, TouchableOpacity, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { TouchableOpacity, View } from 'react-native'; +import { + Text, + TextVariant, + Icon, + IconName, +} from '@metamask/design-system-react-native'; import { useSelector } from 'react-redux'; import Engine from '../../../core/Engine'; import NotificationManager from '../../../core/NotificationManager'; @@ -9,11 +14,7 @@ import Routes from '../../../constants/navigation/Routes'; import { useStyles } from '../../../component-library/hooks'; import { useMetrics } from '../../../components/hooks/useMetrics'; import { strings } from '../../../../locales/i18n'; -import Icon, { - IconName, -} from '../../../component-library/components/Icons/Icon'; import { selectEvmChainId } from '../../../selectors/networkController'; -import ReusableModal, { ReusableModalRef } from '../../UI/ReusableModal'; import styleSheet from './AssetOptions.styles'; import { selectTokenList } from '../../../selectors/tokenListController'; import Logger from '../../../util/Logger'; @@ -31,6 +32,10 @@ import { removeNonEvmToken } from '../../UI/Tokens/util'; import { toChecksumAddress, areAddressesEqual } from '../../../util/address'; import { selectAssetsBySelectedAccountGroup } from '../../../selectors/assets/assets-list'; import useBlockExplorer from '../../hooks/useBlockExplorer'; +import BottomSheet, { + BottomSheetRef, +} from '../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; // Wrapped SOL token address on Solana const WRAPPED_SOL_ADDRESS = 'So11111111111111111111111111111111111111111'; @@ -90,10 +95,9 @@ const AssetOptions = (props: Props) => { chainId: networkId, asset, } = props.route.params; - const { styles } = useStyles(styleSheet, {}); - const safeAreaInsets = useSafeAreaInsets(); + const { styles } = useStyles(styleSheet); const navigation = useNavigation(); - const modalRef = useRef(null); + const modalRef = useRef(null); const tokenList = useSelector(selectTokenList); const chainId = useSelector(selectEvmChainId); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -129,7 +133,7 @@ const AssetOptions = (props: Props) => { const { trackEvent, createEventBuilder } = useMetrics(); const goToBrowserUrl = (url: string, title: string) => { - modalRef.current?.dismissModal(() => { + modalRef.current?.onCloseBottomSheet(() => { (async () => { if (await InAppBrowser.isAvailable()) { await InAppBrowser.open(url); @@ -168,7 +172,7 @@ const AssetOptions = (props: Props) => { }; const openTokenDetails = () => { - modalRef.current?.dismissModal(() => { + modalRef.current?.onCloseBottomSheet(() => { // Extract the actual token address from CAIP format only for non-EVM chains const tokenAddress = isNonEvmChainId(networkId) ? extractTokenAddressFromCaip(address) @@ -317,8 +321,8 @@ const AssetOptions = (props: Props) => { return ( - - {label} + + {label} ); @@ -328,12 +332,14 @@ const AssetOptions = (props: Props) => { }; return ( - - - - {renderOptions()} - - + + modalRef.current?.onCloseBottomSheet()}> + + {strings('asset_details.options.title')} + + + {renderOptions()} + ); }; diff --git a/locales/languages/en.json b/locales/languages/en.json index ea7d163e0aa..b640ed3a362 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -2355,6 +2355,7 @@ "lists": "Token Lists", "hide_cta": "Hide token", "options": { + "title": "Token options", "view_on_portfolio": "View on Portfolio", "view_on_block": "View on block explorer", "token_details": "Token details", From 0168677c44e2a04dfb74693f7aa8eda8d66d6466 Mon Sep 17 00:00:00 2001 From: sophieqgu <37032128+sophieqgu@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:16:54 -0500 Subject: [PATCH 05/12] chore: region selector modal for country state and area code select (#23876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** https://consensyssoftware.atlassian.net/browse/CARD-233 Region selector modal as well as additional issues in the onboarding flow ## **Changelog** CHANGELOG entry: Update region selector ## **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** Simulator Screenshot - E2E Test -
2025-12-10 at 11 53 54 Simulator Screenshot - E2E Test -
2025-12-10 at 11 53 33 ## **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. ## **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] > Introduce a region selector bottom sheet and migrate onboarding to Region objects, updating flows, routing, and validations across sign-up, phone, personal/physical/mailing address, plus consent handling and responsive styles. > > - **Onboarding UX**: > - **Region Selector Modal**: New `RegionSelectorModal` (with search, area code display, and selection callback) wired via `Routes.CARD.MODALS.REGION_SELECTION` and modal navigator. > - **Refactor Selects → Modal**: Replace dropdowns with modal selectors in `SignUp`, `SetPhoneNumber`, `PersonalDetails` (nationality), and `PhysicalAddress` (US state). > - **Region Model**: Switch `selectedCountry` from `string` to `Region` (`key`, `name`, `emoji`, `areaCode`); update validations and payloads (e.g., `countryOfResidence`, mapping, SSN/consent logic). > - **Routing**: Revise `OnboardingNavigator` initial route logic (UNVERIFIED→`SIGN_UP`, PENDING branching for phone/personal/address → `VERIFY_IDENTITY`, VERIFIED→`COMPLETE`) and fetch user on mount. > - **Consent & Tokens**: Harden consent creation/linking flow and store Baanx token using `mapCountryToLocation(selectedCountry?.key)`; add US-only electronic consent gating. > - **Buttons/UX**: Add `loading` to continue buttons; improve error resets on input change. > - **Redux**: > - Update `onboarding.selectedCountry` type to `Region`; add `resetAuthenticatedData`; keep selectors aligned. > - **Views/Styles**: > - `CardWelcome` styles made responsive via `useWindowDimensions`; `CardAuthentication` sign-up now deep-links to `ONBOARDING.SIGN_UP`. > - **i18n**: > - Add strings for region selector title and empty-state message. > - **Tests**: > - Extensive updates for new Region shape, modal behavior, routing changes, and consent handling across onboarding components. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e3d5941069825e2755c2567be77478f95026ab7a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Bruno Nascimento Co-authored-by: Bruno Nascimento --- .../CardAuthentication/CardAuthentication.tsx | 6 +- .../Views/CardWelcome/CardWelcome.styles.ts | 59 +- .../UI/Card/Views/CardWelcome/CardWelcome.tsx | 5 +- .../components/Onboarding/ConfirmEmail.tsx | 3 +- .../Onboarding/ConfirmPhoneNumber.tsx | 1 + .../Onboarding/MailingAddress.test.tsx | 461 ++++++++--- .../components/Onboarding/MailingAddress.tsx | 6 +- .../Onboarding/PersonalDetails.test.tsx | 176 +++-- .../components/Onboarding/PersonalDetails.tsx | 100 ++- .../Onboarding/PhysicalAddress.test.tsx | 225 ++++-- .../components/Onboarding/PhysicalAddress.tsx | 176 +++-- .../Onboarding/RegionSelectorModal.test.tsx | 735 ++++++++++++++++++ .../Onboarding/RegionSelectorModal.tsx | 247 ++++++ .../Onboarding/SetPhoneNumber.test.tsx | 116 ++- .../components/Onboarding/SetPhoneNumber.tsx | 76 +- .../components/Onboarding/SignUp.test.tsx | 184 ++--- .../UI/Card/components/Onboarding/SignUp.tsx | 122 +-- .../Card/hooks/useRegisterUserConsent.test.ts | 31 +- .../UI/Card/hooks/useRegisterUserConsent.ts | 2 +- .../Card/routes/OnboardingNavigator.test.tsx | 101 +-- .../UI/Card/routes/OnboardingNavigator.tsx | 28 +- app/components/UI/Card/routes/index.tsx | 5 + app/constants/navigation/Routes.ts | 1 + app/core/redux/slices/card/index.test.ts | 157 +++- app/core/redux/slices/card/index.ts | 5 +- locales/languages/en.json | 4 + 26 files changed, 2315 insertions(+), 717 deletions(-) create mode 100644 app/components/UI/Card/components/Onboarding/RegionSelectorModal.test.tsx create mode 100644 app/components/UI/Card/components/Onboarding/RegionSelectorModal.tsx diff --git a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx index 5311fb82542..665c69c1434 100644 --- a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx +++ b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx @@ -600,7 +600,11 @@ const CardAuthentication = () => { disabled={isLoginDisabled || loading} /> navigation.navigate(Routes.CARD.ONBOARDING.ROOT)} + onPress={() => + navigation.navigate(Routes.CARD.ONBOARDING.ROOT, { + screen: Routes.CARD.ONBOARDING.SIGN_UP, + }) + } > { + const { width: screenWidth, height: screenHeight } = dimensions; + + const widthScale = screenWidth / BASE_WIDTH; + const heightScale = screenHeight / baseHeight; -// Use more conservative scaling to prevent excessive padding -const scale = Math.min(widthScale, heightScale); -const conservativeScale = Math.min(scale, 1.2); // Cap scaling at 120% + // Use more conservative scaling to prevent excessive padding + const scale = Math.min(widthScale, heightScale); + const conservativeScale = Math.min(scale, 1.2); // Cap scaling at 120% -// Platform-aware responsive scaling functions -const scaleSize = (size: number) => Math.ceil(size * conservativeScale); -const scaleFont = (size: number) => Math.ceil(size * conservativeScale); + const scaleSize = (size: number) => Math.ceil(size * conservativeScale); + const scaleFont = (size: number) => Math.ceil(size * conservativeScale); -// For vertical spacing, use percentage of available height instead of pure scaling -const scaleVertical = (size: number) => { - // Use percentage of screen height for more consistent spacing - const percentage = size / baseHeight; - return Math.ceil(screenHeight * percentage); + // For vertical spacing, use percentage of available height instead of pure scaling + const scaleVertical = (size: number) => { + // Use percentage of screen height for more consistent spacing + const percentage = size / baseHeight; + return Math.ceil(screenHeight * percentage); + }; + + const scaleHorizontal = (size: number) => Math.ceil(size * widthScale); + + return { + screenWidth, + screenHeight, + scaleSize, + scaleFont, + scaleVertical, + scaleHorizontal, + }; }; -const scaleHorizontal = (size: number) => Math.ceil(size * widthScale); +const createStyles = (theme: Theme, dimensions: WindowDimensions) => { + const { screenHeight, scaleSize, scaleFont, scaleVertical, scaleHorizontal } = + createScalingFunctions(dimensions); -const createStyles = (theme: Theme) => - StyleSheet.create({ + return StyleSheet.create({ pageContainer: { flex: 1, position: 'relative', @@ -117,5 +135,6 @@ const createStyles = (theme: Theme) => fontSize: scaleFont(16), }, }); +}; export default createStyles; diff --git a/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx b/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx index 22ea8d30001..166b404fa2e 100644 --- a/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx +++ b/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx @@ -1,6 +1,6 @@ import { useNavigation } from '@react-navigation/native'; import React, { useCallback, useEffect } from 'react'; -import { Image, View } from 'react-native'; +import { Image, View, useWindowDimensions } from 'react-native'; import { strings } from '../../../../../../locales/i18n'; import Button, { @@ -28,7 +28,8 @@ const CardWelcome = () => { const { navigate } = useNavigation(); const hasCardholderAccounts = useSelector(selectHasCardholderAccounts); const theme = useTheme(); - const styles = createStyles(theme); + const dimensions = useWindowDimensions(); + const styles = createStyles(theme, dimensions); useEffect(() => { trackEvent( diff --git a/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx b/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx index c7a87b91cc2..6fe5f9e76eb 100644 --- a/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx +++ b/app/components/UI/Card/components/Onboarding/ConfirmEmail.tsx @@ -146,7 +146,7 @@ const ConfirmEmail = () => { password, verificationCode: confirmCode, contactVerificationId, - countryOfResidence: selectedCountry, + countryOfResidence: selectedCountry?.key || '', allowMarketing: true, allowSms: true, }); @@ -314,6 +314,7 @@ const ConfirmEmail = () => { onPress={handleContinue} width={ButtonWidthTypes.Full} isDisabled={isDisabled} + loading={verifyLoading} testID="confirm-email-continue-button" /> ); diff --git a/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx b/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx index 1f774b06e47..87111450acd 100644 --- a/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx +++ b/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx @@ -316,6 +316,7 @@ const ConfirmPhoneNumber = () => { onPress={handleContinue} width={ButtonWidthTypes.Full} isDisabled={isDisabled} + loading={verifyLoading} testID="confirm-phone-number-continue-button" /> ); diff --git a/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx b/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx index 600f1bc1a90..0649f616fb5 100644 --- a/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx +++ b/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx @@ -87,11 +87,24 @@ jest.mock('@metamask/design-system-react-native', () => { }: React.PropsWithChildren>) => React.createElement(RNText, props, children); + const Icon = ({ name, size, ...props }: { name: string; size: string }) => + React.createElement(View, { testID: 'icon', ...props }); + return { Box, Text, + Icon, TextVariant: { BodySm: 'BodySm', + BodyMd: 'BodyMd', + }, + IconName: { + ArrowDown: 'arrow-down', + }, + IconSize: { + Sm: 'sm', + Md: 'md', + Lg: 'lg', }, }; }); @@ -254,41 +267,6 @@ jest.mock('../../../../../component-library/components/Buttons/Button', () => { }; }); -// Mock SelectComponent -jest.mock('../../../SelectComponent', () => { - const React = jest.requireActual('react'); - const { TouchableOpacity, Text } = jest.requireActual('react-native'); - - return ({ - testID, - onValueChange, - options, - selectedValue, - defaultValue, - }: { - testID?: string; - onValueChange?: (value: string) => void; - options?: { key: string; value: string; label: string }[]; - selectedValue?: string; - defaultValue?: string; - }) => { - const handlePress = () => { - if (options && options.length > 0 && onValueChange) { - onValueChange(options[0].value); - } - }; - - return React.createElement( - TouchableOpacity, - { - testID, - onPress: handlePress, - }, - React.createElement(Text, {}, selectedValue || defaultValue || 'Select'), - ); - }; -}); - // Mock utility functions jest.mock('../../util/cardTokenVault'); jest.mock('../../util/mapCountryToLocation'); @@ -303,6 +281,10 @@ jest.mock('../../../../../constants/navigation/Routes', () => ({ COMPLETE: 'CardOnboardingComplete', SIGN_UP: 'CardOnboardingSignUp', }, + MODALS: { + ID: 'CardModals', + REGION_SELECTION: 'RegionSelection', + }, }, })); @@ -362,7 +344,12 @@ const createTestStore = (initialState = {}) => card: ( state = { onboarding: { - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, onboardingId: 'test-id', contactVerificationId: 'contact-id', user: { @@ -527,7 +514,12 @@ describe('MailingAddress Component', () => { selector({ card: { onboarding: { - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, onboardingId: 'test-id', user: { id: 'user-id', @@ -682,6 +674,28 @@ describe('MailingAddress Component', () => { }); it('enables continue button when all required fields are filled', async () => { + // Use non-US user to avoid state requirement + const { useSelector } = jest.requireMock('react-redux'); + useSelector.mockImplementation((selector: any) => + selector({ + card: { + onboarding: { + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, + onboardingId: 'test-id', + user: { + id: 'user-id', + email: 'test@example.com', + }, + }, + }, + }), + ); + const { getByTestId } = render( @@ -689,9 +703,8 @@ describe('MailingAddress Component', () => { ); fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); - fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); - fireEvent.changeText(getByTestId('zip-code-input'), '12345'); - fireEvent.press(getByTestId('state-select')); + fireEvent.changeText(getByTestId('city-input'), 'Toronto'); + fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2'); await waitFor(() => { const button = getByTestId('mailing-address-continue-button'); @@ -752,7 +765,12 @@ describe('MailingAddress Component', () => { selector({ card: { onboarding: { - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, onboardingId: 'test-id', }, }, @@ -774,7 +792,12 @@ describe('MailingAddress Component', () => { selector({ card: { onboarding: { - selectedCountry: 'CA', + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, onboardingId: 'test-id', }, }, @@ -812,7 +835,12 @@ describe('MailingAddress Component', () => { selector({ card: { onboarding: { - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, onboardingId: 'test-onboarding-id', }, }, @@ -966,7 +994,12 @@ describe('MailingAddress Component', () => { selector({ card: { onboarding: { - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, onboardingId: null, }, }, @@ -1107,7 +1140,29 @@ describe('MailingAddress Component', () => { expect(mockRegisterAddress).not.toHaveBeenCalled(); }); - it('calls registerAddress with correct parameters for US users', async () => { + it('calls registerAddress with correct parameters for non-US users', async () => { + // Use non-US user to avoid state requirement via modal + const { useSelector } = jest.requireMock('react-redux'); + useSelector.mockImplementation((selector: any) => + selector({ + card: { + onboarding: { + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, + onboardingId: 'test-id', + user: { + id: 'user-id', + email: 'test@example.com', + }, + }, + }, + }), + ); + mockUseRegisterMailingAddress.mockReturnValue({ registerAddress: mockRegisterAddress, isLoading: false, @@ -1123,7 +1178,7 @@ describe('MailingAddress Component', () => { user: { id: 'user-123', email: 'test@example.com' }, }); - mockMapCountryToLocation.mockReturnValue('us'); + mockMapCountryToLocation.mockReturnValue('intl'); mockExtractTokenExpiration.mockReturnValue(3600000); mockStoreCardBaanxToken.mockResolvedValue({ success: true }); @@ -1135,9 +1190,8 @@ describe('MailingAddress Component', () => { fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); fireEvent.changeText(getByTestId('address-line-2-input'), 'Apt 4B'); - fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); - fireEvent.changeText(getByTestId('zip-code-input'), '94102'); - fireEvent.press(getByTestId('state-select')); + fireEvent.changeText(getByTestId('city-input'), 'Toronto'); + fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2'); const button = getByTestId('mailing-address-continue-button'); @@ -1150,9 +1204,9 @@ describe('MailingAddress Component', () => { onboardingId: 'test-id', addressLine1: '123 Main St', addressLine2: 'Apt 4B', - city: 'San Francisco', - usState: 'CA', - zip: '94102', + city: 'Toronto', + usState: undefined, + zip: 'M5H 2N2', }); }); }); @@ -1163,7 +1217,12 @@ describe('MailingAddress Component', () => { selector({ card: { onboarding: { - selectedCountry: 'CA', + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, onboardingId: 'test-id', }, }, @@ -1218,6 +1277,28 @@ describe('MailingAddress Component', () => { }); it('updates user via setUser when registration returns updated user', async () => { + // Use non-US user to avoid state requirement + const { useSelector } = jest.requireMock('react-redux'); + useSelector.mockImplementation((selector: any) => + selector({ + card: { + onboarding: { + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, + onboardingId: 'test-id', + user: { + id: 'user-id', + email: 'test@example.com', + }, + }, + }, + }), + ); + const updatedUser = { id: 'user-123', email: 'updated@example.com' }; mockUseRegisterMailingAddress.mockReturnValue({ @@ -1235,7 +1316,7 @@ describe('MailingAddress Component', () => { user: updatedUser, }); - mockMapCountryToLocation.mockReturnValue('us'); + mockMapCountryToLocation.mockReturnValue('intl'); mockExtractTokenExpiration.mockReturnValue(3600000); mockStoreCardBaanxToken.mockResolvedValue({ success: true }); @@ -1246,9 +1327,8 @@ describe('MailingAddress Component', () => { ); fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); - fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); - fireEvent.changeText(getByTestId('zip-code-input'), '94102'); - fireEvent.press(getByTestId('state-select')); + fireEvent.changeText(getByTestId('city-input'), 'Toronto'); + fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2'); const button = getByTestId('mailing-address-continue-button'); @@ -1262,6 +1342,28 @@ describe('MailingAddress Component', () => { }); it('stores access token and dispatches Redux actions on success', async () => { + // Use non-US user to avoid state requirement + const { useSelector } = jest.requireMock('react-redux'); + useSelector.mockImplementation((selector: any) => + selector({ + card: { + onboarding: { + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, + onboardingId: 'test-id', + user: { + id: 'user-id', + email: 'test@example.com', + }, + }, + }, + }), + ); + mockUseRegisterMailingAddress.mockReturnValue({ registerAddress: mockRegisterAddress, isLoading: false, @@ -1277,7 +1379,7 @@ describe('MailingAddress Component', () => { user: { id: 'user-123', email: 'test@example.com' }, }); - mockMapCountryToLocation.mockReturnValue('us'); + mockMapCountryToLocation.mockReturnValue('intl'); mockExtractTokenExpiration.mockReturnValue(3600000); mockStoreCardBaanxToken.mockResolvedValue({ success: true }); @@ -1288,9 +1390,8 @@ describe('MailingAddress Component', () => { ); fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); - fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); - fireEvent.changeText(getByTestId('zip-code-input'), '94102'); - fireEvent.press(getByTestId('state-select')); + fireEvent.changeText(getByTestId('city-input'), 'Toronto'); + fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2'); const button = getByTestId('mailing-address-continue-button'); @@ -1302,12 +1403,34 @@ describe('MailingAddress Component', () => { expect(mockStoreCardBaanxToken).toHaveBeenCalledWith({ accessToken: 'test-access-token', accessTokenExpiresAt: 3600000, - location: 'us', + location: 'intl', }); }); }); it('navigates to complete screen after successful registration', async () => { + // Use non-US user to avoid state requirement + const { useSelector } = jest.requireMock('react-redux'); + useSelector.mockImplementation((selector: any) => + selector({ + card: { + onboarding: { + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, + onboardingId: 'test-id', + user: { + id: 'user-id', + email: 'test@example.com', + }, + }, + }, + }), + ); + mockUseRegisterMailingAddress.mockReturnValue({ registerAddress: mockRegisterAddress, isLoading: false, @@ -1323,7 +1446,7 @@ describe('MailingAddress Component', () => { user: { id: 'user-123', email: 'test@example.com' }, }); - mockMapCountryToLocation.mockReturnValue('us'); + mockMapCountryToLocation.mockReturnValue('intl'); mockExtractTokenExpiration.mockReturnValue(3600000); mockStoreCardBaanxToken.mockResolvedValue({ success: true }); @@ -1334,9 +1457,8 @@ describe('MailingAddress Component', () => { ); fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); - fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); - fireEvent.changeText(getByTestId('zip-code-input'), '94102'); - fireEvent.press(getByTestId('state-select')); + fireEvent.changeText(getByTestId('city-input'), 'Toronto'); + fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2'); const button = getByTestId('mailing-address-continue-button'); @@ -1357,6 +1479,28 @@ describe('MailingAddress Component', () => { }); it('navigates to sign up when Onboarding ID not found error occurs', async () => { + // Use non-US user to avoid state requirement + const { useSelector } = jest.requireMock('react-redux'); + useSelector.mockImplementation((selector: any) => + selector({ + card: { + onboarding: { + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, + onboardingId: 'test-id', + user: { + id: 'user-id', + email: 'test@example.com', + }, + }, + }, + }), + ); + const { CardError } = jest.requireMock('../../types'); mockUseRegisterMailingAddress.mockReturnValue({ @@ -1380,9 +1524,8 @@ describe('MailingAddress Component', () => { ); fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); - fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); - fireEvent.changeText(getByTestId('zip-code-input'), '94102'); - fireEvent.press(getByTestId('state-select')); + fireEvent.changeText(getByTestId('city-input'), 'Toronto'); + fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2'); const button = getByTestId('mailing-address-continue-button'); @@ -1396,6 +1539,28 @@ describe('MailingAddress Component', () => { }); it('allows error display for general registration errors', async () => { + // Use non-US user to avoid state requirement + const { useSelector } = jest.requireMock('react-redux'); + useSelector.mockImplementation((selector: any) => + selector({ + card: { + onboarding: { + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, + onboardingId: 'test-id', + user: { + id: 'user-id', + email: 'test@example.com', + }, + }, + }, + }), + ); + mockUseRegisterMailingAddress.mockReturnValue({ registerAddress: mockRegisterAddress, isLoading: false, @@ -1415,9 +1580,8 @@ describe('MailingAddress Component', () => { ); fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); - fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); - fireEvent.changeText(getByTestId('zip-code-input'), '94102'); - fireEvent.press(getByTestId('state-select')); + fireEvent.changeText(getByTestId('city-input'), 'Toronto'); + fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2'); const button = getByTestId('mailing-address-continue-button'); @@ -1486,19 +1650,6 @@ describe('MailingAddress Component', () => { expect(mockResetHandler).toHaveBeenCalled(); }); - it('calls reset when state changes', () => { - const { getByTestId } = render( - - - , - ); - - const input = getByTestId('state-select'); - fireEvent.press(input); - - expect(mockResetHandler).toHaveBeenCalled(); - }); - it('calls reset when zip code changes', () => { const { getByTestId } = render( @@ -1545,7 +1696,28 @@ describe('MailingAddress Component', () => { }); it('creates new consent when no existing consent found', async () => { - // Given: No consent exists and registration will succeed + // Given: No consent exists and registration will succeed (non-US user to avoid state requirement) + const { useSelector } = jest.requireMock('react-redux'); + useSelector.mockImplementation((selector: any) => + selector({ + card: { + onboarding: { + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, + onboardingId: 'test-id', + user: { + id: 'user-id', + email: 'test@example.com', + }, + }, + }, + }), + ); + mockGetOnboardingConsentSetByOnboardingId.mockResolvedValue(null); mockCreateOnboardingConsent.mockResolvedValue('new-consent-123'); mockRegisterAddress.mockResolvedValue({ @@ -1584,9 +1756,8 @@ describe('MailingAddress Component', () => { ); fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); - fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); - fireEvent.changeText(getByTestId('zip-code-input'), '94102'); - fireEvent.press(getByTestId('state-select')); + fireEvent.changeText(getByTestId('city-input'), 'Toronto'); + fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2'); const button = getByTestId('mailing-address-continue-button'); @@ -1615,7 +1786,28 @@ describe('MailingAddress Component', () => { }); it('reuses existing incomplete consent', async () => { - // Given: Incomplete consent exists + // Given: Incomplete consent exists (non-US user to avoid state requirement) + const { useSelector } = jest.requireMock('react-redux'); + useSelector.mockImplementation((selector: any) => + selector({ + card: { + onboarding: { + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, + onboardingId: 'test-id', + user: { + id: 'user-id', + email: 'test@example.com', + }, + }, + }, + }), + ); + mockGetOnboardingConsentSetByOnboardingId.mockResolvedValue({ consentSetId: 'existing-consent-456', userId: null, @@ -1657,9 +1849,8 @@ describe('MailingAddress Component', () => { ); fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); - fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); - fireEvent.changeText(getByTestId('zip-code-input'), '94102'); - fireEvent.press(getByTestId('state-select')); + fireEvent.changeText(getByTestId('city-input'), 'Toronto'); + fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2'); const button = getByTestId('mailing-address-continue-button'); @@ -1686,7 +1877,28 @@ describe('MailingAddress Component', () => { }); it('skips consent operations when consent already completed', async () => { - // Given: Completed consent exists + // Given: Completed consent exists (non-US user to avoid state requirement) + const { useSelector } = jest.requireMock('react-redux'); + useSelector.mockImplementation((selector: any) => + selector({ + card: { + onboarding: { + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, + onboardingId: 'test-id', + user: { + id: 'user-id', + email: 'test@example.com', + }, + }, + }, + }), + ); + mockGetOnboardingConsentSetByOnboardingId.mockResolvedValue({ consentSetId: 'completed-consent-789', userId: 'user-123', @@ -1728,9 +1940,8 @@ describe('MailingAddress Component', () => { ); fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); - fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); - fireEvent.changeText(getByTestId('zip-code-input'), '94102'); - fireEvent.press(getByTestId('state-select')); + fireEvent.changeText(getByTestId('city-input'), 'Toronto'); + fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2'); const button = getByTestId('mailing-address-continue-button'); @@ -1762,13 +1973,18 @@ describe('MailingAddress Component', () => { }); it('uses existing consent set ID from Redux when available', async () => { - // Given: Consent ID exists in Redux + // Given: Consent ID exists in Redux (non-US user to avoid state requirement) const { useSelector } = jest.requireMock('react-redux'); useSelector.mockImplementation((selector: any) => selector({ card: { onboarding: { - selectedCountry: 'US', + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, onboardingId: 'test-id', consentSetId: 'redux-consent-999', user: { @@ -1816,9 +2032,8 @@ describe('MailingAddress Component', () => { ); fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); - fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); - fireEvent.changeText(getByTestId('zip-code-input'), '94102'); - fireEvent.press(getByTestId('state-select')); + fireEvent.changeText(getByTestId('city-input'), 'Toronto'); + fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2'); const button = getByTestId('mailing-address-continue-button'); @@ -1840,7 +2055,28 @@ describe('MailingAddress Component', () => { }); it('clears consent set ID from Redux after linking consent', async () => { - // Given: No consent exists + // Given: No consent exists (non-US user to avoid state requirement) + const { useSelector } = jest.requireMock('react-redux'); + useSelector.mockImplementation((selector: any) => + selector({ + card: { + onboarding: { + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, + onboardingId: 'test-id', + user: { + id: 'user-id', + email: 'test@example.com', + }, + }, + }, + }), + ); + mockGetOnboardingConsentSetByOnboardingId.mockResolvedValue(null); mockCreateOnboardingConsent.mockResolvedValue('new-consent-123'); mockRegisterAddress.mockResolvedValue({ @@ -1879,9 +2115,8 @@ describe('MailingAddress Component', () => { ); fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); - fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); - fireEvent.changeText(getByTestId('zip-code-input'), '94102'); - fireEvent.press(getByTestId('state-select')); + fireEvent.changeText(getByTestId('city-input'), 'Toronto'); + fireEvent.changeText(getByTestId('zip-code-input'), 'M5H 2N2'); const button = getByTestId('mailing-address-continue-button'); @@ -1916,7 +2151,12 @@ describe('MailingAddress Component', () => { selector({ card: { onboarding: { - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, onboardingId: null, }, }, @@ -1944,7 +2184,12 @@ describe('MailingAddress Component', () => { selector({ card: { onboarding: { - selectedCountry: 'CA', + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, onboardingId: 'test-id', }, }, diff --git a/app/components/UI/Card/components/Onboarding/MailingAddress.tsx b/app/components/UI/Card/components/Onboarding/MailingAddress.tsx index 7ed112b52d1..49753dfc079 100644 --- a/app/components/UI/Card/components/Onboarding/MailingAddress.tsx +++ b/app/components/UI/Card/components/Onboarding/MailingAddress.tsx @@ -116,7 +116,7 @@ const MailingAddress = () => { !onboardingId || !addressLine1 || !city || - (!state && selectedCountry === 'US') || + (!state && selectedCountry?.key === 'US') || !zipCode, [ registerLoading, @@ -135,7 +135,7 @@ const MailingAddress = () => { !onboardingId || !addressLine1 || !city || - (!state && selectedCountry === 'US') || + (!state && selectedCountry?.key === 'US') || !zipCode ) { return; @@ -164,7 +164,7 @@ const MailingAddress = () => { if (accessToken && updatedUser?.id) { // Store the access token for immediate authentication - const location = mapCountryToLocation(selectedCountry); + const location = mapCountryToLocation(selectedCountry?.key || null); const accessTokenExpiresIn = extractTokenExpiration(accessToken); const storeResult = await storeCardBaanxToken({ diff --git a/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx b/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx index c0ae90ea1ea..1e0531efe83 100644 --- a/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx +++ b/app/components/UI/Card/components/Onboarding/PersonalDetails.test.tsx @@ -46,6 +46,16 @@ jest.mock('@metamask/design-system-react-native', () => { HeadingMd: 'HeadingMd', }; + const IconName = { + ArrowDown: 'arrow-down', + }; + + const IconSize = { + Sm: 'sm', + Md: 'md', + Lg: 'lg', + }; + return { Box: ({ children, @@ -63,7 +73,11 @@ jest.mock('@metamask/design-system-react-native', () => { children: React.ReactNode; testID?: string; }) => React.createElement(Text, { testID, ...props }, children), + Icon: ({ name, size, ...props }: { name: string; size: string }) => + React.createElement(View, { testID: 'icon', ...props }), TextVariant, + IconName, + IconSize, }; }); @@ -189,51 +203,40 @@ jest.mock('../../../../../component-library/components/Form/Label', () => { }) => React.createElement(Text, { testID }, children); }); -jest.mock('../../../SelectComponent', () => { - const React = jest.requireActual('react'); - const { View, Text } = jest.requireActual('react-native'); - - return ({ - testID, - options, - selectedValue, - onValueChange, - ...props - }: { - testID?: string; - options?: { label: string; value: string }[]; - selectedValue?: string; - onValueChange?: (value: string) => void; - }) => - React.createElement( - View, - { testID, ...props }, - React.createElement(Text, {}, `Selected: ${selectedValue || 'None'}`), - ); -}); - jest.mock('../../../Ramp/Deposit/components/DepositDateField', () => { const React = jest.requireActual('react'); const { TextInput } = jest.requireActual('react-native'); return ({ - testID, onChangeText, value, ...props }: { - testID?: string; onChangeText?: (text: string) => void; value?: string; }) => React.createElement(TextInput, { - testID, + testID: 'personal-details-date-of-birth-input', onChangeText, value, ...props, }); }); +// Mock RegionSelectorModal - setOnValueChange should immediately invoke the callback +const mockSetOnValueChange = jest.fn( + (callback: (region: { key: string }) => void) => { + // Immediately invoke with a mock region + callback({ key: 'US' }); + }, +); +jest.mock('./RegionSelectorModal', () => ({ + setOnValueChange: (callback: (region: { key: string }) => void) => + mockSetOnValueChange(callback), + clearOnValueChange: jest.fn(), + createRegionSelectorModalNavigationDetails: jest.fn(() => ['MockRoute', {}]), +})); + jest.mock('../../../../hooks/useDebouncedValue', () => ({ useDebouncedValue: (value: string) => value, })); @@ -324,6 +327,7 @@ const mockReset = jest.fn(); const mockDispatch = jest.fn(); const mockRegisterPersonalDetails = jest.fn(); const mockSetUser = jest.fn(); +const mockFetchUserData = jest.fn(); const mockTrackEvent = jest.fn(); const mockCreateEventBuilder = jest.fn(() => ({ addProperties: jest.fn().mockReturnThis(), @@ -343,7 +347,12 @@ const mockCreateEventBuilder = jest.fn(() => ({ card: { onboarding: { onboardingId: 'test-onboarding-id', - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, }, }, }; @@ -361,8 +370,8 @@ const mockCreateEventBuilder = jest.fn(() => ({ (useRegistrationSettings as jest.Mock).mockReturnValue({ data: { countries: [ - { code: 'US', name: 'United States' }, - { code: 'CA', name: 'Canada' }, + { iso3166alpha2: 'US', name: 'United States', callingCode: '1' }, + { iso3166alpha2: 'CA', name: 'Canada', callingCode: '1' }, ], }, }); @@ -372,6 +381,7 @@ const mockCreateEventBuilder = jest.fn(() => ({ isLoading: false, user: null, setUser: mockSetUser, + fetchUserData: mockFetchUserData, logoutFromProvider: jest.fn(), }); @@ -408,6 +418,12 @@ describe('PersonalDetails Component', () => { expect(queryByTestId('personal-details-ssn-error')).toBeNull(); expect(queryByTestId('personal-details-error')).toBeNull(); }); + + it('calls fetchUserData on mount', () => { + render(); + + expect(mockFetchUserData).toHaveBeenCalledTimes(1); + }); }); describe('Conditional SSN Field Rendering', () => { @@ -417,7 +433,12 @@ describe('PersonalDetails Component', () => { card: { onboarding: { onboardingId: 'test-onboarding-id', - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, }, }, }; @@ -435,7 +456,12 @@ describe('PersonalDetails Component', () => { card: { onboarding: { onboardingId: 'test-onboarding-id', - selectedCountry: 'CA', + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, }, }, }; @@ -489,7 +515,12 @@ describe('PersonalDetails Component', () => { card: { onboarding: { onboardingId: 'test-onboarding-id', - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, }, }, }; @@ -558,12 +589,13 @@ describe('PersonalDetails Component', () => { firstName: 'John', lastName: 'Doe', dateOfBirth: '2002-06-07T00:00:00.000Z', - countryOfResidence: 'US', + countryOfNationality: 'US', ssn: '123456789', }; (useCardSDK as jest.Mock).mockReturnValue({ user: mockUserData, setUser: mockSetUser, + fetchUserData: mockFetchUserData, logoutFromProvider: jest.fn(), }); @@ -580,12 +612,13 @@ describe('PersonalDetails Component', () => { firstName: 'Jane', lastName: 'Smith', dateOfBirth: '1995-03-15T00:00:00.000Z', - countryOfResidence: 'CA', + countryOfNationality: 'CA', ssn: '987654321', }; (useCardSDK as jest.Mock).mockReturnValue({ user: mockUserData, setUser: mockSetUser, + fetchUserData: mockFetchUserData, logoutFromProvider: jest.fn(), }); @@ -603,12 +636,13 @@ describe('PersonalDetails Component', () => { firstName: 'John', lastName: 'Doe', dateOfBirth: null, - countryOfResidence: 'US', + countryOfNationality: 'US', ssn: '123456789', }; (useCardSDK as jest.Mock).mockReturnValue({ user: mockUserData, setUser: mockSetUser, + fetchUserData: mockFetchUserData, logoutFromProvider: jest.fn(), }); @@ -623,12 +657,13 @@ describe('PersonalDetails Component', () => { firstName: 'John', lastName: 'Doe', dateOfBirth: '', - countryOfResidence: 'US', + countryOfNationality: 'US', ssn: '123456789', }; (useCardSDK as jest.Mock).mockReturnValue({ user: mockUserData, setUser: mockSetUser, + fetchUserData: mockFetchUserData, logoutFromProvider: jest.fn(), }); @@ -643,12 +678,13 @@ describe('PersonalDetails Component', () => { firstName: 'John', lastName: 'Doe', dateOfBirth: 'invalid-date', - countryOfResidence: 'US', + countryOfNationality: 'US', ssn: '123456789', }; (useCardSDK as jest.Mock).mockReturnValue({ user: mockUserData, setUser: mockSetUser, + fetchUserData: mockFetchUserData, logoutFromProvider: jest.fn(), }); @@ -663,12 +699,13 @@ describe('PersonalDetails Component', () => { firstName: 'John', lastName: 'Doe', dateOfBirth: 1234567890000, - countryOfResidence: 'US', + countryOfNationality: 'US', ssn: '123456789', }; (useCardSDK as jest.Mock).mockReturnValue({ user: mockUserData, setUser: mockSetUser, + fetchUserData: mockFetchUserData, logoutFromProvider: jest.fn(), }); @@ -683,12 +720,13 @@ describe('PersonalDetails Component', () => { firstName: 'John', lastName: 'Doe', dateOfBirth: '1990-12-25T00:00:00.000Z', - countryOfResidence: 'US', + countryOfNationality: 'US', ssn: '123456789', }; (useCardSDK as jest.Mock).mockReturnValue({ user: mockUserData, setUser: mockSetUser, + fetchUserData: mockFetchUserData, logoutFromProvider: jest.fn(), }); @@ -703,7 +741,7 @@ describe('PersonalDetails Component', () => { firstName: 'John', lastName: 'Doe', dateOfBirth: '1990-01-01T00:00:00.000Z', - countryOfResidence: 'US', + countryOfNationality: 'US', ssn: '123456789', }; (useSelector as jest.Mock).mockImplementation((selector) => { @@ -711,7 +749,12 @@ describe('PersonalDetails Component', () => { card: { onboarding: { onboardingId: 'test-onboarding-id', - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, }, }, }; @@ -720,6 +763,7 @@ describe('PersonalDetails Component', () => { (useCardSDK as jest.Mock).mockReturnValue({ user: mockUserData, setUser: mockSetUser, + fetchUserData: mockFetchUserData, logoutFromProvider: jest.fn(), }); @@ -737,7 +781,12 @@ describe('PersonalDetails Component', () => { card: { onboarding: { onboardingId: 'test-onboarding-id', - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, }, }, }; @@ -754,10 +803,18 @@ describe('PersonalDetails Component', () => { const firstNameInput = getByTestId('personal-details-first-name-input'); const lastNameInput = getByTestId('personal-details-last-name-input'); + const dateOfBirthInput = getByTestId( + 'personal-details-date-of-birth-input', + ); + const nationalitySelect = getByTestId( + 'personal-details-nationality-select', + ); const ssnInput = getByTestId('personal-details-ssn-input'); fireEvent.changeText(firstNameInput, 'John'); fireEvent.changeText(lastNameInput, 'Doe'); + fireEvent.changeText(dateOfBirthInput, '631152000000'); // Valid timestamp for 1990-01-01 + fireEvent.press(nationalitySelect); // Triggers setOnValueChange which sets nationalityKey fireEvent.changeText(ssnInput, '123456789'); const continueButton = getByTestId('personal-details-continue-button'); @@ -784,10 +841,18 @@ describe('PersonalDetails Component', () => { const firstNameInput = getByTestId('personal-details-first-name-input'); const lastNameInput = getByTestId('personal-details-last-name-input'); + const dateOfBirthInput = getByTestId( + 'personal-details-date-of-birth-input', + ); + const nationalitySelect = getByTestId( + 'personal-details-nationality-select', + ); const ssnInput = getByTestId('personal-details-ssn-input'); fireEvent.changeText(firstNameInput, 'John'); fireEvent.changeText(lastNameInput, 'Doe'); + fireEvent.changeText(dateOfBirthInput, '631152000000'); // Valid timestamp for 1990-01-01 + fireEvent.press(nationalitySelect); // Triggers setOnValueChange which sets nationalityKey fireEvent.changeText(ssnInput, '123456789'); const continueButton = getByTestId('personal-details-continue-button'); @@ -805,6 +870,7 @@ describe('PersonalDetails Component', () => { (useCardSDK as jest.Mock).mockReturnValue({ user: null, setUser: mockSetUser, + fetchUserData: mockFetchUserData, logoutFromProvider: jest.fn(), }); @@ -824,7 +890,12 @@ describe('PersonalDetails Component', () => { card: { onboarding: { onboardingId: null, - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, }, }, }; @@ -871,12 +942,13 @@ describe('PersonalDetails Component', () => { firstName: 'John', lastName: 'Doe', dateOfBirth: '1990-01-01T00:00:00.000Z', - countryOfResidence: 'US', + countryOfNationality: 'US', ssn: '123456789', }; (useCardSDK as jest.Mock).mockReturnValue({ user: mockUserData, setUser: mockSetUser, + fetchUserData: mockFetchUserData, logoutFromProvider: jest.fn(), }); @@ -922,10 +994,18 @@ describe('PersonalDetails Component', () => { const firstNameInput = getByTestId('personal-details-first-name-input'); const lastNameInput = getByTestId('personal-details-last-name-input'); + const dateOfBirthInput = getByTestId( + 'personal-details-date-of-birth-input', + ); + const nationalitySelect = getByTestId( + 'personal-details-nationality-select', + ); const ssnInput = getByTestId('personal-details-ssn-input'); fireEvent.changeText(firstNameInput, 'John'); fireEvent.changeText(lastNameInput, 'Doe'); + fireEvent.changeText(dateOfBirthInput, '631152000000'); // Valid timestamp for 1990-01-01 + fireEvent.press(nationalitySelect); // Triggers setOnValueChange which sets nationalityKey fireEvent.changeText(ssnInput, '123456789'); const continueButton = getByTestId('personal-details-continue-button'); @@ -939,6 +1019,7 @@ describe('PersonalDetails Component', () => { onboardingId: 'test-onboarding-id', firstName: 'John', lastName: 'Doe', + dateOfBirth: expect.any(String), }), ); }); @@ -949,7 +1030,12 @@ describe('PersonalDetails Component', () => { card: { onboarding: { onboardingId: 'test-onboarding-id', - selectedCountry: 'CA', + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, }, }, }; diff --git a/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx b/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx index 441e0d80149..58e679c70b3 100644 --- a/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx +++ b/app/components/UI/Card/components/Onboarding/PersonalDetails.tsx @@ -1,6 +1,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigation } from '@react-navigation/native'; -import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; +import { + Box, + Icon, + IconName, + IconSize, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; import Button, { ButtonSize, ButtonVariants, @@ -21,7 +28,6 @@ import { selectSelectedCountry, } from '../../../../../core/redux/slices/card'; import { useDispatch, useSelector } from 'react-redux'; -import SelectComponent from '../../../SelectComponent'; import useRegisterPersonalDetails from '../../hooks/useRegisterPersonalDetails'; import useRegistrationSettings from '../../hooks/useRegistrationSettings'; import { @@ -32,11 +38,19 @@ import { CardError } from '../../types'; import { useCardSDK } from '../../sdk'; import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; import { CardActions, CardScreens } from '../../util/metrics'; +import { countryCodeToFlag } from '../../util/countryCodeToFlag'; +import { + clearOnValueChange, + createRegionSelectorModalNavigationDetails, + Region, + setOnValueChange, +} from './RegionSelectorModal'; +import { TouchableOpacity } from 'react-native'; const PersonalDetails = () => { const navigation = useNavigation(); const dispatch = useDispatch(); - const { setUser, user: userData } = useCardSDK(); + const { setUser, fetchUserData, user: userData } = useCardSDK(); const onboardingId = useSelector(selectOnboardingId); const selectedCountry = useSelector(selectSelectedCountry); const { trackEvent, createEventBuilder } = useMetrics(); @@ -44,13 +58,17 @@ const PersonalDetails = () => { const [lastName, setLastName] = useState(''); const [dateOfBirth, setDateOfBirth] = useState(''); const [dateError, setDateError] = useState(''); - const [nationality, setNationality] = useState(''); + const [nationalityKey, setNationalityKey] = useState(''); // ISO 3166-1 alpha-2 country code const [SSN, setSSN] = useState(''); const [isSSNError, setIsSSNError] = useState(false); // Get registration settings data const { data: registrationSettings } = useRegistrationSettings(); + useEffect(() => { + fetchUserData(); + }, [fetchUserData]); + // If user data is available, set the state values useEffect(() => { if (userData) { @@ -78,13 +96,12 @@ const PersonalDetails = () => { } else { setDateOfBirth(''); } - setNationality(userData.countryOfResidence || ''); + setNationalityKey(userData.countryOfNationality || ''); setSSN(userData.ssn || ''); } }, [userData]); - // Create select options from registration settings data - const selectOptions = useMemo(() => { + const regions: Region[] = useMemo(() => { if (!registrationSettings?.countries) { return []; } @@ -92,11 +109,17 @@ const PersonalDetails = () => { .sort((a, b) => a.name.localeCompare(b.name)) .map((country) => ({ key: country.iso3166alpha2, - value: country.iso3166alpha2, - label: country.name, + name: country.name, + emoji: countryCodeToFlag(country.iso3166alpha2), + areaCode: country.callingCode, })); }, [registrationSettings]); + const nationalityName = useMemo( + () => regions.find((region) => region.key === nationalityKey)?.name, + [regions, nationalityKey], + ); + const { registerPersonalDetails, isLoading: registerLoading, @@ -105,13 +128,17 @@ const PersonalDetails = () => { reset: resetRegisterPersonalDetails, } = useRegisterPersonalDetails(); - const handleNationalitySelect = useCallback( - (value: string) => { - resetRegisterPersonalDetails(); - setNationality(value); - }, - [resetRegisterPersonalDetails], - ); + const handleNationalitySelect = useCallback(() => { + resetRegisterPersonalDetails(); + setOnValueChange((region) => { + setNationalityKey(region.key); + }); + navigation.navigate( + ...createRegionSelectorModalNavigationDetails({ + regions, + }), + ); + }, [navigation, regions, resetRegisterPersonalDetails]); const handleDateOfBirthChange = useCallback( (timestamp: string) => { @@ -165,20 +192,22 @@ const PersonalDetails = () => { } else setDateError(''); }, [dateOfBirth]); + useEffect(() => () => clearOnValueChange(), []); + const handleContinue = async () => { if ( !onboardingId || !firstName || !lastName || !dateOfBirth || - !nationality || - (!SSN && selectedCountry === 'US') + !nationalityKey || + (!SSN && selectedCountry?.key === 'US') ) { return; } // Validate SSN before submitting if it's a US user - if (selectedCountry === 'US') { + if (selectedCountry?.key === 'US') { const isSSNValid = /^\d{9}$/.test(SSN); if (!isSSNValid) { setIsSSNError(true); @@ -191,7 +220,7 @@ const PersonalDetails = () => { createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) .addProperties({ action: CardActions.PERSONAL_DETAILS_BUTTON, - country_of_residence: selectedCountry, + country_of_residence: selectedCountry?.key, }) .build(), ); @@ -200,7 +229,7 @@ const PersonalDetails = () => { firstName, lastName, dateOfBirth: formatDateOfBirth(dateOfBirth), - countryOfNationality: nationality, + countryOfNationality: nationalityKey, ssn: SSN, }); @@ -238,7 +267,7 @@ const PersonalDetails = () => { const isDisabled = useMemo(() => { // Check the actual SSN value, not the debounced one const isSSNValid = - SSN && selectedCountry === 'US' ? /^\d{9}$/.test(SSN) : true; + SSN && selectedCountry?.key === 'US' ? /^\d{9}$/.test(SSN) : true; return ( registerLoading || @@ -246,8 +275,8 @@ const PersonalDetails = () => { !firstName || !lastName || !dateOfBirth || - !nationality || - (!SSN && selectedCountry === 'US') || + !nationalityKey || + (!SSN && selectedCountry?.key === 'US') || !isSSNValid || !!dateError || !onboardingId @@ -258,7 +287,7 @@ const PersonalDetails = () => { firstName, lastName, dateOfBirth, - nationality, + nationalityKey, SSN, selectedCountry, dateError, @@ -321,20 +350,22 @@ const PersonalDetails = () => { {strings('card.card_onboarding.personal_details.nationality_label')} - + > + + + {nationalityName || nationalityKey} + + + + {/* SSN */} - {selectedCountry === 'US' && ( + {selectedCountry?.key === 'US' && ( diff --git a/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx b/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx index 0dd002e4382..e2348d340bc 100644 --- a/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx +++ b/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx @@ -147,11 +147,24 @@ jest.mock('@metamask/design-system-react-native', () => { }: React.PropsWithChildren>) => React.createElement(RNText, props, children); + const Icon = ({ name, size, ...props }: { name: string; size: string }) => + React.createElement(View, { testID: 'icon', ...props }); + return { Box, Text, + Icon, TextVariant: { BodySm: 'BodySm', + BodyMd: 'BodyMd', + }, + IconName: { + ArrowDown: 'arrow-down', + }, + IconSize: { + Sm: 'sm', + Md: 'md', + Lg: 'lg', }, }; }); @@ -271,52 +284,6 @@ jest.mock('../../../../../component-library/components/Buttons/Button', () => { }; }); -// Mock SelectComponent -jest.mock('../../../SelectComponent', () => { - // eslint-disable-next-line @typescript-eslint/no-shadow - const React = jest.requireActual('react'); - const { TouchableOpacity, Text } = jest.requireActual('react-native'); - - return ({ - testID, - onValueChange, - options, - selectedValue, - defaultValue, - disabled, - }: { - testID?: string; - onValueChange?: (value: string) => void; - options?: { key: string; value: string; label: string }[]; - selectedValue?: string; - defaultValue?: string; - disabled?: boolean; - }) => { - const handlePress = () => { - if (!disabled && options && options.length > 0 && onValueChange) { - onValueChange(options[0].value); - } - }; - - // Find the label for the selected value - const selectedLabel = - options?.find((opt) => opt.value === selectedValue)?.label || - selectedValue || - defaultValue || - 'Select'; - - return React.createElement( - TouchableOpacity, - { - testID, - onPress: handlePress, - disabled, - }, - React.createElement(Text, {}, selectedLabel), - ); - }; -}); - // Mock i18n jest.mock('../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { @@ -366,7 +333,12 @@ const createTestStore = (initialState = {}) => card: ( state = { onboarding: { - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, onboardingId: 'test-id', contactVerificationId: 'contact-id', user: { @@ -530,7 +502,12 @@ describe('PhysicalAddress Component', () => { selector({ card: { onboarding: { - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, onboardingId: 'test-id', user: { id: 'user-id', @@ -593,16 +570,6 @@ describe('PhysicalAddress Component', () => { expect(getByTestId('state-select')).toBeTruthy(); }); - it('renders country field', () => { - const { getByTestId } = render( - - - , - ); - - expect(getByTestId('country-select')).toBeTruthy(); - }); - it('renders continue button', () => { const { getByTestId } = render( @@ -694,6 +661,20 @@ describe('PhysicalAddress Component', () => { }); it('enables continue button when all required fields are filled', async () => { + // Mock useCardSDK with user data that includes usState + mockUseCardSDK.mockReturnValue({ + sdk: null, + isLoading: false, + user: { + id: 'user-id', + email: 'test@example.com', + usState: 'CA', + }, + fetchUserData: jest.fn(), + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + }); + const { getByTestId } = render( @@ -704,7 +685,6 @@ describe('PhysicalAddress Component', () => { fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); fireEvent.changeText(getByTestId('zip-code-input'), '12345'); - fireEvent.press(getByTestId('state-select')); // Check the electronic consent checkbox fireEvent.press( getByTestId('physical-address-electronic-consent-checkbox'), @@ -718,6 +698,19 @@ describe('PhysicalAddress Component', () => { }); it('requires state for US users', () => { + // User has no usState set + mockUseCardSDK.mockReturnValue({ + sdk: null, + isLoading: false, + user: { + id: 'user-id', + email: 'test@example.com', + }, + fetchUserData: jest.fn(), + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + }); + const { getByTestId } = render( @@ -772,6 +765,20 @@ describe('PhysicalAddress Component', () => { reset: jest.fn(), }); + // Mock useCardSDK with user data that includes usState + mockUseCardSDK.mockReturnValue({ + sdk: null, + isLoading: false, + user: { + id: 'user-id', + email: 'test@example.com', + usState: 'CA', + }, + fetchUserData: jest.fn(), + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + }); + const { getByTestId } = render( @@ -781,7 +788,6 @@ describe('PhysicalAddress Component', () => { fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); fireEvent.changeText(getByTestId('zip-code-input'), '12345'); - fireEvent.press(getByTestId('state-select')); fireEvent.press( getByTestId('physical-address-electronic-consent-checkbox'), ); @@ -859,6 +865,20 @@ describe('PhysicalAddress Component', () => { reset: jest.fn(), }); + // Mock useCardSDK with user data that includes usState + mockUseCardSDK.mockReturnValue({ + sdk: null, + isLoading: false, + user: { + id: 'user-id', + email: 'test@example.com', + usState: 'CA', + }, + fetchUserData: jest.fn(), + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + }); + const { getByTestId } = render( @@ -868,7 +888,6 @@ describe('PhysicalAddress Component', () => { fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); fireEvent.changeText(getByTestId('zip-code-input'), '12345'); - fireEvent.press(getByTestId('state-select')); fireEvent.press( getByTestId('physical-address-electronic-consent-checkbox'), ); @@ -934,6 +953,20 @@ describe('PhysicalAddress Component', () => { reset: jest.fn(), }); + // Mock useCardSDK with user data that includes usState + mockUseCardSDK.mockReturnValue({ + sdk: null, + isLoading: false, + user: { + id: 'user-id', + email: 'test@example.com', + usState: 'CA', + }, + fetchUserData: jest.fn(), + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + }); + const { getByTestId } = render( @@ -943,7 +976,6 @@ describe('PhysicalAddress Component', () => { fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); fireEvent.changeText(getByTestId('zip-code-input'), '12345'); - fireEvent.press(getByTestId('state-select')); fireEvent.press( getByTestId('physical-address-electronic-consent-checkbox'), ); @@ -1014,6 +1046,20 @@ describe('PhysicalAddress Component', () => { reset: jest.fn(), }); + // Mock useCardSDK with user data that includes usState + mockUseCardSDK.mockReturnValue({ + sdk: null, + isLoading: false, + user: { + id: 'user-id', + email: 'test@example.com', + usState: 'CA', + }, + fetchUserData: jest.fn(), + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + }); + const { getByTestId } = render( @@ -1023,7 +1069,6 @@ describe('PhysicalAddress Component', () => { fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); fireEvent.changeText(getByTestId('zip-code-input'), '12345'); - fireEvent.press(getByTestId('state-select')); fireEvent.press( getByTestId('physical-address-electronic-consent-checkbox'), ); @@ -1163,7 +1208,12 @@ describe('PhysicalAddress Component', () => { selector({ card: { onboarding: { - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, onboardingId: 'test-id', }, }, @@ -1185,7 +1235,12 @@ describe('PhysicalAddress Component', () => { selector({ card: { onboarding: { - selectedCountry: 'CA', + selectedCountry: { + key: 'CA', + name: 'Canada', + emoji: '🇨🇦', + areaCode: '1', + }, onboardingId: 'test-id', }, }, @@ -1200,16 +1255,6 @@ describe('PhysicalAddress Component', () => { expect(queryByTestId('state-select')).toBeFalsy(); }); - - it('shows country field for all users', () => { - const { getByTestId } = render( - - - , - ); - - expect(getByTestId('country-select')).toBeTruthy(); - }); }); describe('Edge Cases', () => { @@ -1320,6 +1365,20 @@ describe('PhysicalAddress Component', () => { }); it('disables continue button when checkbox is unchecked', () => { + // Mock useCardSDK with user data that includes usState + mockUseCardSDK.mockReturnValue({ + sdk: null, + isLoading: false, + user: { + id: 'user-id', + email: 'test@example.com', + usState: 'CA', + }, + fetchUserData: jest.fn(), + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + }); + const { getByTestId } = render( @@ -1330,13 +1389,26 @@ describe('PhysicalAddress Component', () => { fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); fireEvent.changeText(getByTestId('zip-code-input'), '12345'); - fireEvent.press(getByTestId('state-select')); const button = getByTestId('physical-address-continue-button'); expect(button.props.disabled).toBe(true); }); it('enables continue button when checkbox is checked and all fields filled', async () => { + // Mock useCardSDK with user data that includes usState + mockUseCardSDK.mockReturnValue({ + sdk: null, + isLoading: false, + user: { + id: 'user-id', + email: 'test@example.com', + usState: 'CA', + }, + fetchUserData: jest.fn(), + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + }); + const { getByTestId } = render( @@ -1347,7 +1419,6 @@ describe('PhysicalAddress Component', () => { fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); fireEvent.changeText(getByTestId('zip-code-input'), '12345'); - fireEvent.press(getByTestId('state-select')); // Button should be disabled without checkbox const buttonBefore = getByTestId('physical-address-continue-button'); diff --git a/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx b/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx index b63cb1d85c6..2db42d20d63 100644 --- a/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx +++ b/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx @@ -1,6 +1,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigation } from '@react-navigation/native'; -import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; +import { + Box, + Icon, + IconName, + IconSize, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; import Button, { ButtonSize, ButtonVariants, @@ -22,12 +29,12 @@ import { selectSelectedCountry, setConsentSetId, setIsAuthenticatedCard, + setSelectedCountry, setUserCardLocation, } from '../../../../../core/redux/slices/card'; import useRegisterUserConsent from '../../hooks/useRegisterUserConsent'; import { CardError } from '../../types'; import useRegistrationSettings from '../../hooks/useRegistrationSettings'; -import SelectComponent from '../../../SelectComponent'; import { storeCardBaanxToken } from '../../util/cardTokenVault'; import { mapCountryToLocation } from '../../util/mapCountryToLocation'; import { extractTokenExpiration } from '../../util/extractTokenExpiration'; @@ -37,9 +44,13 @@ import { CardActions, CardScreens } from '../../util/metrics'; import { Linking, TouchableOpacity } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import Checkbox from '../../../../../component-library/components/Checkbox'; - -// No-op function for disabled SelectComponent -const noop = () => undefined; +import { + clearOnValueChange, + createRegionSelectorModalNavigationDetails, + Region, + setOnValueChange, +} from './RegionSelectorModal'; +import { countryCodeToFlag } from '../../util/countryCodeToFlag'; export const AddressFields = ({ addressLine1, @@ -64,10 +75,11 @@ export const AddressFields = ({ zipCode: string; handleZipCodeChange: (text: string) => void; }) => { + const navigation = useNavigation(); const { data: registrationSettings } = useRegistrationSettings(); const selectedCountry = useSelector(selectSelectedCountry); - const selectOptions = useMemo(() => { + const regions: Region[] = useMemo(() => { if (!registrationSettings?.usStates) { return []; } @@ -75,21 +87,23 @@ export const AddressFields = ({ .sort((a, b) => a.name.localeCompare(b.name)) .map((usState) => ({ key: usState.postalAbbreviation, - value: usState.postalAbbreviation, - label: usState.name, + name: usState.name, + emoji: countryCodeToFlag('US'), })); }, [registrationSettings]); - const countryOptions = useMemo(() => { - if (!registrationSettings?.countries) { - return []; - } - return registrationSettings.countries.map((country) => ({ - key: country.iso3166alpha2, - value: country.iso3166alpha2, - label: country.name, - })); - }, [registrationSettings]); + useEffect(() => () => clearOnValueChange(), []); + + const handleStateSelect = useCallback(() => { + setOnValueChange((region) => { + handleStateChange(region.key); + }); + navigation.navigate( + ...createRegionSelectorModalNavigationDetails({ + regions, + }), + ); + }, [handleStateChange, navigation, regions]); return ( <> @@ -155,21 +169,18 @@ export const AddressFields = ({ /> {/* State */} - {selectedCountry === 'US' && ( + {selectedCountry?.key === 'US' && ( - + + + {state} + + + )} @@ -192,21 +203,20 @@ export const AddressFields = ({ testID="zip-code-input" /> - {/* Country */} + {/* Country (read-only) */} - + + + {selectedCountry?.name} + + @@ -228,9 +238,39 @@ const PhysicalAddress = () => { const [state, setState] = useState(''); const [zipCode, setZipCode] = useState(''); const [electronicConsent, setElectronicConsent] = useState(false); - const { data: registrationSettings } = useRegistrationSettings(); + const regions: Region[] = useMemo(() => { + if (!registrationSettings?.countries) { + return []; + } + return [...registrationSettings.countries] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((country) => ({ + key: country.iso3166alpha2, + name: country.name, + emoji: countryCodeToFlag(country.iso3166alpha2), + areaCode: country.callingCode, + })); + }, [registrationSettings]); + + // If user data is available, set the state values + useEffect(() => { + if (user) { + setAddressLine1(user.addressLine1 || ''); + setAddressLine2(user.addressLine2 || ''); + setCity(user.city || ''); + setState(user.usState || ''); + setZipCode(user.zip || ''); + const country = regions.find( + (region) => region.key === user.countryOfResidence, + ); + if (country) { + dispatch(setSelectedCountry(country)); + } + } + }, [dispatch, regions, user]); + const eSignConsentDisclosureUSUrl = useMemo( () => registrationSettings?.links?.us?.eSignConsentDisclosure || '', [registrationSettings?.links?.us?.eSignConsentDisclosure], @@ -316,9 +356,9 @@ const PhysicalAddress = () => { !user?.id || !addressLine1 || !city || - (!state && selectedCountry === 'US') || + (!state && selectedCountry?.key === 'US') || !zipCode || - !electronicConsent, + (!electronicConsent && selectedCountry?.key === 'US'), [ registerLoading, registerIsError, @@ -341,9 +381,9 @@ const PhysicalAddress = () => { !user?.id || !addressLine1 || !city || - (selectedCountry === 'US' && !state) || + (!state && selectedCountry?.key === 'US') || !zipCode || - !electronicConsent + (!electronicConsent && selectedCountry?.key === 'US') ) { return; } @@ -403,7 +443,7 @@ const PhysicalAddress = () => { // If registration is complete (accessToken received), link consent to user if (accessToken && updatedUser?.id) { // Store the access token for immediate authentication - const location = mapCountryToLocation(selectedCountry); + const location = mapCountryToLocation(selectedCountry?.key || null); const accessTokenExpiresIn = extractTokenExpiration(accessToken); const storeResult = await storeCardBaanxToken({ @@ -470,35 +510,38 @@ const PhysicalAddress = () => { zipCode={zipCode} handleZipCodeChange={handleZipCodeChange} /> - - - {strings( - 'card.card_onboarding.physical_address.electronic_consent_1', - )} {strings( - 'card.card_onboarding.physical_address.electronic_consent_2', + 'card.card_onboarding.physical_address.electronic_consent_1', )} + + {strings( + 'card.card_onboarding.physical_address.electronic_consent_2', + )} + - - - } - style={tw.style('h-auto flex flex-row items-start')} - testID="physical-address-electronic-consent-checkbox" - /> + + } + style={tw.style('h-auto flex flex-row items-start')} + testID="physical-address-electronic-consent-checkbox" + /> + )} ); @@ -528,6 +571,7 @@ const PhysicalAddress = () => { onPress={handleContinue} width={ButtonWidthTypes.Full} isDisabled={isDisabled} + loading={registerLoading} testID="physical-address-continue-button" /> diff --git a/app/components/UI/Card/components/Onboarding/RegionSelectorModal.test.tsx b/app/components/UI/Card/components/Onboarding/RegionSelectorModal.test.tsx new file mode 100644 index 00000000000..6b1954c58c2 --- /dev/null +++ b/app/components/UI/Card/components/Onboarding/RegionSelectorModal.test.tsx @@ -0,0 +1,735 @@ +import React from 'react'; +import { render, fireEvent, waitFor, act } from '@testing-library/react-native'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import RegionSelectorModal, { + setOnValueChange, + clearOnValueChange, + Region, +} from './RegionSelectorModal'; +import { OnboardingState } from '../../../../../core/redux/slices/card'; + +// Mock navigation +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + }), +})); + +// Mock useParams +const mockUseParams = jest.fn(); +jest.mock('../../../../../util/navigation/navUtils', () => ({ + useParams: () => mockUseParams(), + createNavigationDetails: jest.fn( + (stackId, screenName) => (params?: unknown) => [ + stackId, + { screen: screenName, params }, + ], + ), +})); + +// Mock i18n +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn( + (key: string, params?: { [key: string]: string | number }) => { + const mockStrings: { [key: string]: string } = { + 'card.card_onboarding.region_selector.title': 'Select Region', + 'card.card_onboarding.errors.no_region_results': + 'No results found for "{searchString}"', + }; + + let result = mockStrings[key] || key; + if (params) { + Object.keys(params).forEach((param) => { + result = result.replace(`{${param}}`, String(params[param])); + }); + } + return result; + }, + ), +})); + +// Mock BottomSheet component +const mockOnCloseBottomSheet = jest.fn(); +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + + return React.forwardRef( + ( + { + children, + onClose, + testID, + }: { + children: React.ReactNode; + onClose?: () => void; + shouldNavigateBack?: boolean; + keyboardAvoidingViewEnabled?: boolean; + testID?: string; + }, + ref: React.Ref<{ onCloseBottomSheet: () => void }>, + ) => { + React.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: () => { + mockOnCloseBottomSheet(); + onClose?.(); + }, + })); + return React.createElement( + View, + { testID: testID || 'bottom-sheet' }, + children, + ); + }, + ); + }, +); + +// Mock BottomSheetHeader +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetHeader', + () => { + const React = jest.requireActual('react'); + const { View, TouchableOpacity } = jest.requireActual('react-native'); + + return ({ + children, + onClose, + }: { + children: React.ReactNode; + onClose?: () => void; + }) => + React.createElement( + View, + { testID: 'bottom-sheet-header' }, + children, + onClose && + React.createElement( + TouchableOpacity, + { testID: 'bottom-sheet-close-button', onPress: onClose }, + 'Close', + ), + ); + }, +); + +// Mock ListItemSelect +jest.mock( + '../../../../../component-library/components/List/ListItemSelect', + () => { + const React = jest.requireActual('react'); + const { TouchableOpacity } = jest.requireActual('react-native'); + + return ({ + children, + onPress, + isSelected, + testID, + }: { + children: React.ReactNode; + onPress: () => void; + isSelected?: boolean; + accessibilityRole?: string; + accessible?: boolean; + testID?: string; + }) => + React.createElement( + TouchableOpacity, + { + testID: testID || 'list-item-select', + onPress, + accessibilityState: { selected: isSelected }, + }, + children, + ); + }, +); + +// Mock ListItemColumn +jest.mock( + '../../../../../component-library/components/List/ListItemColumn', + () => { + const React = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + + const MockListItemColumn = ({ + children, + }: { + children: React.ReactNode; + widthType?: string; + }) => React.createElement(View, { testID: 'list-item-column' }, children); + + return { + __esModule: true, + default: MockListItemColumn, + WidthType: { + Fill: 'Fill', + }, + }; + }, +); + +// Mock TextFieldSearch +jest.mock( + '../../../../../component-library/components/Form/TextFieldSearch', + () => { + const React = jest.requireActual('react'); + const { TextInput, TouchableOpacity, View } = + jest.requireActual('react-native'); + + return ({ + value, + onChangeText, + onPressClearButton, + onFocus, + showClearButton, + testID, + }: { + value: string; + onChangeText: (text: string) => void; + onPressClearButton?: () => void; + onFocus?: () => void; + showClearButton?: boolean; + testID?: string; + }) => + React.createElement( + View, + { testID: 'search-field-container' }, + React.createElement(TextInput, { + testID: testID || 'search-input', + value, + onChangeText, + onFocus, + }), + showClearButton && + React.createElement( + TouchableOpacity, + { + testID: 'search-clear-button', + onPress: onPressClearButton, + }, + 'Clear', + ), + ); + }, +); + +// Mock design system components +jest.mock('@metamask/design-system-react-native', () => { + const React = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + + return { + Box: ({ + children, + testID, + twClassName, + ...props + }: { + children?: React.ReactNode; + testID?: string; + twClassName?: string; + flexDirection?: string; + alignItems?: string; + [key: string]: unknown; + }) => + React.createElement( + View, + { testID: testID || 'box', 'data-tw-class': twClassName, ...props }, + children, + ), + Text: ({ + children, + testID, + variant, + ...props + }: { + children?: React.ReactNode; + testID?: string; + variant?: string; + [key: string]: unknown; + }) => + React.createElement( + Text, + { testID: testID || 'text', 'data-variant': variant, ...props }, + children, + ), + TextVariant: { + HeadingMd: 'HeadingMd', + BodyLg: 'BodyLg', + BodyMd: 'BodyMd', + }, + BoxFlexDirection: { + Row: 'row', + Column: 'column', + }, + BoxAlignItems: { + Center: 'center', + }, + }; +}); + +// Mock FlatList from react-native-gesture-handler +jest.mock('react-native-gesture-handler', () => { + const RN = jest.requireActual('react-native'); + return { + ...jest.requireActual('react-native-gesture-handler'), + FlatList: RN.FlatList, + }; +}); + +// Create test store with correct nested structure +const createTestStore = (initialState: { onboarding?: OnboardingState } = {}) => + configureStore({ + reducer: { + card: ( + state = { + onboarding: { + selectedCountry: null, + onboardingId: null, + contactVerificationId: null, + ...initialState.onboarding, + }, + ...initialState, + }, + ) => state, + }, + }); + +// Helper to create mock regions +const createMockRegion = (overrides: Partial = {}): Region => ({ + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + ...overrides, +}); + +const createMockRegions = (): Region[] => [ + createMockRegion({ + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }), + createMockRegion({ key: 'CA', name: 'Canada', emoji: '🇨🇦', areaCode: '1' }), + createMockRegion({ + key: 'GB', + name: 'United Kingdom', + emoji: '🇬🇧', + areaCode: '44', + }), + createMockRegion({ key: 'DE', name: 'Germany', emoji: '🇩🇪', areaCode: '49' }), + createMockRegion({ key: 'FR', name: 'France', emoji: '🇫🇷', areaCode: '33' }), +]; + +describe('RegionSelectorModal', () => { + let store: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + store = createTestStore(); + + mockUseParams.mockReturnValue({ + regions: createMockRegions(), + renderAreaCode: false, + }); + }); + + afterEach(() => { + clearOnValueChange(); + jest.resetAllMocks(); + }); + + describe('Rendering', () => { + it('renders bottom sheet with header title', () => { + const { getByText, getByTestId } = render( + + + , + ); + + expect(getByTestId('region-selector-modal')).toBeTruthy(); + expect(getByText('Select Region')).toBeTruthy(); + }); + + it('renders search input field', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('region-selector-search-input')).toBeTruthy(); + }); + + it('renders region list with all regions', () => { + const { getAllByTestId } = render( + + + , + ); + + const regionItems = getAllByTestId('region-selector-item'); + expect(regionItems.length).toBe(5); + }); + + it('displays region emoji and name', () => { + const { getByText } = render( + + + , + ); + + expect(getByText('United States')).toBeTruthy(); + expect(getByText('🇺🇸')).toBeTruthy(); + }); + + it('displays area code when renderAreaCode is true', () => { + mockUseParams.mockReturnValue({ + regions: createMockRegions(), + renderAreaCode: true, + }); + + const { getAllByText, getByText } = render( + + + , + ); + + // US and Canada both have +1 area code, so we expect multiple matches + expect(getAllByText('(+1)').length).toBeGreaterThan(0); + expect(getByText('(+44)')).toBeTruthy(); + }); + + it('does not display area code when renderAreaCode is false', () => { + mockUseParams.mockReturnValue({ + regions: createMockRegions(), + renderAreaCode: false, + }); + + const { queryByText } = render( + + + , + ); + + expect(queryByText('(+1)')).toBeNull(); + expect(queryByText('(+44)')).toBeNull(); + }); + }); + + describe('Search Functionality', () => { + it('filters regions based on search text', async () => { + const { getByTestId, queryByText, getByText } = render( + + + , + ); + + const searchInput = getByTestId('region-selector-search-input'); + + await act(async () => { + fireEvent.changeText(searchInput, 'United'); + }); + + await waitFor(() => { + expect(getByText('United States')).toBeTruthy(); + expect(getByText('United Kingdom')).toBeTruthy(); + expect(queryByText('Germany')).toBeNull(); + }); + }); + + it('shows empty list message when no results found', async () => { + const { getByTestId } = render( + + + , + ); + + const searchInput = getByTestId('region-selector-search-input'); + + await act(async () => { + fireEvent.changeText(searchInput, 'XYZ'); + }); + + await waitFor(() => { + expect(getByTestId('region-selector-empty-list')).toBeTruthy(); + }); + }); + + it('clears search text when clear button is pressed', async () => { + const { getByTestId, getAllByTestId } = render( + + + , + ); + + const searchInput = getByTestId('region-selector-search-input'); + + await act(async () => { + fireEvent.changeText(searchInput, 'Germany'); + }); + + const clearButton = getByTestId('search-clear-button'); + + await act(async () => { + fireEvent.press(clearButton); + }); + + await waitFor(() => { + const regionItems = getAllByTestId('region-selector-item'); + expect(regionItems.length).toBe(5); + }); + }); + + it('shows clear button only when search text is present', async () => { + const { getByTestId, queryByTestId } = render( + + + , + ); + + expect(queryByTestId('search-clear-button')).toBeNull(); + + const searchInput = getByTestId('region-selector-search-input'); + + await act(async () => { + fireEvent.changeText(searchInput, 'Test'); + }); + + expect(getByTestId('search-clear-button')).toBeTruthy(); + }); + }); + + describe('Region Selection', () => { + it('calls onValueChange callback when region is pressed', async () => { + const mockCallback = jest.fn(); + setOnValueChange(mockCallback); + + const { getByText } = render( + + + , + ); + + await act(async () => { + fireEvent.press(getByText('Germany')); + }); + + expect(mockCallback).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'DE', + name: 'Germany', + emoji: '🇩🇪', + }), + ); + }); + + it('closes bottom sheet when region is selected', async () => { + const mockCallback = jest.fn(); + setOnValueChange(mockCallback); + + const { getByText } = render( + + + , + ); + + await act(async () => { + fireEvent.press(getByText('Canada')); + }); + + expect(mockOnCloseBottomSheet).toHaveBeenCalled(); + }); + + it('handles selection when callback is not set', async () => { + clearOnValueChange(); + + const { getByText } = render( + + + , + ); + + await act(async () => { + fireEvent.press(getByText('France')); + }); + + expect(mockOnCloseBottomSheet).toHaveBeenCalled(); + }); + }); + + describe('Header Interactions', () => { + it('closes bottom sheet when header close button is pressed', async () => { + const { getByTestId } = render( + + + , + ); + + const closeButton = getByTestId('bottom-sheet-close-button'); + + await act(async () => { + fireEvent.press(closeButton); + }); + + expect(mockOnCloseBottomSheet).toHaveBeenCalled(); + }); + }); + + describe('Edge Cases', () => { + it('handles empty regions array', () => { + mockUseParams.mockReturnValue({ + regions: [], + renderAreaCode: false, + }); + + const { queryByTestId, getByTestId } = render( + + + , + ); + + expect(queryByTestId('region-selector-item')).toBeNull(); + expect(getByTestId('region-selector-modal')).toBeTruthy(); + }); + + it('handles null regions parameter', () => { + mockUseParams.mockReturnValue({ + regions: null, + renderAreaCode: false, + }); + + const { queryByTestId } = render( + + + , + ); + + expect(queryByTestId('region-selector-item')).toBeNull(); + }); + + it('handles undefined regions parameter', () => { + mockUseParams.mockReturnValue({ + regions: undefined, + renderAreaCode: false, + }); + + const { queryByTestId } = render( + + + , + ); + + expect(queryByTestId('region-selector-item')).toBeNull(); + }); + + it('handles region with missing emoji', () => { + mockUseParams.mockReturnValue({ + regions: [ + createMockRegion({ + key: 'XX', + name: 'Test Country', + emoji: undefined, + }), + ], + renderAreaCode: false, + }); + + const { getByText, queryByTestId } = render( + + + , + ); + + expect(getByText('Test Country')).toBeTruthy(); + expect(queryByTestId('region-selector-item')).toBeTruthy(); + }); + + it('does not render area code when areaCode is undefined', () => { + mockUseParams.mockReturnValue({ + regions: [ + createMockRegion({ + key: 'XX', + name: 'Test Country', + areaCode: undefined, + }), + ], + renderAreaCode: true, + }); + + const { getByText, queryByTestId } = render( + + + , + ); + + expect(getByText('Test Country')).toBeTruthy(); + expect(queryByTestId('region-selector-item-area-code')).toBeNull(); + }); + }); + + describe('Callback Registry', () => { + it('setOnValueChange sets callback correctly', () => { + const mockCallback = jest.fn(); + setOnValueChange(mockCallback); + + const { getByText } = render( + + + , + ); + + fireEvent.press(getByText('United States')); + + expect(mockCallback).toHaveBeenCalled(); + }); + + it('clearOnValueChange removes callback', () => { + const mockCallback = jest.fn(); + setOnValueChange(mockCallback); + clearOnValueChange(); + + const { getByText } = render( + + + , + ); + + fireEvent.press(getByText('United States')); + + expect(mockCallback).not.toHaveBeenCalled(); + }); + }); + + describe('Sorting', () => { + it('sorts regions alphabetically when no search is performed', () => { + mockUseParams.mockReturnValue({ + regions: [ + createMockRegion({ key: 'ZW', name: 'Zimbabwe' }), + createMockRegion({ key: 'AL', name: 'Albania' }), + createMockRegion({ key: 'MX', name: 'Mexico' }), + ], + renderAreaCode: false, + }); + + const { getAllByTestId } = render( + + + , + ); + + const regionItems = getAllByTestId('region-selector-item-name'); + expect(regionItems[0]).toHaveTextContent('Albania'); + expect(regionItems[1]).toHaveTextContent('Mexico'); + expect(regionItems[2]).toHaveTextContent('Zimbabwe'); + }); + }); +}); diff --git a/app/components/UI/Card/components/Onboarding/RegionSelectorModal.tsx b/app/components/UI/Card/components/Onboarding/RegionSelectorModal.tsx new file mode 100644 index 00000000000..cb02d2744e3 --- /dev/null +++ b/app/components/UI/Card/components/Onboarding/RegionSelectorModal.tsx @@ -0,0 +1,247 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useWindowDimensions } from 'react-native'; +import { FlatList } from 'react-native-gesture-handler'; +import Fuse from 'fuse.js'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import ListItemSelect from '../../../../../component-library/components/List/ListItemSelect'; +import ListItemColumn, { + WidthType, +} from '../../../../../component-library/components/List/ListItemColumn'; +import TextFieldSearch from '../../../../../component-library/components/Form/TextFieldSearch'; +import { + createNavigationDetails, + useParams, +} from '../../../../../util/navigation/navUtils'; +import Routes from '../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../locales/i18n'; +import { useSelector } from 'react-redux'; +import { selectSelectedCountry } from '../../../../../core/redux/slices/card'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; + +const MAX_REGION_RESULTS = 20; + +export interface Region { + key: string; // country code + name: string; + emoji?: string; + areaCode?: string; +} + +// Simple callback registry for onValueChange +let onValueChangeCallback: ((region: Region) => void) | null = null; + +export const setOnValueChange = (callback: (region: Region) => void) => { + onValueChangeCallback = callback; +}; + +export const clearOnValueChange = () => { + onValueChangeCallback = null; +}; + +interface RegionSelectorModalParams { + regions: Region[]; + renderAreaCode?: boolean; +} + +export const createRegionSelectorModalNavigationDetails = + createNavigationDetails( + Routes.CARD.MODALS.ID, + Routes.CARD.MODALS.REGION_SELECTION, + ); + +function RegionSelectorModal() { + const sheetRef = useRef(null); + const listRef = useRef>(null); + const { regions, renderAreaCode } = useParams(); + const [searchString, setSearchString] = useState(''); + const selectedCountry = useSelector(selectSelectedCountry); + const [currentData, setCurrentData] = useState(regions || []); + const { height: screenHeight } = useWindowDimensions(); + + // Sync currentData when regions param changes + useEffect(() => { + setCurrentData(regions || []); + }, [regions]); + + const listStyle = useMemo( + () => ({ height: screenHeight * 0.65 }), + [screenHeight], + ); + + const fuseData = useMemo( + () => + new Fuse(currentData, { + shouldSort: true, + threshold: 0.2, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: ['name'], + }), + [currentData], + ); + + const dataSearchResults = useMemo(() => { + if (searchString.length > 0) { + const results = fuseData + .search(searchString) + ?.slice(0, MAX_REGION_RESULTS); + + const mappedResults: Region[] = + results + ?.map((result) => + typeof result === 'object' && result !== null && 'item' in result + ? result.item + : result, + ) + .filter((item): item is Region => Boolean(item)) || []; + + return mappedResults; + } + + if (!currentData?.length) return []; + + return [...currentData].sort((a, b) => a.name.localeCompare(b.name)); + }, [searchString, fuseData, currentData]); + + const scrollToTop = useCallback(() => { + if (listRef?.current) { + listRef.current.scrollToOffset({ + animated: false, + offset: 0, + }); + } + }, []); + + const handleOnRegionPressCallback = useCallback((region: Region) => { + onValueChangeCallback?.(region); + sheetRef.current?.onCloseBottomSheet(); + }, []); + + const renderRegionItem = useCallback( + ({ item: region }: { item: Region }) => { + if (!region) return null; + + return ( + handleOnRegionPressCallback(region)} + accessibilityRole="button" + accessible + testID="region-selector-item" + > + + + + {region.emoji} + + + {region.name} + + {renderAreaCode && region.areaCode && ( + + (+{region.areaCode}) + + )} + + + + ); + }, + [selectedCountry, renderAreaCode, handleOnRegionPressCallback], + ); + + const renderEmptyList = useCallback( + () => ( + + + {strings('card.card_onboarding.errors.no_region_results', { + searchString, + })} + + + ), + [searchString], + ); + + const handleSearchTextChange = useCallback( + (text: string) => { + setSearchString(text); + scrollToTop(); + }, + [scrollToTop], + ); + + const clearSearchText = useCallback(() => { + setSearchString(''); + scrollToTop(); + }, [scrollToTop]); + + const handleClose = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + const onModalHide = useCallback(() => { + setCurrentData(regions || []); + setSearchString(''); + }, [regions]); + + return ( + + + + {strings('card.card_onboarding.region_selector.title')} + + + + 0} + onPressClearButton={clearSearchText} + onFocus={scrollToTop} + onChangeText={handleSearchTextChange} + testID="region-selector-search-input" + /> + + `${item?.key}-${item?.areaCode}`} + ListEmptyComponent={renderEmptyList} + keyboardDismissMode="none" + keyboardShouldPersistTaps="always" + /> + + ); +} + +export default RegionSelectorModal; diff --git a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx index c3d92897c72..4a9129c7cbc 100644 --- a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx +++ b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.test.tsx @@ -25,42 +25,6 @@ jest.mock('../../hooks/usePhoneVerificationSend'); jest.mock('../../hooks/useRegistrationSettings'); jest.mock('../../../../hooks/useDebouncedValue'); -// Mock SelectComponent with proper interaction simulation -jest.mock('../../../SelectComponent', () => { - const React = jest.requireActual('react'); - const { TouchableOpacity, Text } = jest.requireActual('react-native'); - - return (props: { - testID?: string; - onValueChange?: (value: string) => void; - selectedValue?: string; - defaultValue?: string; - options?: { key: string; value: string; label: string }[]; - [key: string]: unknown; - }) => { - const handlePress = () => { - // Simulate selecting the first available option - if (props.options && props.options.length > 0 && props.onValueChange) { - props.onValueChange(props.options[0].value); - } - }; - - return React.createElement( - TouchableOpacity, - { - testID: props.testID, - onPress: handlePress, - ...props, - }, - React.createElement( - Text, - {}, - props.selectedValue || props.defaultValue || 'Select...', - ), - ); - }; -}); - // Mock OnboardingStep jest.mock('./OnboardingStep', () => { const React = jest.requireActual('react'); @@ -95,14 +59,19 @@ jest.mock('./OnboardingStep', () => { ); }); -// Create test store +// Create test store with country object format const createTestStore = (initialState = {}) => configureStore({ reducer: { card: ( state = { onboarding: { - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, contactVerificationId: 'test-verification-id', }, ...initialState, @@ -143,9 +112,24 @@ describe('SetPhoneNumber Component', () => { (useRegistrationSettings as jest.Mock).mockReturnValue({ data: { countries: [ - { iso3166alpha2: 'US', name: 'United States', callingCode: '1' }, - { iso3166alpha2: 'CA', name: 'Canada', callingCode: '1' }, - { iso3166alpha2: 'GB', name: 'United Kingdom', callingCode: '44' }, + { + iso3166alpha2: 'US', + name: 'United States', + callingCode: '1', + canSignUp: true, + }, + { + iso3166alpha2: 'CA', + name: 'Canada', + callingCode: '1', + canSignUp: true, + }, + { + iso3166alpha2: 'GB', + name: 'United Kingdom', + callingCode: '44', + canSignUp: true, + }, ], }, }); @@ -287,7 +271,7 @@ describe('SetPhoneNumber Component', () => { }); describe('Country Area Code Selection', () => { - it('allows country area code selection', () => { + it('renders country area code selector', () => { const { getByTestId } = render( @@ -297,26 +281,10 @@ describe('SetPhoneNumber Component', () => { const countrySelect = getByTestId( 'set-phone-number-country-area-code-select', ); - fireEvent.press(countrySelect); - expect(countrySelect).toBeTruthy(); }); - it('displays initial area code based on selected country', () => { - const { getByTestId } = render( - - - , - ); - - const countrySelect = getByTestId( - 'set-phone-number-country-area-code-select', - ); - // Should show country code - area code (initial selected country) - expect(countrySelect.props.selectedValue).toBe('US-1'); - }); - - it('updates area code when different country is selected', () => { + it('navigates to region selector modal on press', () => { const { getByTestId } = render( @@ -326,11 +294,9 @@ describe('SetPhoneNumber Component', () => { const countrySelect = getByTestId( 'set-phone-number-country-area-code-select', ); - - // Mock selecting UK (+44) fireEvent.press(countrySelect); - expect(countrySelect).toBeTruthy(); + expect(mockNavigate).toHaveBeenCalled(); }); }); @@ -588,7 +554,12 @@ describe('SetPhoneNumber Component', () => { it('handles missing contact verification ID', () => { const storeWithoutVerificationId = createTestStore({ onboarding: { - selectedCountry: 'US', + selectedCountry: { + key: 'US', + name: 'United States', + emoji: '🇺🇸', + areaCode: '1', + }, contactVerificationId: null, }, }); @@ -614,30 +585,37 @@ describe('SetPhoneNumber Component', () => { , ); + // Should still render the area code selector const countrySelect = getByTestId( 'set-phone-number-country-area-code-select', ); - expect(countrySelect.props.options).toEqual([]); + expect(countrySelect).toBeTruthy(); }); - it('handles missing selected country in registration settings', () => { - const storeWithUnknownCountry = createTestStore({ + it('handles missing selected country area code', () => { + const storeWithNoAreaCode = createTestStore({ onboarding: { - selectedCountry: 'XX', // Unknown country code + selectedCountry: { + key: 'XX', + name: 'Unknown Country', + emoji: '🏳️', + // areaCode is undefined + }, contactVerificationId: 'test-verification-id', }, }); const { getByTestId } = render( - + , ); + // Should still render the component without errors const countrySelect = getByTestId( 'set-phone-number-country-area-code-select', ); - expect(countrySelect.props.selectedValue).toBe('XX-1'); + expect(countrySelect).toBeTruthy(); }); }); }); diff --git a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx index 3c41b815d11..4a54405b555 100644 --- a/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx +++ b/app/components/UI/Card/components/Onboarding/SetPhoneNumber.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigation } from '@react-navigation/native'; import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; import Button, { @@ -13,7 +13,6 @@ import Label from '../../../../../component-library/components/Form/Label'; import Routes from '../../../../../constants/navigation/Routes'; import { strings } from '../../../../../../locales/i18n'; import OnboardingStep from './OnboardingStep'; -import SelectComponent from '../../../SelectComponent'; import { useDebouncedValue } from '../../../../hooks/useDebouncedValue'; import usePhoneVerificationSend from '../../hooks/usePhoneVerificationSend'; import useRegistrationSettings from '../../hooks/useRegistrationSettings'; @@ -27,6 +26,13 @@ import { CardError } from '../../types'; import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; import { CardActions, CardScreens } from '../../util/metrics'; import { countryCodeToFlag } from '../../util/countryCodeToFlag'; +import { + clearOnValueChange, + createRegionSelectorModalNavigationDetails, + Region, + setOnValueChange, +} from './RegionSelectorModal'; +import { TouchableOpacity } from 'react-native'; const SetPhoneNumber = () => { const navigation = useNavigation(); @@ -36,7 +42,7 @@ const SetPhoneNumber = () => { const { trackEvent, createEventBuilder } = useMetrics(); const { data: registrationSettings } = useRegistrationSettings(); - const selectOptions = useMemo(() => { + const regions: Region[] = useMemo(() => { if (!registrationSettings?.countries) { return []; } @@ -45,27 +51,18 @@ const SetPhoneNumber = () => { .filter((country) => country.canSignUp) .map((country) => ({ key: country.iso3166alpha2, - value: `${country.iso3166alpha2}-${country.callingCode}`, - label: `${countryCodeToFlag(country.iso3166alpha2)} +${country.callingCode}`, + name: country.name, + emoji: countryCodeToFlag(country.iso3166alpha2), + areaCode: country.callingCode, })); }, [registrationSettings]); - const initialSelectedCountryAreaCode = useMemo(() => { - if (!registrationSettings?.countries) { - return '1'; - } - const selectedCountryWithCallingCode = registrationSettings.countries.find( - (country) => country.iso3166alpha2 === selectedCountry, - ); - return selectedCountryWithCallingCode?.callingCode || '1'; - }, [selectedCountry, registrationSettings]); - const [phoneNumber, setPhoneNumber] = useState(''); const [isPhoneNumberError, setIsPhoneNumberError] = useState(false); const [selectedCountryAreaCode, setSelectedCountryAreaCode] = - useState(initialSelectedCountryAreaCode); - const [selectedCountryIsoCode, setSelectedCountryIsoCode] = useState( - selectedCountry || 'US', + useState(selectedCountry?.areaCode || ''); + const [selectedCountryEmoji, setSelectedCountryEmoji] = useState( + selectedCountry?.emoji || '', ); const debouncedPhoneNumber = useDebouncedValue(phoneNumber, 1000); @@ -131,12 +128,21 @@ const SetPhoneNumber = () => { } }; - const handleCountrySelect = (value: string) => { + const handleCountrySelect = useCallback(() => { resetPhoneVerificationSend(); - const [key, areaCode] = value.split('-'); - setSelectedCountryAreaCode(areaCode); - setSelectedCountryIsoCode(key); - }; + + setOnValueChange((region) => { + setSelectedCountryAreaCode(region.areaCode || ''); + setSelectedCountryEmoji(region.emoji || ''); + }); + + navigation.navigate( + ...createRegionSelectorModalNavigationDetails({ + regions, + renderAreaCode: true, + }), + ); + }, [navigation, regions, resetPhoneVerificationSend]); const handlePhoneNumberChange = (text: string) => { resetPhoneVerificationSend(); @@ -173,6 +179,8 @@ const SetPhoneNumber = () => { phoneVerificationIsError, ]); + useEffect(() => () => clearOnValueChange(), []); + const renderFormFields = () => ( {/* Area code selector */} - - - + + + > + + + {`${selectedCountryEmoji} +${selectedCountryAreaCode}`} + + + @@ -242,6 +251,7 @@ const SetPhoneNumber = () => { onPress={handleContinue} width={ButtonWidthTypes.Full} isDisabled={isDisabled} + loading={phoneVerificationIsLoading} testID="set-phone-number-continue-button" /> { - const ReactActual = jest.requireActual('react'); - const { TouchableOpacity, Text } = jest.requireActual('react-native'); - - return (props: { - testID?: string; - onValueChange?: (value: string) => void; - selectedValue?: string; - defaultValue?: string; - options?: { key: string; value: string; label: string }[]; - [key: string]: unknown; - }) => { - const handlePress = () => { - // Simulate selecting the first available option - if (props.options && props.options.length > 0 && props.onValueChange) { - props.onValueChange(props.options[0].value); - } - }; - - return ReactActual.createElement( - TouchableOpacity, - { - testID: props.testID, - onPress: handlePress, - ...props, - }, - ReactActual.createElement( - Text, - {}, - props.selectedValue || props.defaultValue || 'Select...', - ), - ); - }; -}); - // Mock OnboardingStep jest.mock('./OnboardingStep', () => { const ReactActual = jest.requireActual('react'); @@ -283,17 +247,6 @@ describe('SignUp Component', () => { expect(passwordInput.props.value).toBe('password123'); }); - - it('has secure text entry enabled', () => { - const { getByTestId } = render( - - - , - ); - - const passwordInput = getByTestId('signup-password-input'); - expect(passwordInput.props.secureTextEntry).toBe(true); - }); }); describe('Confirm Password Input', () => { @@ -353,7 +306,18 @@ describe('SignUp Component', () => { }); describe('Country Selection', () => { - it('allows country selection', () => { + it('renders country select touchable', () => { + const { getByTestId } = render( + + + , + ); + + const countrySelect = getByTestId('signup-country-select'); + expect(countrySelect).toBeTruthy(); + }); + + it('navigates to region selector modal on press', () => { const { getByTestId } = render( @@ -363,14 +327,24 @@ describe('SignUp Component', () => { const countrySelect = getByTestId('signup-country-select'); fireEvent.press(countrySelect); - expect(countrySelect).toBeTruthy(); + expect(mockNavigate).toHaveBeenCalled(); }); }); describe('Form Validation', () => { it('enables continue button when all fields are valid', async () => { + // Create store with pre-selected country + const storeWithCountry = createTestStore({ + onboarding: { + selectedCountry: { key: 'US', name: 'United States' }, + onboardingId: null, + contactVerificationId: null, + user: null, + }, + }); + const { getByTestId } = render( - + , ); @@ -378,21 +352,13 @@ describe('SignUp Component', () => { const emailInput = getByTestId('signup-email-input'); const passwordInput = getByTestId('signup-password-input'); const confirmPasswordInput = getByTestId('signup-confirm-password-input'); - const countrySelect = getByTestId('signup-country-select'); const continueButton = getByTestId('signup-continue-button'); // Fill in all form fields - fireEvent.changeText(emailInput, 'test@example.com'); - fireEvent.changeText(passwordInput, 'Password123!'); - fireEvent.changeText(confirmPasswordInput, 'Password123!'); - - // Select a country - this should trigger the Redux action - fireEvent.press(countrySelect); - - // Wait for all state updates to complete - await waitFor(() => { - const state = store.getState(); - expect(state.card.onboarding.selectedCountry).toBe('CA'); // Canada comes first alphabetically + await act(async () => { + fireEvent.changeText(emailInput, 'test@example.com'); + fireEvent.changeText(passwordInput, 'Password123!'); + fireEvent.changeText(confirmPasswordInput, 'Password123!'); }); // Now check if the continue button is enabled @@ -406,8 +372,17 @@ describe('SignUp Component', () => { it('keeps continue button disabled when email is invalid', async () => { (validateEmail as jest.Mock).mockReturnValue(false); + const storeWithCountry = createTestStore({ + onboarding: { + selectedCountry: { key: 'US', name: 'United States' }, + onboardingId: null, + contactVerificationId: null, + user: null, + }, + }); + const { getByTestId } = render( - + , ); @@ -415,14 +390,12 @@ describe('SignUp Component', () => { const emailInput = getByTestId('signup-email-input'); const passwordInput = getByTestId('signup-password-input'); const confirmPasswordInput = getByTestId('signup-confirm-password-input'); - const countrySelect = getByTestId('signup-country-select'); const continueButton = getByTestId('signup-continue-button'); await act(async () => { fireEvent.changeText(emailInput, 'invalid-email'); fireEvent.changeText(passwordInput, 'Password123!'); fireEvent.changeText(confirmPasswordInput, 'Password123!'); - fireEvent.press(countrySelect); }); await waitFor(() => { @@ -431,8 +404,17 @@ describe('SignUp Component', () => { }); it('keeps continue button disabled when passwords do not match', async () => { + const storeWithCountry = createTestStore({ + onboarding: { + selectedCountry: { key: 'US', name: 'United States' }, + onboardingId: null, + contactVerificationId: null, + user: null, + }, + }); + const { getByTestId } = render( - + , ); @@ -440,14 +422,12 @@ describe('SignUp Component', () => { const emailInput = getByTestId('signup-email-input'); const passwordInput = getByTestId('signup-password-input'); const confirmPasswordInput = getByTestId('signup-confirm-password-input'); - const countrySelect = getByTestId('signup-country-select'); const continueButton = getByTestId('signup-continue-button'); await act(async () => { fireEvent.changeText(emailInput, 'test@example.com'); fireEvent.changeText(passwordInput, 'Password123!'); fireEvent.changeText(confirmPasswordInput, 'Password321!'); - fireEvent.press(countrySelect); }); await waitFor(() => { @@ -456,12 +436,18 @@ describe('SignUp Component', () => { }); it('keeps continue button disabled when password is invalid', async () => { - // Create a new store for this test with the invalid password mock - const testStore = createTestStore(); - (validatePassword as jest.Mock).mockReturnValue(false); // Return false directly, not an object + (validatePassword as jest.Mock).mockReturnValue(false); + const storeWithCountry = createTestStore({ + onboarding: { + selectedCountry: { key: 'US', name: 'United States' }, + onboardingId: null, + contactVerificationId: null, + user: null, + }, + }); const { getByTestId } = render( - + , ); @@ -469,13 +455,13 @@ describe('SignUp Component', () => { const emailInput = getByTestId('signup-email-input'); const passwordInput = getByTestId('signup-password-input'); const confirmPasswordInput = getByTestId('signup-confirm-password-input'); - const countrySelect = getByTestId('signup-country-select'); const continueButton = getByTestId('signup-continue-button'); - fireEvent.changeText(emailInput, 'test@example.com'); - fireEvent.changeText(passwordInput, 'weak'); - fireEvent.changeText(confirmPasswordInput, 'weak'); - fireEvent.press(countrySelect); + await act(async () => { + fireEvent.changeText(emailInput, 'test@example.com'); + fireEvent.changeText(passwordInput, 'weak'); + fireEvent.changeText(confirmPasswordInput, 'weak'); + }); await waitFor(() => { expect(continueButton.props.disabled).toBe(true); @@ -509,8 +495,17 @@ describe('SignUp Component', () => { describe('Form Submission', () => { it('calls sendEmailVerification when continue button is pressed', async () => { + const storeWithCountry = createTestStore({ + onboarding: { + selectedCountry: { key: 'US', name: 'United States' }, + onboardingId: null, + contactVerificationId: null, + user: null, + }, + }); + const { getByTestId } = render( - + , ); @@ -518,14 +513,12 @@ describe('SignUp Component', () => { const emailInput = getByTestId('signup-email-input'); const passwordInput = getByTestId('signup-password-input'); const confirmPasswordInput = getByTestId('signup-confirm-password-input'); - const countrySelect = getByTestId('signup-country-select'); const continueButton = getByTestId('signup-continue-button'); await act(async () => { fireEvent.changeText(emailInput, 'test@example.com'); fireEvent.changeText(passwordInput, 'Password123!'); fireEvent.changeText(confirmPasswordInput, 'Password123!'); - fireEvent.press(countrySelect); }); await waitFor(() => { @@ -582,37 +575,6 @@ describe('SignUp Component', () => { }); }); - describe('Country Selection', () => { - it('filters selectOptions by country.canSignUp property', () => { - const { getByTestId } = render( - - - , - ); - - const countrySelect = getByTestId('signup-country-select'); - - // The SelectComponent mock should receive only countries where canSignUp is true - // Based on the mock data, we should have US, Canada, and Germany (3 countries) - expect(countrySelect.props.options).toHaveLength(3); - - // Verify that only countries with canSignUp: true are included - const optionValues = countrySelect.props.options.map( - (option: { value: string }) => option.value, - ); - expect(optionValues).toContain('US'); - expect(optionValues).toContain('CA'); - expect(optionValues).toContain('DE'); - expect(optionValues).not.toContain('GB'); - - // Verify the options are sorted alphabetically by name - const optionLabels = countrySelect.props.options.map( - (option: { label: string }) => option.label, - ); - expect(optionLabels).toEqual(['Canada', 'Germany', 'United States']); - }); - }); - describe('Navigation', () => { it('navigates to authentication screen when "I already have an account" is pressed', () => { const { getByTestId } = render( diff --git a/app/components/UI/Card/components/Onboarding/SignUp.tsx b/app/components/UI/Card/components/Onboarding/SignUp.tsx index 8ceeb3e9da6..1a5857addd4 100644 --- a/app/components/UI/Card/components/Onboarding/SignUp.tsx +++ b/app/components/UI/Card/components/Onboarding/SignUp.tsx @@ -23,7 +23,6 @@ import { strings } from '../../../../../../locales/i18n'; import OnboardingStep from './OnboardingStep'; import { validateEmail } from '../../../Ramp/Deposit/utils'; import { useDebouncedValue } from '../../../../hooks/useDebouncedValue'; -import SelectComponent from '../../../SelectComponent'; import useEmailVerificationSend from '../../hooks/useEmailVerificationSend'; import useRegistrationSettings from '../../hooks/useRegistrationSettings'; import { @@ -37,12 +36,20 @@ import { validatePassword } from '../../util/validatePassword'; import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; import { CardActions, CardScreens } from '../../util/metrics'; import { TouchableOpacity } from 'react-native'; +import { + clearOnValueChange, + createRegionSelectorModalNavigationDetails, + Region, + setOnValueChange, +} from './RegionSelectorModal'; +import { countryCodeToFlag } from '../../util/countryCodeToFlag'; const SignUp = () => { const navigation = useNavigation(); const dispatch = useDispatch(); const [email, setEmail] = useState(''); const [isEmailError, setIsEmailError] = useState(false); + const [isEmailValid, setIsEmailValid] = useState(false); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [isPasswordError, setIsPasswordError] = useState(false); @@ -75,7 +82,7 @@ const SignUp = () => { const debouncedPassword = useDebouncedValue(password, 1000); const debouncedConfirmPassword = useDebouncedValue(confirmPassword, 1000); - const selectOptions = useMemo(() => { + const regions: Region[] = useMemo(() => { if (!registrationSettings?.countries) { return []; } @@ -84,8 +91,9 @@ const SignUp = () => { .filter((country) => country.canSignUp) .map((country) => ({ key: country.iso3166alpha2, - value: country.iso3166alpha2, - label: country.name, + name: country.name, + emoji: countryCodeToFlag(country.iso3166alpha2), + areaCode: country.callingCode, })); }, [registrationSettings]); @@ -93,7 +101,9 @@ const SignUp = () => { if (!debouncedEmail) { return; } - setIsEmailError(!validateEmail(debouncedEmail)); + const isValid = validateEmail(debouncedEmail); + setIsEmailError(!isValid); + setIsEmailValid(isValid); }, [debouncedEmail]); useEffect(() => { @@ -114,15 +124,8 @@ const SignUp = () => { setIsConfirmPasswordValid(isValid); }, [debouncedConfirmPassword, debouncedPassword]); - const isDisabled = useMemo(() => { - // Check the actual values, not the debounced ones - const isEmailValid = email ? validateEmail(email) : false; - const isPasswordValid = password ? validatePassword(password) : false; - const isConfirmPasswordValid = confirmPassword - ? confirmPassword === password - : false; - - return ( + const isDisabled = useMemo( + () => !email || !password || !confirmPassword || @@ -131,16 +134,19 @@ const SignUp = () => { !isPasswordValid || !isConfirmPasswordValid || emailVerificationIsError || - emailVerificationIsLoading - ); - }, [ - email, - password, - confirmPassword, - selectedCountry, - emailVerificationIsError, - emailVerificationIsLoading, - ]); + emailVerificationIsLoading, + [ + email, + password, + confirmPassword, + selectedCountry, + isEmailValid, + isPasswordValid, + isConfirmPasswordValid, + emailVerificationIsError, + emailVerificationIsLoading, + ], + ); const handleEmailChange = useCallback( (emailText: string) => { @@ -164,16 +170,19 @@ const SignUp = () => { return; } - // Validate current values before submitting - const isEmailValid = validateEmail(email); - const isPasswordValid = validatePassword(password); - const isConfirmPasswordValid = confirmPassword === password; + const isCurrentEmailValid = validateEmail(email); + const isCurrentPasswordValid = validatePassword(password); + const isCurrentConfirmPasswordValid = confirmPassword === password; - if (!isEmailValid || !isPasswordValid || !isConfirmPasswordValid) { + if ( + !isCurrentEmailValid || + !isCurrentPasswordValid || + !isCurrentConfirmPasswordValid + ) { // Set error states - setIsEmailError(!isEmailValid); - setIsPasswordError(!isPasswordValid); - setIsConfirmPasswordError(!isConfirmPasswordValid); + setIsEmailError(!isCurrentEmailValid); + setIsPasswordError(!isCurrentPasswordValid); + setIsConfirmPasswordError(!isCurrentConfirmPasswordValid); return; } @@ -202,27 +211,34 @@ const SignUp = () => { // Allow error message to display } }, [ - confirmPassword, email, password, - dispatch, - navigation, + confirmPassword, selectedCountry, - sendEmailVerification, trackEvent, createEventBuilder, + sendEmailVerification, + dispatch, + navigation, ]); - const handleCountrySelect = useCallback( - (countryValue: string) => { - resetEmailVerificationSend(); - dispatch(setSelectedCountry(countryValue)); + const handleCountrySelect = useCallback(() => { + resetEmailVerificationSend(); + setOnValueChange((region) => { + dispatch(setSelectedCountry(region)); dispatch( - setUserCardLocation(countryValue === 'US' ? 'us' : 'international'), + setUserCardLocation(region.key === 'US' ? 'us' : 'international'), ); - }, - [dispatch, resetEmailVerificationSend], - ); + }); + + navigation.navigate( + ...createRegionSelectorModalNavigationDetails({ + regions, + }), + ); + }, [dispatch, navigation, regions, resetEmailVerificationSend]); + + useEffect(() => () => clearOnValueChange(), []); const renderFormFields = () => ( <> @@ -347,16 +363,15 @@ const SignUp = () => { - + > + + {selectedCountry?.name} + + + @@ -371,6 +386,7 @@ const SignUp = () => { onPress={handleContinue} width={ButtonWidthTypes.Full} isDisabled={isDisabled} + loading={emailVerificationIsLoading} testID="signup-continue-button" /> ({ @@ -35,6 +36,20 @@ const mockGetErrorMessage = getErrorMessage as jest.MockedFunction< typeof getErrorMessage >; +// Mock Region objects for testing +const MOCK_REGION_US: Region = { + key: 'US', + name: 'United States', + emoji: '🇺🇸', +}; +const MOCK_REGION_CA: Region = { key: 'CA', name: 'Canada', emoji: '🇨🇦' }; +const MOCK_REGION_GB: Region = { + key: 'GB', + name: 'United Kingdom', + emoji: '🇬🇧', +}; +const MOCK_REGION_DE: Region = { key: 'DE', name: 'Germany', emoji: '🇩🇪' }; + describe('useRegisterUserConsent', () => { const mockCreateOnboardingConsent = jest.fn(); const mockLinkUserToConsent = jest.fn(); @@ -62,7 +77,7 @@ describe('useRegisterUserConsent', () => { sdk: mockSDK, }); - mockUseSelector.mockReturnValue('US'); // selectedCountry + mockUseSelector.mockReturnValue(MOCK_REGION_US); // selectedCountry mockGetErrorMessage.mockReturnValue('Mocked error message'); mockCreateOnboardingConsent.mockResolvedValue(mockConsentResponse); @@ -171,7 +186,7 @@ describe('useRegisterUserConsent', () => { describe('createOnboardingConsent function', () => { describe('successful consent creation', () => { it('creates consent record for US users with eSignAct consent', async () => { - mockUseSelector.mockReturnValue('US'); + mockUseSelector.mockReturnValue(MOCK_REGION_US); const { result } = renderHook(() => useRegisterUserConsent()); let returnedConsentSetId = ''; @@ -234,7 +249,7 @@ describe('useRegisterUserConsent', () => { }); it('creates consent record for international users without eSignAct', async () => { - mockUseSelector.mockReturnValue('CA'); + mockUseSelector.mockReturnValue(MOCK_REGION_CA); const { result } = renderHook(() => useRegisterUserConsent()); await act(async () => { @@ -716,26 +731,26 @@ describe('useRegisterUserConsent', () => { describe('country-specific behavior', () => { const countryTestCases = [ { - country: 'US', + country: MOCK_REGION_US, expectedPolicy: 'us', description: 'US users', }, { - country: 'CA', + country: MOCK_REGION_CA, expectedPolicy: 'global', description: 'Canadian users', }, { - country: 'GB', + country: MOCK_REGION_GB, expectedPolicy: 'global', description: 'UK users', }, { - country: 'DE', + country: MOCK_REGION_DE, expectedPolicy: 'global', description: 'German users', }, - ] as const; + ]; it.each(countryTestCases)( 'uses correct policy for $description', diff --git a/app/components/UI/Card/hooks/useRegisterUserConsent.ts b/app/components/UI/Card/hooks/useRegisterUserConsent.ts index 328634136fd..46899ac79a7 100644 --- a/app/components/UI/Card/hooks/useRegisterUserConsent.ts +++ b/app/components/UI/Card/hooks/useRegisterUserConsent.ts @@ -96,7 +96,7 @@ export const useRegisterUserConsent = (): UseRegisterUserConsentReturn => { throw new Error('Card SDK not initialized'); } - const policy = selectedCountry === 'US' ? 'us' : 'global'; + const policy = selectedCountry?.key === 'US' ? 'us' : 'global'; try { // Reset state and start loading diff --git a/app/components/UI/Card/routes/OnboardingNavigator.test.tsx b/app/components/UI/Card/routes/OnboardingNavigator.test.tsx index c05962c5e2d..5c1b05eadf4 100644 --- a/app/components/UI/Card/routes/OnboardingNavigator.test.tsx +++ b/app/components/UI/Card/routes/OnboardingNavigator.test.tsx @@ -280,14 +280,13 @@ describe('OnboardingNavigator', () => { }); describe('when user verification state is PENDING', () => { - it('returns VERIFY_IDENTITY route when firstName is missing', () => { + it('returns SET_PHONE_NUMBER route when phoneNumber is missing', () => { mockUseSelector.mockReturnValue('onboarding-123'); mockUseCardSDK.mockReturnValue({ user: { id: 'user-123', verificationState: 'PENDING', - countryOfNationality: 'US', - // firstName is undefined + // phoneNumber is undefined }, isLoading: false, sdk: null, @@ -303,18 +302,18 @@ describe('OnboardingNavigator', () => { const stackNavigator = queryByTestId('stack-navigator'); expect(stackNavigator).not.toBeNull(); expect(stackNavigator?.props.initialRouteName).toBe( - Routes.CARD.ONBOARDING.VERIFY_IDENTITY, + Routes.CARD.ONBOARDING.SET_PHONE_NUMBER, ); }); - it('returns VERIFY_IDENTITY route when countryOfNationality is missing', () => { + it('returns PERSONAL_DETAILS route when phoneNumber exists but firstName is missing', () => { mockUseSelector.mockReturnValue('onboarding-123'); mockUseCardSDK.mockReturnValue({ user: { id: 'user-123', verificationState: 'PENDING', - firstName: 'John', - // countryOfNationality is undefined + phoneNumber: '+1234567890', + // firstName is undefined }, isLoading: false, sdk: null, @@ -330,47 +329,21 @@ describe('OnboardingNavigator', () => { const stackNavigator = queryByTestId('stack-navigator'); expect(stackNavigator).not.toBeNull(); expect(stackNavigator?.props.initialRouteName).toBe( - Routes.CARD.ONBOARDING.VERIFY_IDENTITY, + Routes.CARD.ONBOARDING.PERSONAL_DETAILS, ); }); - it('returns VALIDATING_KYC route when firstName and countryOfNationality exist', () => { + it('returns PERSONAL_DETAILS route when dateOfBirth is missing', () => { mockUseSelector.mockReturnValue('onboarding-123'); mockUseCardSDK.mockReturnValue({ user: { id: 'user-123', verificationState: 'PENDING', + phoneNumber: '+1234567890', firstName: 'John', + lastName: 'Doe', countryOfNationality: 'US', - }, - isLoading: false, - sdk: null, - setUser: jest.fn(), - logoutFromProvider: jest.fn(), - fetchUserData: jest.fn(), - }); - - const { queryByTestId } = renderWithNavigation( - , - ); - - const stackNavigator = queryByTestId('stack-navigator'); - expect(stackNavigator).not.toBeNull(); - expect(stackNavigator?.props.initialRouteName).toBe( - Routes.CARD.ONBOARDING.VALIDATING_KYC, - ); - }); - }); - - describe('when user verification state is VERIFIED', () => { - it('returns PERSONAL_DETAILS route when firstName is missing', () => { - mockUseSelector.mockReturnValue('onboarding-123'); - mockUseCardSDK.mockReturnValue({ - user: { - id: 'user-123', - verificationState: 'VERIFIED', - countryOfNationality: 'US', - // firstName is undefined + // dateOfBirth is undefined }, isLoading: false, sdk: null, @@ -390,14 +363,17 @@ describe('OnboardingNavigator', () => { ); }); - it('returns PHYSICAL_ADDRESS route when firstName and countryOfNationality exist but addressLine1 is missing', () => { + it('returns PHYSICAL_ADDRESS route when personal details exist but addressLine1 is missing', () => { mockUseSelector.mockReturnValue('onboarding-123'); mockUseCardSDK.mockReturnValue({ user: { id: 'user-123', - verificationState: 'VERIFIED', + verificationState: 'PENDING', + phoneNumber: '+1234567890', firstName: 'John', + lastName: 'Doe', countryOfNationality: 'US', + dateOfBirth: '1990-01-01', // addressLine1 is undefined }, isLoading: false, @@ -418,15 +394,19 @@ describe('OnboardingNavigator', () => { ); }); - it('returns COMPLETE route when user has all required data', () => { + it('returns PHYSICAL_ADDRESS route when city is missing', () => { mockUseSelector.mockReturnValue('onboarding-123'); mockUseCardSDK.mockReturnValue({ user: { id: 'user-123', - verificationState: 'VERIFIED', + verificationState: 'PENDING', + phoneNumber: '+1234567890', firstName: 'John', + lastName: 'Doe', countryOfNationality: 'US', + dateOfBirth: '1990-01-01', addressLine1: '123 Main St', + // city is undefined }, isLoading: false, sdk: null, @@ -442,19 +422,24 @@ describe('OnboardingNavigator', () => { const stackNavigator = queryByTestId('stack-navigator'); expect(stackNavigator).not.toBeNull(); expect(stackNavigator?.props.initialRouteName).toBe( - Routes.CARD.ONBOARDING.COMPLETE, + Routes.CARD.ONBOARDING.PHYSICAL_ADDRESS, ); }); - }); - describe('when user verification state is UNVERIFIED', () => { - it('returns SIGN_UP route when email is missing', () => { + it('returns VERIFY_IDENTITY route when all user data is complete', () => { mockUseSelector.mockReturnValue('onboarding-123'); mockUseCardSDK.mockReturnValue({ user: { id: 'user-123', - verificationState: 'UNVERIFIED', - // email is undefined + verificationState: 'PENDING', + phoneNumber: '+1234567890', + firstName: 'John', + lastName: 'Doe', + countryOfNationality: 'US', + dateOfBirth: '1990-01-01', + addressLine1: '123 Main St', + city: 'New York', + zip: '10001', }, isLoading: false, sdk: null, @@ -470,18 +455,18 @@ describe('OnboardingNavigator', () => { const stackNavigator = queryByTestId('stack-navigator'); expect(stackNavigator).not.toBeNull(); expect(stackNavigator?.props.initialRouteName).toBe( - Routes.CARD.ONBOARDING.SIGN_UP, + Routes.CARD.ONBOARDING.VERIFY_IDENTITY, ); }); + }); - it('returns SET_PHONE_NUMBER route when email exists but phoneNumber is missing', () => { + describe('when user verification state is VERIFIED', () => { + it('returns COMPLETE route regardless of user data completeness', () => { mockUseSelector.mockReturnValue('onboarding-123'); mockUseCardSDK.mockReturnValue({ user: { id: 'user-123', - verificationState: 'UNVERIFIED', - email: 'test@example.com', - // phoneNumber is undefined + verificationState: 'VERIFIED', }, isLoading: false, sdk: null, @@ -497,18 +482,18 @@ describe('OnboardingNavigator', () => { const stackNavigator = queryByTestId('stack-navigator'); expect(stackNavigator).not.toBeNull(); expect(stackNavigator?.props.initialRouteName).toBe( - Routes.CARD.ONBOARDING.SET_PHONE_NUMBER, + Routes.CARD.ONBOARDING.COMPLETE, ); }); + }); - it('returns VERIFY_IDENTITY route when email and phoneNumber exist', () => { + describe('when user verification state is UNVERIFIED', () => { + it('returns SIGN_UP route regardless of user data', () => { mockUseSelector.mockReturnValue('onboarding-123'); mockUseCardSDK.mockReturnValue({ user: { id: 'user-123', verificationState: 'UNVERIFIED', - email: 'test@example.com', - phoneNumber: '+1234567890', }, isLoading: false, sdk: null, @@ -524,7 +509,7 @@ describe('OnboardingNavigator', () => { const stackNavigator = queryByTestId('stack-navigator'); expect(stackNavigator).not.toBeNull(); expect(stackNavigator?.props.initialRouteName).toBe( - Routes.CARD.ONBOARDING.VERIFY_IDENTITY, + Routes.CARD.ONBOARDING.SIGN_UP, ); }); }); diff --git a/app/components/UI/Card/routes/OnboardingNavigator.tsx b/app/components/UI/Card/routes/OnboardingNavigator.tsx index bf5db35b823..eafe8b63918 100644 --- a/app/components/UI/Card/routes/OnboardingNavigator.tsx +++ b/app/components/UI/Card/routes/OnboardingNavigator.tsx @@ -146,31 +146,31 @@ const OnboardingNavigator: React.FC = () => { // Priority 2: Use cached user data if available if (user?.verificationState && onboardingId) { if (user.verificationState === 'UNVERIFIED') { - if (!user?.email) { - return Routes.CARD.ONBOARDING.SIGN_UP; - } + return Routes.CARD.ONBOARDING.SIGN_UP; + } + if (user.verificationState === 'PENDING') { if (!user?.phoneNumber) { return Routes.CARD.ONBOARDING.SET_PHONE_NUMBER; } - return Routes.CARD.ONBOARDING.VERIFY_IDENTITY; - } + if ( + !user.firstName || + !user.lastName || + !user.countryOfNationality || + !user.dateOfBirth + ) { + return Routes.CARD.ONBOARDING.PERSONAL_DETAILS; + } - if (user.verificationState === 'PENDING') { - if (!user.firstName || !user.countryOfNationality) { - return Routes.CARD.ONBOARDING.VERIFY_IDENTITY; + if (!user?.addressLine1 || !user?.city || !user?.zip) { + return Routes.CARD.ONBOARDING.PHYSICAL_ADDRESS; } - return Routes.CARD.ONBOARDING.VALIDATING_KYC; + return Routes.CARD.ONBOARDING.VERIFY_IDENTITY; } if (user.verificationState === 'VERIFIED') { - if (!user?.firstName || !user?.countryOfNationality) { - return Routes.CARD.ONBOARDING.PERSONAL_DETAILS; - } else if (!user?.addressLine1) { - return Routes.CARD.ONBOARDING.PHYSICAL_ADDRESS; - } return Routes.CARD.ONBOARDING.COMPLETE; } } diff --git a/app/components/UI/Card/routes/index.tsx b/app/components/UI/Card/routes/index.tsx index 0f90dca9ba7..6f75e8d9bb4 100644 --- a/app/components/UI/Card/routes/index.tsx +++ b/app/components/UI/Card/routes/index.tsx @@ -29,6 +29,7 @@ import AddFundsBottomSheet from '../components/AddFundsBottomSheet/AddFundsBotto import AssetSelectionBottomSheet from '../components/AssetSelectionBottomSheet/AssetSelectionBottomSheet'; import { colors } from '../../../../styles/common'; import VerifyingRegistration from '../components/Onboarding/VerifyingRegistration'; +import RegionSelectorModal from '../components/Onboarding/RegionSelectorModal'; const Stack = createStackNavigator(); const ModalsStack = createStackNavigator(); @@ -156,6 +157,10 @@ const CardModalsRoutes = () => ( name={Routes.CARD.MODALS.ASSET_SELECTION} component={AssetSelectionBottomSheet} /> + ); diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 975011af3fa..ed79ebf5b30 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -406,6 +406,7 @@ const Routes = { ID: 'CardModals', ADD_FUNDS: 'CardAddFundsModal', ASSET_SELECTION: 'CardAssetSelectionModal', + REGION_SELECTION: 'CardRegionSelectionModal', }, }, SEND: { diff --git a/app/core/redux/slices/card/index.test.ts b/app/core/redux/slices/card/index.test.ts index f0a5e9fed4e..cd3565f9e1e 100644 --- a/app/core/redux/slices/card/index.test.ts +++ b/app/core/redux/slices/card/index.test.ts @@ -36,11 +36,13 @@ import cardReducer, { selectSelectedCountry, selectContactVerificationId, selectConsentSetId, + resetAuthenticatedData, } from '.'; import { CardTokenAllowance, AllowanceState, } from '../../../../components/UI/Card/types'; +import { Region } from '../../../../components/UI/Card/components/Onboarding/RegionSelectorModal'; // Mock the multichain selectors jest.mock('../../../../selectors/multichainAccounts/accounts', () => ({ @@ -116,6 +118,22 @@ const MOCK_PRIORITY_TOKEN: CardTokenAllowance = { const testAddress = '0x1234567890123456789012345678901234567890'; +// Mock Region objects for testing +const MOCK_REGION_US: Region = { + key: 'US', + name: 'United States', + emoji: '🇺🇸', +}; +const MOCK_REGION_GB: Region = { + key: 'GB', + name: 'United Kingdom', + emoji: '🇬🇧', +}; +const MOCK_REGION_CA: Region = { key: 'CA', name: 'Canada', emoji: '🇨🇦' }; +const MOCK_REGION_DE: Region = { key: 'DE', name: 'Germany', emoji: '🇩🇪' }; +const MOCK_REGION_FR: Region = { key: 'FR', name: 'France', emoji: '🇫🇷' }; +const MOCK_REGION_JP: Region = { key: 'JP', name: 'Japan', emoji: '🇯🇵' }; + const CARD_STATE_MOCK: CardSliceState = { cardholderAccounts: CARDHOLDER_ACCOUNTS_MOCK, priorityTokensByAddress: { @@ -363,7 +381,7 @@ describe('Card Selectors', () => { }); it('should return the selected country when set', () => { - const selectedCountry = 'US'; + const selectedCountry = MOCK_REGION_US; const stateWithCountry: CardSliceState = { ...initialState, onboarding: { @@ -378,20 +396,27 @@ describe('Card Selectors', () => { }); it('should handle different country codes', () => { - const countryCodes = ['US', 'GB', 'CA', 'DE', 'FR', 'JP']; - - countryCodes.forEach((countryCode) => { + const regions: Region[] = [ + MOCK_REGION_US, + MOCK_REGION_GB, + MOCK_REGION_CA, + MOCK_REGION_DE, + MOCK_REGION_FR, + MOCK_REGION_JP, + ]; + + regions.forEach((region) => { const stateWithCountry: CardSliceState = { ...initialState, onboarding: { ...initialState.onboarding, - selectedCountry: countryCode, + selectedCountry: region, }, }; const mockRootState = { card: stateWithCountry, } as unknown as RootState; - expect(selectSelectedCountry(mockRootState)).toBe(countryCode); + expect(selectSelectedCountry(mockRootState)).toBe(region); }); }); }); @@ -630,7 +655,7 @@ describe('Card Reducer', () => { describe('setSelectedCountry', () => { it('should set selectedCountry', () => { - const country = 'US'; + const country = MOCK_REGION_US; const state = cardReducer(initialState, setSelectedCountry(country)); expect(state.onboarding.selectedCountry).toBe(country); // ensure other parts of state untouched @@ -644,10 +669,10 @@ describe('Card Reducer', () => { ...initialState, onboarding: { ...initialState.onboarding, - selectedCountry: 'GB', + selectedCountry: MOCK_REGION_GB, }, }; - const newCountry = 'CA'; + const newCountry = MOCK_REGION_CA; const state = cardReducer(current, setSelectedCountry(newCountry)); expect(state.onboarding.selectedCountry).toBe(newCountry); }); @@ -657,7 +682,7 @@ describe('Card Reducer', () => { ...initialState, onboarding: { ...initialState.onboarding, - selectedCountry: 'US', + selectedCountry: MOCK_REGION_US, }, }; const state = cardReducer(current, setSelectedCountry(null)); @@ -756,7 +781,7 @@ describe('Card Reducer', () => { ...initialState, onboarding: { onboardingId: 'test-id', - selectedCountry: 'US', + selectedCountry: MOCK_REGION_US, contactVerificationId: 'verification-123', consentSetId: 'consent-456', }, @@ -1019,6 +1044,116 @@ describe('Card Reducer', () => { }); }); }); + + describe('resetAuthenticatedData', () => { + it('resets all authenticated-related state to initial values', () => { + const currentState: CardSliceState = { + ...initialState, + authenticatedPriorityToken: MOCK_PRIORITY_TOKEN, + authenticatedPriorityTokenLastFetched: new Date( + '2025-08-21T10:00:00Z', + ), + userCardLocation: 'us', + isAuthenticated: true, + }; + + const state = cardReducer(currentState, resetAuthenticatedData()); + + expect(state.authenticatedPriorityToken).toBeNull(); + expect(state.authenticatedPriorityTokenLastFetched).toBeNull(); + expect(state.userCardLocation).toBe('international'); + expect(state.isAuthenticated).toBe(false); + }); + + it('does not affect other state properties', () => { + const currentState: CardSliceState = { + ...initialState, + cardholderAccounts: ['0x123'], + geoLocation: 'US', + isLoaded: true, + hasViewedCardButton: true, + alwaysShowCardButton: true, + authenticatedPriorityToken: MOCK_PRIORITY_TOKEN, + authenticatedPriorityTokenLastFetched: new Date( + '2025-08-21T10:00:00Z', + ), + userCardLocation: 'us', + isAuthenticated: true, + priorityTokensByAddress: { + [testAddress.toLowerCase()]: MOCK_PRIORITY_TOKEN, + }, + lastFetchedByAddress: { + [testAddress.toLowerCase()]: new Date('2025-08-21T10:00:00Z'), + }, + onboarding: { + onboardingId: 'test-id', + selectedCountry: MOCK_REGION_US, + contactVerificationId: 'verification-123', + consentSetId: 'consent-456', + }, + }; + + const state = cardReducer(currentState, resetAuthenticatedData()); + + // Authenticated data is reset + expect(state.authenticatedPriorityToken).toBeNull(); + expect(state.authenticatedPriorityTokenLastFetched).toBeNull(); + expect(state.userCardLocation).toBe('international'); + expect(state.isAuthenticated).toBe(false); + + // Other state properties remain unchanged + expect(state.cardholderAccounts).toEqual(['0x123']); + expect(state.geoLocation).toBe('US'); + expect(state.isLoaded).toBe(true); + expect(state.hasViewedCardButton).toBe(true); + expect(state.alwaysShowCardButton).toBe(true); + expect( + state.priorityTokensByAddress[testAddress.toLowerCase()], + ).toEqual(MOCK_PRIORITY_TOKEN); + expect(state.lastFetchedByAddress[testAddress.toLowerCase()]).toEqual( + new Date('2025-08-21T10:00:00Z'), + ); + expect(state.onboarding).toEqual({ + onboardingId: 'test-id', + selectedCountry: MOCK_REGION_US, + contactVerificationId: 'verification-123', + consentSetId: 'consent-456', + }); + }); + + it('works when authenticated data is already at initial values', () => { + const state = cardReducer(initialState, resetAuthenticatedData()); + + expect(state.authenticatedPriorityToken).toBeNull(); + expect(state.authenticatedPriorityTokenLastFetched).toBeNull(); + expect(state.userCardLocation).toBe('international'); + expect(state.isAuthenticated).toBe(false); + }); + + it('resets userCardLocation to international from us', () => { + const currentState: CardSliceState = { + ...initialState, + userCardLocation: 'us', + }; + + const state = cardReducer(currentState, resetAuthenticatedData()); + + expect(state.userCardLocation).toBe('international'); + }); + + it('resets string date format for authenticatedPriorityTokenLastFetched', () => { + const currentState: CardSliceState = { + ...initialState, + authenticatedPriorityTokenLastFetched: '2025-08-21T10:00:00Z', + isAuthenticated: true, + }; + + const state = cardReducer(currentState, resetAuthenticatedData()); + + expect(state.authenticatedPriorityTokenLastFetched).toBeNull(); + expect(state.isAuthenticated).toBe(false); + }); + }); }); }); diff --git a/app/core/redux/slices/card/index.ts b/app/core/redux/slices/card/index.ts index ba821a3ec5b..81316520e86 100644 --- a/app/core/redux/slices/card/index.ts +++ b/app/core/redux/slices/card/index.ts @@ -15,10 +15,11 @@ import { selectDisplayCardButtonFeatureFlag, } from '../../../../selectors/featureFlagController/card'; import { handleLocalAuthentication } from '../../../../components/UI/Card/util/handleLocalAuthentication'; +import { Region } from '../../../../components/UI/Card/components/Onboarding/RegionSelectorModal'; export interface OnboardingState { onboardingId: string | null; - selectedCountry: string | null; // ISO 3166 alpha-2 country code, e.g. 'US' + selectedCountry: Region | null; contactVerificationId: string | null; consentSetId: string | null; } @@ -137,7 +138,7 @@ const slice = createSlice({ setOnboardingId: (state, action: PayloadAction) => { state.onboarding.onboardingId = action.payload; }, - setSelectedCountry: (state, action: PayloadAction) => { + setSelectedCountry: (state, action: PayloadAction) => { state.onboarding.selectedCountry = action.payload; }, setContactVerificationId: (state, action: PayloadAction) => { diff --git a/locales/languages/en.json b/locales/languages/en.json index b640ed3a362..d8fe169bf99 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6615,7 +6615,11 @@ "confirm_button": "Confirm", "retry_button": "Try again", "close_button": "Got it", + "region_selector": { + "title": "Select your Region" + }, "errors": { + "no_region_results": "No region matches {{searchString}}", "email_already_exists": "Email already exists. Please use a different email.", "invalid_email_format": "Invalid email format. Please check your email and try again.", "invalid_verification_code": "Invalid verification code. Please check your code and try again.", From a6ad0e98c65a9061c204f14279156755228d79e6 Mon Sep 17 00:00:00 2001 From: Pavel Dvorkin Date: Tue, 16 Dec 2025 10:51:08 -0500 Subject: [PATCH 06/12] =?UTF-8?q?chore:=20INFRA-3187:=20Added=20workflow?= =?UTF-8?q?=20to=20auto-merge=20old=20release=20branches=20into=20ne?= =?UTF-8?q?=E2=80=A6=20(#23831)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …w one ## **Description** Adds a workflow and script to automatically merge all older release/X.Y.Z branches into a newly created release branch, favoring the destination on conflicts. **CI/CD:** New workflow /.github/workflows/merge-previous-releases.yml: Triggers via workflow_call or workflow_dispatch with new-release-branch input. Checks out repo and metamask/github-tools, configures git user, and runs merge script. New script /.github/scripts/merge-previous-releases.sh: Parses release/X.Y.Z branches, filters/sorts older versions, and merges them into NEW_RELEASE_BRANCH. Uses -X ours to resolve conflicts favoring destination; skips already merged branches; pushes only if merges occurred. Logs actions and summarizes merged vs skipped branches. Testing: https://github.com/consensys-test/metamask-mobile-test-workflow/pull/59 1. Multiple branches, one already merged: https://github.com/consensys-test/metamask-mobile-test-workflow/actions/runs/20072233678 2. Multiple branches, neither merged: https://github.com/consensys-test/metamask-mobile-test-workflow/actions/runs/20073029520 3. Multiple branches, with merge conflicts: https://github.com/consensys-test/metamask-mobile-test-workflow/actions/runs/20073136324 ## **Changelog** CHANGELOG entry: None ## **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. ## **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] > Adds a GitHub Actions workflow that, upon creating a `release/X.Y.Z` branch, validates the branch name and merges previous release branches into it using `metamask/github-tools`. > > - **CI/CD — GitHub Actions** > - **New workflow** `/.github/workflows/merge-previous-release-branches.yml`: > - Triggers on branch creation; validates `release/X.Y.Z` format. > - If valid, runs job to merge previous release branches into `${{ github.event.ref }}` using `metamask/github-tools/.github/actions/merge-previous-releases@v1.2.0` with `METAMASK_MOBILE_BRANCH_SYNC_TOKEN`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit eb5031e6994423fc3adff2555586e5c5dd542b02. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../merge-previous-release-branches.yml | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/merge-previous-release-branches.yml diff --git a/.github/workflows/merge-previous-release-branches.yml b/.github/workflows/merge-previous-release-branches.yml new file mode 100644 index 00000000000..2a2f8f13fce --- /dev/null +++ b/.github/workflows/merge-previous-release-branches.yml @@ -0,0 +1,44 @@ +name: Merge Previous Release Branches + +permissions: + pull-requests: write + contents: write + issues: write + +on: + create: + # Trigger when a branch is created, filter for release/* happens in the job + +jobs: + validate-branch: + name: Validate release branch format + runs-on: ubuntu-latest + # Only run for branch creation (not tags) + if: github.event.ref_type == 'branch' + outputs: + is-valid: ${{ steps.check.outputs.is-valid }} + steps: + - name: Check branch name format + id: check + env: + BRANCH: ${{ github.event.ref }} + run: | + if [[ "$BRANCH" =~ ^release/[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Branch '$BRANCH' matches release/X.Y.Z format" + echo "is-valid=true" >> "$GITHUB_OUTPUT" + else + echo "Branch '$BRANCH' does not match release/X.Y.Z format. Skipping." + echo "is-valid=false" >> "$GITHUB_OUTPUT" + fi + + merge-previous-releases: + name: Merge previous release branches + needs: validate-branch + if: needs.validate-branch.outputs.is-valid == 'true' + runs-on: ubuntu-latest + steps: + - name: Merge previous releases + uses: metamask/github-tools/.github/actions/merge-previous-releases@v1.2.0 + with: + new-release-branch: ${{ github.event.ref }} + github-token: ${{ secrets.METAMASK_MOBILE_BRANCH_SYNC_TOKEN }} From 5d88300f888ea9d91ce34122d724c3bed26c27b0 Mon Sep 17 00:00:00 2001 From: Pavel Dvorkin Date: Tue, 16 Dec 2025 10:52:17 -0500 Subject: [PATCH 07/12] chore: INFRA-3188: automate release branch sync (#23991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Ticket: https://consensyssoftware.atlassian.net/browse/INFRA-3188 Test stable sync branch: https://github.com/consensys-test/metamask-mobile-test-workflow/actions/runs/20148777729/job/57923961348, https://github.com/consensys-test/metamask-mobile-test-workflow/pull/174 > Adds a composite GitHub Action and script to auto-open PRs syncing `stable` into active `release/*` branches after a release is merged. > > - **CI/GitHub Actions** > - **New composite action** `/.github/actions/release-branch-sync/action.yml`: > - Inputs: `merged-release-branch`, `github-token`, optional `github-tools-repository` and `github-tools-ref`. > - Checks out repo and tools, configures git, runs sync script. > - **New script** `/.github/scripts/release-branch-sync.sh`: > - Detects active release branches via open PR titles matching `release: X.Y.Z`. > - Creates `stable-sync-release-X.Y.Z` branches from `origin/stable`, pushes, and opens PRs into corresponding `release/X.Y.Z` branches. > - Skips invalid/older-than-merged branches, the just-merged branch, non-existent branches, already up-to-date branches, or if a sync PR already exists. > - Emits a summary of created/skipped/failed operations. ## **Changelog** CHANGELOG entry: None ## **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. ## **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] > Adds a GitHub Actions workflow to validate merged release branch names and sync `stable` into open `release/*` branches. > > - **CI/GitHub Actions**: > - **New workflow** `/.github/workflows/release-branch-sync.yml` triggered on closed PRs to `stable`. > - **`validate-branch` job**: Ensures merged head branch matches `release/X.Y.Z`. > - **`sync-release-branches` job**: If valid, runs `metamask/github-tools/.github/actions/release-branch-sync@v1.2.0` to sync `stable` into open `release/*` branches using `STABLE_SYNC_TOKEN`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5492723662c87dba90fe1593e669b214603eaca7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/release-branch-sync.yml | 44 +++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/release-branch-sync.yml diff --git a/.github/workflows/release-branch-sync.yml b/.github/workflows/release-branch-sync.yml new file mode 100644 index 00000000000..b3605beaeee --- /dev/null +++ b/.github/workflows/release-branch-sync.yml @@ -0,0 +1,44 @@ +name: Release Branch Sync + +permissions: + pull-requests: write + contents: write + +on: + pull_request: + types: [closed] + branches: + - stable + +jobs: + validate-branch: + name: Validate release branch format + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + outputs: + is-valid: ${{ steps.check.outputs.is-valid }} + steps: + - name: Check branch name format + id: check + env: + BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + if [[ "$BRANCH" =~ ^release/[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Branch '$BRANCH' matches release/X.Y.Z format" + echo "is-valid=true" >> "$GITHUB_OUTPUT" + else + echo "Branch '$BRANCH' does not match release/X.Y.Z format. Skipping." + echo "is-valid=false" >> "$GITHUB_OUTPUT" + fi + + sync-release-branches: + name: Sync open release branches with stable + needs: validate-branch + if: needs.validate-branch.outputs.is-valid == 'true' + runs-on: ubuntu-latest + steps: + - name: Sync release branches with stable + uses: metamask/github-tools/.github/actions/release-branch-sync@v1.2.0 + with: + merged-release-branch: ${{ github.event.pull_request.head.ref }} + github-token: ${{ secrets.STABLE_SYNC_TOKEN }} \ No newline at end of file From d6aa74ede5bc0c74b050293ff5ad490b662eb3c3 Mon Sep 17 00:00:00 2001 From: Edouard Bougon <15703023+EdouardBougon@users.noreply.github.com> Date: Tue, 16 Dec 2025 17:13:40 +0100 Subject: [PATCH 08/12] feat: add support for accountChanged for Tron network (#23639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** After Solana, add the support for Tron account Change. In parallel, a new PR is in progress to factorize this code and make it generic for all non-EVM chains: Solana, Tron, Bitcoin, etc. ## **Changelog** CHANGELOG entry: Add Tron Account Change detection support for multichain Api (#23639) ## **Related issues** Fixes: ## **Manual testing steps** You could try with our Tron test app here: https://metamask.github.io/test-dapp-tron/latest/ ```gherkin Feature: User connection Scenario: User connection on Mainnet Given the network Shasta Network is selected by default When user user select mainnet network, and then select MetaMask Wallet Then MetaMask connection screen popup Scenario: User connection on Shasta (2 step connection) Given the network Shasta Network is selected by default When user user select MetaMask Wallet Then MetaMask connection screen popup to connect. Once connected an other connection screen to accept to connect to Shasta. Scenario: User reload the page Given the user is already connected to a specific network and account When user user reload the app Then the app should re-connect to Metamask automatically with the right account and network. Feature: Chain Change Scenario: Ask permission for new chain Given the network Shasta Network is the only one user accepted When user user switch to mainnet Then MetaMask connection screen popup to accept. Once connected the chain mainnet is selected. Scenario: Do not ask permission for chain Given the network Shasta Network AND mainnet are allowed When user user switch to mainnet Then the chain mainnet is selected. Feature: Account Change Scenario: User do not change if select a not allowed account Given the user allow only one account When user user switch to an other account Then nothing happens in the app. Scenario: User change if select an allowed account Given the user allow only 2 accounts When user user switch to the second account Then nothing the selected address change in the app Feature: Disconnect Scenario: User disconnects from MM Given the user is connected When user remove the session from MM Then the app disconnects ``` ## **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. ## **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] > Adds Tron accountChanged notifications to the Multichain provider, reusing a generalized notification helper, with new event subscriptions, tests, and dependency bumps. > > - **Core (`app/core/BackgroundBridge/BackgroundBridge.js`)** > - **Tron accountChanged support**: > - Add Tron-specific handlers: `handleTronAccountChangedFromScopeChanges`, `handleTronAccountChangedFromSelectedAccountChanges`, `handleTronAccountChangedFromSelectedAccountGroupChanges`, and `getTronAccountFromSelectedAccountGroup`. > - Track last selected Tron account via `lastSelectedTronAccountAddress` and emit initial `notifyTronAccountChangedForCurrentAccount()` on provider setup. > - Subscribe/unsubscribe to Tron-related Permission, Account, and AccountGroup events. > - **Generalization**: > - Replace ` > _notifySolanaAccountChange` with ` > _notifyMultichainAccountChange(scope, value)` and use for Solana and Tron. > - Rename helper to `getSolanaAccountFromSelectedAccountGroup()` and update Solana flows accordingly. > - **Tests (`app/core/BackgroundBridge/BackgroundBridge.test.js`)** > - Add comprehensive Tron tests for permissions, selected account changes, and account group changes. > - Minor Solana test assertion fix and alignment with generalized notifier. > - **Dependencies (`package.json`, `yarn.lock`)** > - Bump `@metamask/chain-agnostic-permission` to `^1.3.0`. > - Bump `@metamask/multichain-api-client` to `^0.10.1` and `@metamask/multichain-api-middleware` to `1.2.5`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3d95aa35ded9b46edc891b7e721f7d7495c19f6d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Baptiste Marchand <75846779+baptiste-marchand@users.noreply.github.com> --- app/core/BackgroundBridge/BackgroundBridge.js | 299 ++++++++- .../BackgroundBridge/BackgroundBridge.test.js | 610 +++++++++++++++++- package.json | 6 +- yarn.lock | 107 +-- 4 files changed, 915 insertions(+), 107 deletions(-) diff --git a/app/core/BackgroundBridge/BackgroundBridge.js b/app/core/BackgroundBridge/BackgroundBridge.js index 7d600f8a758..37cfb114843 100644 --- a/app/core/BackgroundBridge/BackgroundBridge.js +++ b/app/core/BackgroundBridge/BackgroundBridge.js @@ -83,7 +83,14 @@ import { import { createEip5792Middleware } from '../RPCMethods/createEip5792Middleware'; import { createOriginThrottlingMiddleware } from '../RPCMethods/OriginThrottlingMiddleware'; import { getAuthorizedScopes } from '../../selectors/permissions'; -import { SolAccountType, SolScope } from '@metamask/keyring-api'; +import { + SolAccountType, + SolScope, + ///: BEGIN:ONLY_INCLUDE_IF(tron) + TrxScope, + TrxAccountType, + ///: END:ONLY_INCLUDE_IF +} from '@metamask/keyring-api'; import { parseCaipAccountId } from '@metamask/utils'; import { toFormattedAddress, areAddressesEqual } from '../../util/address'; import { isSameOrigin } from '../../util/url'; @@ -150,6 +157,9 @@ export class BackgroundBridge extends EventEmitter { this.multichainMiddlewareManager = null; this.lastSelectedSolanaAccountAddress = null; + ///: BEGIN:ONLY_INCLUDE_IF(tron) + this.lastSelectedTronAccountAddress = null; + ///: END:ONLY_INCLUDE_IF const networkClientId = Engine.controllerMessenger.call( 'SelectedNetworkController:getNetworkClientIdForDomain', @@ -512,6 +522,20 @@ export class BackgroundBridge extends EventEmitter { `${AccountTreeController.name}:selectedAccountGroupChange`, this.handleSolanaAccountChangedFromSelectedAccountGroupChanges, ); + ///: BEGIN:ONLY_INCLUDE_IF(tron) + controllerMessenger.unsubscribe( + `${PermissionController.name}:stateChange`, + this.handleTronAccountChangedFromScopeChanges, + ); + controllerMessenger.unsubscribe( + `${AccountsController.name}:selectedAccountChange`, + this.handleTronAccountChangedFromSelectedAccountChanges, + ); + controllerMessenger.unsubscribe( + `${AccountTreeController.name}:selectedAccountGroupChange`, + this.handleTronAccountChangedFromSelectedAccountGroupChanges, + ); + ///: END:ONLY_INCLUDE_IF } this.port.emit('disconnect', { name: this.port.name, data: null }); @@ -552,8 +576,11 @@ export class BackgroundBridge extends EventEmitter { // transport. Unlike externally_connectable's chrome.runtime.connect() API, the // window.postMessage API allows the inpage provider to setup listeners for // messages before attempting to establish the connection meaning that it will - // have listeners ready for this solana accountChanged event below. + // have listeners ready for this Solana and Tron accountChanged event below. this.notifySolanaAccountChangedForCurrentAccount(); + ///: BEGIN:ONLY_INCLUDE_IF(tron) + this.notifyTronAccountChangedForCurrentAccount(); + ///: END:ONLY_INCLUDE_IF pump(outStream, providerStream, outStream, (err) => { // handle any middleware cleanup @@ -910,7 +937,7 @@ export class BackgroundBridge extends EventEmitter { context: { AccountsController, PermissionController }, } = Engine; - // this throws if there is no solana account... perhaps we should handle this better at the controller level + // this throws if there is no Solana or Tron account... perhaps we should handle this better at the controller level try { this.lastSelectedSolanaAccountAddress = AccountsController.getSelectedMultichainAccount( @@ -919,6 +946,16 @@ export class BackgroundBridge extends EventEmitter { } catch { // noop } + ///: BEGIN:ONLY_INCLUDE_IF(tron) + try { + this.lastSelectedTronAccountAddress = + AccountsController.getSelectedMultichainAccount( + TrxScope.Mainnet, + )?.address; + } catch { + // noop + } + ///: END:ONLY_INCLUDE_IF // wallet_sessionChanged and eth_subscription setup/teardown controllerMessenger.subscribe( @@ -927,14 +964,14 @@ export class BackgroundBridge extends EventEmitter { getAuthorizedScopes(this.channelIdOrOrigin), ); - // wallet_notify for solana accountChanged when permission changes + // wallet_notify for Solana accountChanged when permission changes controllerMessenger.subscribe( `${PermissionController.name}:stateChange`, this.handleSolanaAccountChangedFromScopeChanges, getAuthorizedScopes(this.channelIdOrOrigin), ); - // wallet_notify for solana accountChanged when selected account changes + // wallet_notify for Solana accountChanged when selected account changes controllerMessenger.subscribe( `${AccountsController.name}:selectedAccountChange`, this.handleSolanaAccountChangedFromSelectedAccountChanges, @@ -944,6 +981,26 @@ export class BackgroundBridge extends EventEmitter { `${AccountTreeController.name}:selectedAccountGroupChange`, this.handleSolanaAccountChangedFromSelectedAccountGroupChanges, ); + + ///: BEGIN:ONLY_INCLUDE_IF(tron) + // wallet_notify for Tron accountChanged when permission changes + controllerMessenger.subscribe( + `${PermissionController.name}:stateChange`, + this.handleTronAccountChangedFromScopeChanges, + getAuthorizedScopes(this.channelIdOrOrigin), + ); + + // wallet_notify for Tron accountChanged when selected account changes + controllerMessenger.subscribe( + `${AccountsController.name}:selectedAccountChange`, + this.handleTronAccountChangedFromSelectedAccountChanges, + ); + + controllerMessenger.subscribe( + `${AccountTreeController.name}:selectedAccountGroupChange`, + this.handleTronAccountChangedFromSelectedAccountGroupChanges, + ); + ///: END:ONLY_INCLUDE_IF } /** @@ -1062,10 +1119,11 @@ export class BackgroundBridge extends EventEmitter { previousSelectedSolanaAccountAddress !== currentSelectedSolanaAccountAddress ) { - this._notifySolanaAccountChange( + this._notifyMultichainAccountChange( currentSelectedSolanaAccountAddress ? [currentSelectedSolanaAccountAddress] : [], + SolScope.Mainnet, ); } }; @@ -1111,19 +1169,22 @@ export class BackgroundBridge extends EventEmitter { }); if (parsedSolanaAddresses.includes(account.address)) { - this._notifySolanaAccountChange([account.address]); + this._notifyMultichainAccountChange( + [account.address], + SolScope.Mainnet, + ); } } }; handleSolanaAccountChangedFromSelectedAccountGroupChanges = () => { - const solanaAccount = this.getNonEvmAccountFromSelectedAccountGroup(); + const solanaAccount = this.getSolanaAccountFromSelectedAccountGroup(); if (solanaAccount) { this.handleSolanaAccountChangedFromSelectedAccountChanges(solanaAccount); } }; - getNonEvmAccountFromSelectedAccountGroup() { + getSolanaAccountFromSelectedAccountGroup() { const controllerMessenger = Engine.controllerMessenger; const [solanaAccount] = controllerMessenger.call( @@ -1133,6 +1194,133 @@ export class BackgroundBridge extends EventEmitter { return solanaAccount; } + ///: BEGIN:ONLY_INCLUDE_IF(tron) + handleTronAccountChangedFromScopeChanges = (currentValue, previousValue) => { + const previousTronAccountChangedNotificationsEnabled = Boolean( + previousValue?.sessionProperties?.[ + KnownSessionProperties.TronAccountChangedNotifications + ], + ); + const currentTronAccountChangedNotificationsEnabled = Boolean( + currentValue?.sessionProperties?.[ + KnownSessionProperties.TronAccountChangedNotifications + ], + ); + + if ( + !previousTronAccountChangedNotificationsEnabled && + !currentTronAccountChangedNotificationsEnabled + ) { + return; + } + + const previousTronCaipAccountIds = previousValue + ? getPermittedAccountsForScopes(previousValue, [ + TrxScope.Mainnet, + TrxScope.Shasta, + TrxScope.Nile, + ]) + : []; + + const [previousSelectedTronAccountId] = + sortMultichainAccountsByLastSelected(previousTronCaipAccountIds); + const previousSelectedTronAccountAddress = previousSelectedTronAccountId + ? parseCaipAccountId(previousSelectedTronAccountId).address + : ''; + + const currentTronCaipAccountIds = currentValue + ? getPermittedAccountsForScopes(currentValue, [ + TrxScope.Mainnet, + TrxScope.Shasta, + TrxScope.Nile, + ]) + : []; + const [currentSelectedTronAccountId] = sortMultichainAccountsByLastSelected( + currentTronCaipAccountIds, + ); + const currentSelectedTronAccountAddress = currentSelectedTronAccountId + ? parseCaipAccountId(currentSelectedTronAccountId).address + : ''; + + if ( + previousSelectedTronAccountAddress !== currentSelectedTronAccountAddress + ) { + this._notifyMultichainAccountChange( + currentSelectedTronAccountAddress + ? [currentSelectedTronAccountAddress] + : [], + TrxScope.Mainnet, + ); + } + }; + + handleTronAccountChangedFromSelectedAccountChanges = (account) => { + if ( + account.type === TrxAccountType.Eoa && + !areAddressesEqual(account.address, this.lastSelectedTronAccountAddress) + ) { + this.lastSelectedTronAccountAddress = account.address; + + let caip25Caveat; + try { + caip25Caveat = Engine.context.PermissionController.getCaveat( + this.channelIdOrOrigin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + } catch { + // noop + } + if (!caip25Caveat) { + return; + } + + const shouldNotifyTronAccountChanged = + caip25Caveat.value.sessionProperties?.[ + KnownSessionProperties.TronAccountChangedNotifications + ]; + if (!shouldNotifyTronAccountChanged) { + return; + } + + const tronAccounts = getPermittedAccountsForScopes(caip25Caveat.value, [ + TrxScope.Mainnet, + TrxScope.Shasta, + TrxScope.Nile, + ]); + + const parsedTronAddresses = tronAccounts.map((caipAccountId) => { + const { address } = parseCaipAccountId(caipAccountId); + return address; + }); + + if (parsedTronAddresses.includes(account.address)) { + this._notifyMultichainAccountChange( + [account.address], + TrxScope.Mainnet, + ); + } + } + }; + + handleTronAccountChangedFromSelectedAccountGroupChanges = () => { + const tronAccount = this.getTronAccountFromSelectedAccountGroup(); + if (tronAccount) { + this.handleTronAccountChangedFromSelectedAccountChanges(tronAccount); + } + }; + + getTronAccountFromSelectedAccountGroup() { + const controllerMessenger = Engine.controllerMessenger; + + const [tronAccount] = controllerMessenger.call( + `AccountTreeController:getAccountsFromSelectedAccountGroup`, + { type: TrxAccountType.Eoa }, + ); + return tronAccount; + } + ///: END:ONLY_INCLUDE_IF + sendNotificationEip1193(payload) { DevLogger.log(`BackgroundBridge::sendNotificationEip1193: `, payload); this.engine && this.engine.emit('notification', payload); @@ -1224,11 +1412,11 @@ export class BackgroundBridge extends EventEmitter { } /** - * For origins with a solana scope permitted, sends a wallet_notify -> metamask_accountChanged - * event to fire for the solana scope with the currently selected solana account if any are + * For origins with a Solana scope permitted, sends a wallet_notify -> metamask_accountChanged + * event to fire for the Solana scope with the currently selected Solana account if any are * permitted or empty array otherwise. * - * @param {string} origin - The origin to notify with the current solana account + * @param {string} origin - The origin to notify with the current Solana account */ notifySolanaAccountChangedForCurrentAccount() { let caip25Caveat; @@ -1265,12 +1453,13 @@ export class BackgroundBridge extends EventEmitter { if (solanaAccountsChangedNotifications && solanaScope) { const currentSolanaAccountFromSelectedAccountGroup = - this.getNonEvmAccountFromSelectedAccountGroup(); + this.getSolanaAccountFromSelectedAccountGroup(); if (currentSolanaAccountFromSelectedAccountGroup) { - this._notifySolanaAccountChange([ - currentSolanaAccountFromSelectedAccountGroup.address, - ]); + this._notifyMultichainAccountChange( + [currentSolanaAccountFromSelectedAccountGroup.address], + SolScope.Mainnet, + ); return; } @@ -1281,16 +1470,88 @@ export class BackgroundBridge extends EventEmitter { if (accountIdToEmit) { const accountAddressToEmit = parseCaipAccountId(accountIdToEmit).address; - this._notifySolanaAccountChange([accountAddressToEmit]); + this._notifyMultichainAccountChange( + [accountAddressToEmit], + SolScope.Mainnet, + ); + } + } + } + + ///: BEGIN:ONLY_INCLUDE_IF(tron) + /** + * For origins with a Tron scope permitted, sends a wallet_notify -> metamask_accountChanged + * event to fire for the Tron scope with the currently selected Tron account if any are + * permitted or empty array otherwise. + * + * @param {string} origin - The origin to notify with the current Tron account + */ + notifyTronAccountChangedForCurrentAccount() { + let caip25Caveat; + try { + caip25Caveat = Engine.context.PermissionController.getCaveat( + this.channelIdOrOrigin, + Caip25EndowmentPermissionName, + Caip25CaveatType, + ); + } catch (err) { + if (err instanceof PermissionDoesNotExistError) { + // suppress expected error in case that the origin + // does not have the target permission yet + return; + } + throw err; + } + if (!caip25Caveat) { + return; + } + const tronAccountsChangedNotifications = + caip25Caveat.value.sessionProperties[ + KnownSessionProperties.TronAccountChangedNotifications + ]; + + const sessionScopes = getSessionScopes(caip25Caveat.value, { + getNonEvmSupportedMethods: this.getNonEvmSupportedMethods.bind(this), + }); + + const tronScope = + sessionScopes[TrxScope.Mainnet] || + sessionScopes[TrxScope.Shasta] || + sessionScopes[TrxScope.Nile]; + + if (tronAccountsChangedNotifications && tronScope) { + const currentTronAccountFromSelectedAccountGroup = + this.getTronAccountFromSelectedAccountGroup(); + + if (currentTronAccountFromSelectedAccountGroup) { + this._notifyMultichainAccountChange( + [currentTronAccountFromSelectedAccountGroup.address], + TrxScope.Mainnet, + ); + return; + } + + const { accounts } = tronScope; + + const [accountIdToEmit] = sortMultichainAccountsByLastSelected(accounts); + + if (accountIdToEmit) { + const accountAddressToEmit = + parseCaipAccountId(accountIdToEmit).address; + this._notifyMultichainAccountChange( + [accountAddressToEmit], + TrxScope.Mainnet, + ); } } } + ///: END:ONLY_INCLUDE_IF - _notifySolanaAccountChange(value) { + _notifyMultichainAccountChange(value, scope) { this.sendNotificationMultichain({ method: MultichainApiNotifications.walletNotify, params: { - scope: SolScope.Mainnet, + scope, notification: { method: NOTIFICATION_NAMES.accountsChanged, params: value, diff --git a/app/core/BackgroundBridge/BackgroundBridge.test.js b/app/core/BackgroundBridge/BackgroundBridge.test.js index 614fc8cb645..05330ff0bce 100644 --- a/app/core/BackgroundBridge/BackgroundBridge.test.js +++ b/app/core/BackgroundBridge/BackgroundBridge.test.js @@ -11,6 +11,8 @@ import { EthAccountType, SolAccountType, SolScope, + TrxAccountType, + TrxScope, } from '@metamask/keyring-api'; jest.mock('../Engine', () => ({ @@ -657,7 +659,7 @@ describe('BackgroundBridge', () => { previousValue, ); - expect(sendNotificationSpy).not.toHaveBeenCalledWith(); + expect(sendNotificationSpy).not.toHaveBeenCalled(); }); it('emits nothing if currently and previously selected solana accounts did not change', () => { @@ -699,7 +701,7 @@ describe('BackgroundBridge', () => { previousValue, ); - expect(sendNotificationSpy).not.toHaveBeenCalledWith(); + expect(sendNotificationSpy).not.toHaveBeenCalled(); }); it('emits the currently selected solana account if the currently selected solana accounts did change', () => { @@ -1046,4 +1048,608 @@ describe('BackgroundBridge', () => { expect(handleSolanaAccountSpy).toHaveBeenCalledTimes(1); }); }); + + describe('notifyTronAccountChangedForCurrentAccount', () => { + it('emits nothing if there is no CAIP-25 permission', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const sendNotificationSpy = jest.spyOn( + bridge, + 'sendNotificationMultichain', + ); + + bridge.notifyTronAccountChangedForCurrentAccount(); + + expect(sendNotificationSpy).not.toHaveBeenCalled(); + }); + + it('emits nothing if there are no permitted tron scopes and `tron_accountChanged_notifications` session property is set', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const sendNotificationSpy = jest.spyOn( + bridge, + 'sendNotificationMultichain', + ); + PermissionController.getCaveat.mockReturnValue({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + 'eip155:1': { + accounts: [], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + tron_accountChanged_notifications: true, + }, + }, + }); + + bridge.notifyTronAccountChangedForCurrentAccount(); + + expect(sendNotificationSpy).not.toHaveBeenCalled(); + }); + + it('emits nothing if there are permitted tron accounts, but the `tron_accountChanged_notifications` session property is not set', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const sendNotificationSpy = jest.spyOn( + bridge, + 'sendNotificationMultichain', + ); + PermissionController.getCaveat.mockReturnValue({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + [TrxScope.Mainnet]: { + accounts: [`${TrxScope.Mainnet}:someaddress`], + }, + }, + isMultichainOrigin: true, + sessionProperties: {}, + }, + }); + + bridge.notifyTronAccountChangedForCurrentAccount(); + + expect(sendNotificationSpy).not.toHaveBeenCalled(); + }); + + it('emits nothing if there are permitted tron scopes but no accounts and the `tron_accountChanged_notifications` session property is set', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const sendNotificationSpy = jest.spyOn( + bridge, + 'sendNotificationMultichain', + ); + PermissionController.getCaveat.mockReturnValue({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + [TrxScope.Mainnet]: { + accounts: [], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + tron_accountChanged_notifications: true, + }, + }, + }); + + bridge.notifyTronAccountChangedForCurrentAccount(); + + expect(sendNotificationSpy).not.toHaveBeenCalled(); + }); + + it('emits a tron accountChanged event when there are permitted tron accounts and the `tron_accountChanged_notifications` session property is set', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const sendNotificationSpy = jest.spyOn( + bridge, + 'sendNotificationMultichain', + ); + PermissionController.getCaveat.mockReturnValue({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + [TrxScope.Mainnet]: { + accounts: [`${TrxScope.Mainnet}:someaddress`], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + tron_accountChanged_notifications: true, + }, + }, + }); + + bridge.notifyTronAccountChangedForCurrentAccount(); + + expect(sendNotificationSpy).toHaveBeenCalledWith({ + method: 'wallet_notify', + params: { + notification: { + method: 'metamask_accountsChanged', + params: ['someaddress'], + }, + scope: TrxScope.Mainnet, + }, + }); + }); + + it('prioritizes tron account from selected account group over scope accounts', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const sendNotificationSpy = jest.spyOn( + bridge, + 'sendNotificationMultichain', + ); + + const selectedGroupTronAccount = { + type: TrxAccountType.Eoa, + address: 'TRXAddressExample123456789', + }; + mockAccountTreeController([selectedGroupTronAccount]); + + PermissionController.getCaveat.mockReturnValue({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + [TrxScope.Mainnet]: { + accounts: [`${TrxScope.Mainnet}:TDifferentAddress987654321`], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + [KnownSessionProperties.TronAccountChangedNotifications]: true, + }, + }, + }); + + bridge.notifyTronAccountChangedForCurrentAccount(); + + expect(sendNotificationSpy).toHaveBeenCalledWith({ + method: 'wallet_notify', + params: { + notification: { + method: 'metamask_accountsChanged', + params: ['TRXAddressExample123456789'], + }, + scope: TrxScope.Mainnet, + }, + }); + }); + }); + + describe('handleTronAccountChangedFromScopeChanges', () => { + it('emits nothing if the current and previous permissions both did not have `tron_accountChanged_notifications` session property set', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const sendNotificationSpy = jest.spyOn( + bridge, + 'sendNotificationMultichain', + ); + + const currentValue = { + requiredScopes: {}, + optionalScopes: { + [TrxScope.Mainnet]: { + accounts: [`${TrxScope.Mainnet}:456`], + }, + }, + isMultichainOrigin: true, + sessionProperties: {}, + }; + + const previousValue = { + requiredScopes: {}, + optionalScopes: { + [TrxScope.Mainnet]: { + accounts: [`${TrxScope.Mainnet}:123`], + }, + }, + isMultichainOrigin: true, + sessionProperties: {}, + }; + + bridge.handleTronAccountChangedFromScopeChanges( + currentValue, + previousValue, + ); + + expect(sendNotificationSpy).not.toHaveBeenCalled(); + }); + + it('emits nothing if currently and previously selected tron accounts did not change', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const sendNotificationSpy = jest.spyOn( + bridge, + 'sendNotificationMultichain', + ); + + const currentValue = { + requiredScopes: {}, + optionalScopes: { + [TrxScope.Mainnet]: { + accounts: [`${TrxScope.Mainnet}:123`], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + tron_accountChanged_notifications: true, + }, + }; + + const previousValue = { + requiredScopes: {}, + optionalScopes: { + [TrxScope.Mainnet]: { + accounts: [`${TrxScope.Mainnet}:123`], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + tron_accountChanged_notifications: true, + }, + }; + + bridge.handleTronAccountChangedFromScopeChanges( + currentValue, + previousValue, + ); + + expect(sendNotificationSpy).not.toHaveBeenCalled(); + }); + + it('emits the currently selected tron account if the currently selected tron accounts did change', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const sendNotificationSpy = jest.spyOn( + bridge, + 'sendNotificationMultichain', + ); + + const currentValue = { + requiredScopes: {}, + optionalScopes: { + [TrxScope.Mainnet]: { + accounts: [`${TrxScope.Mainnet}:456`], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + tron_accountChanged_notifications: true, + }, + }; + + const previousValue = { + requiredScopes: {}, + optionalScopes: { + [TrxScope.Mainnet]: { + accounts: [`${TrxScope.Mainnet}:123`], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + tron_accountChanged_notifications: true, + }, + }; + + bridge.handleTronAccountChangedFromScopeChanges( + currentValue, + previousValue, + ); + + expect(sendNotificationSpy).toHaveBeenCalledWith({ + method: 'wallet_notify', + params: { + notification: { + method: 'metamask_accountsChanged', + params: ['456'], + }, + scope: TrxScope.Mainnet, + }, + }); + }); + }); + + describe('handleTronAccountChangedFromSelectedAccountChanges', () => { + it('emits nothing if the selected account is not a tron account', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const sendNotificationSpy = jest.spyOn( + bridge, + 'sendNotificationMultichain', + ); + PermissionController.getCaveat.mockReturnValue({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + [TrxScope.Mainnet]: { + accounts: [`${TrxScope.Mainnet}:someaddress`], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + tron_accountChanged_notifications: true, + }, + }, + }); + + bridge.handleTronAccountChangedFromSelectedAccountChanges({ + type: EthAccountType.Eoa, + address: 'someaddress', + }); + + expect(sendNotificationSpy).not.toHaveBeenCalled(); + }); + + it('emits nothing if the selected account did not change from the last seen tron account', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const sendNotificationSpy = jest.spyOn( + bridge, + 'sendNotificationMultichain', + ); + PermissionController.getCaveat.mockReturnValue({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + [TrxScope.Mainnet]: { + accounts: [`${TrxScope.Mainnet}:someaddress`], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + tron_accountChanged_notifications: true, + }, + }, + }); + bridge.lastSelectedTronAccountAddress = 'someaddress'; + + bridge.handleTronAccountChangedFromSelectedAccountChanges({ + type: TrxAccountType.Eoa, + address: 'someaddress', + }); + + expect(sendNotificationSpy).not.toHaveBeenCalled(); + }); + + it('emits nothing if there is no CAIP-25 permission', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const sendNotificationSpy = jest.spyOn( + bridge, + 'sendNotificationMultichain', + ); + PermissionController.getCaveat.mockReturnValue(); + + bridge.handleTronAccountChangedFromSelectedAccountChanges({ + type: TrxAccountType.Eoa, + address: 'someaddress', + }); + + expect(sendNotificationSpy).not.toHaveBeenCalled(); + }); + + it('emits nothing if the `tron_accountChanged_notifications` session property is not set', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const sendNotificationSpy = jest.spyOn( + bridge, + 'sendNotificationMultichain', + ); + PermissionController.getCaveat.mockReturnValue({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + [TrxScope.Mainnet]: { + accounts: [`${TrxScope.Mainnet}:someaddress`], + }, + }, + isMultichainOrigin: true, + sessionProperties: {}, + }, + }); + + bridge.handleTronAccountChangedFromSelectedAccountChanges({ + type: TrxAccountType.Eoa, + address: 'someaddress', + }); + + expect(sendNotificationSpy).not.toHaveBeenCalled(); + }); + + it('emits nothing if the selected account does not match a permitted tron account', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const sendNotificationSpy = jest.spyOn( + bridge, + 'sendNotificationMultichain', + ); + PermissionController.getCaveat.mockReturnValue({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + [TrxScope.Mainnet]: { + accounts: [`${TrxScope.Mainnet}:someaddress`], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + tron_accountChanged_notifications: true, + }, + }, + }); + + bridge.handleTronAccountChangedFromSelectedAccountChanges({ + type: TrxAccountType.Eoa, + address: 'differentaddress', + }); + + expect(sendNotificationSpy).not.toHaveBeenCalled(); + }); + + it('emits a tron accountChanged event for the selected account if it does match a permitted tron account', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const sendNotificationSpy = jest.spyOn( + bridge, + 'sendNotificationMultichain', + ); + PermissionController.getCaveat.mockReturnValue({ + type: Caip25CaveatType, + value: { + requiredScopes: {}, + optionalScopes: { + [TrxScope.Mainnet]: { + accounts: [`${TrxScope.Mainnet}:someaddress`], + }, + }, + isMultichainOrigin: true, + sessionProperties: { + tron_accountChanged_notifications: true, + }, + }, + }); + + bridge.handleTronAccountChangedFromSelectedAccountChanges({ + type: TrxAccountType.Eoa, + address: 'someaddress', + }); + + expect(sendNotificationSpy).toHaveBeenCalledWith({ + method: 'wallet_notify', + params: { + notification: { + method: 'metamask_accountsChanged', + params: ['someaddress'], + }, + scope: TrxScope.Mainnet, + }, + }); + }); + }); + + describe('handleTronAccountChangedFromSelectedAccountGroupChanges', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('emits nothing when AccountTreeController returns no accounts', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const handleTronAccountSpy = jest.spyOn( + bridge, + 'handleTronAccountChangedFromSelectedAccountChanges', + ); + + mockAccountTreeController([]); + + bridge.handleTronAccountChangedFromSelectedAccountGroupChanges(); + + expect(Engine.controllerMessenger.call).toHaveBeenCalledWith( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + { type: TrxAccountType.Eoa }, + ); + expect(handleTronAccountSpy).not.toHaveBeenCalled(); + }); + + it('emits nothing when AccountTreeController returns only non-Tron accounts', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const handleTronAccountSpy = jest.spyOn( + bridge, + 'handleTronAccountChangedFromSelectedAccountChanges', + ); + + const mockAccounts = [ + { type: EthAccountType.Eoa, address: 'eth-address-1' }, + { type: EthAccountType.Erc4337, address: 'eth-address-2' }, + ]; + + mockAccountTreeController(mockAccounts); + + bridge.handleTronAccountChangedFromSelectedAccountGroupChanges(); + + expect(Engine.controllerMessenger.call).toHaveBeenCalledWith( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + { type: TrxAccountType.Eoa }, + ); + expect(handleTronAccountSpy).not.toHaveBeenCalled(); + }); + + it('calls handleTronAccountChangedFromSelectedAccountChanges when AccountTreeController returns a Tron account', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const handleTronAccountSpy = jest.spyOn( + bridge, + 'handleTronAccountChangedFromSelectedAccountChanges', + ); + + const mockTronAccount = { + type: TrxAccountType.Eoa, + address: 'tron-address-1', + }; + const mockAccounts = [mockTronAccount]; + + mockAccountTreeController(mockAccounts); + + bridge.handleTronAccountChangedFromSelectedAccountGroupChanges(); + + expect(Engine.controllerMessenger.call).toHaveBeenCalledWith( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + { type: TrxAccountType.Eoa }, + ); + expect(handleTronAccountSpy).toHaveBeenCalledWith(mockTronAccount); + }); + + it('processes only the first Tron account when multiple valid Tron accounts exist', () => { + const url = 'https:www.mock.io'; + const bridge = setupBackgroundBridge(url); + const handleTronAccountSpy = jest.spyOn( + bridge, + 'handleTronAccountChangedFromSelectedAccountChanges', + ); + + const mockTronAccount1 = { + type: TrxAccountType.Eoa, + address: 'first-tron-address', + }; + const mockTronAccount2 = { + type: TrxAccountType.Eoa, + address: 'second-tron-address', + }; + const mockTronAccount3 = { + type: TrxAccountType.Eoa, + address: 'third-tron-address', + }; + + mockAccountTreeController([ + mockTronAccount1, + mockTronAccount2, + mockTronAccount3, + ]); + + bridge.handleTronAccountChangedFromSelectedAccountGroupChanges(); + + expect(Engine.controllerMessenger.call).toHaveBeenCalledWith( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + { type: TrxAccountType.Eoa }, + ); + expect(handleTronAccountSpy).toHaveBeenCalledWith(mockTronAccount1); + expect(handleTronAccountSpy).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/package.json b/package.json index 2fcccd8a286..6ff97854faa 100644 --- a/package.json +++ b/package.json @@ -203,7 +203,7 @@ "@metamask/bitcoin-wallet-snap": "^1.8.0", "@metamask/bridge-controller": "^64.1.0", "@metamask/bridge-status-controller": "^64.0.1", - "@metamask/chain-agnostic-permission": "^1.2.2", + "@metamask/chain-agnostic-permission": "^1.3.0", "@metamask/composable-controller": "^12.0.0", "@metamask/controller-utils": "^11.16.0", "@metamask/core-backend": "^4.1.0", @@ -245,8 +245,8 @@ "@metamask/mobile-wallet-protocol-core": "^0.3.1", "@metamask/mobile-wallet-protocol-wallet-client": "^0.2.1", "@metamask/multichain-account-service": "^3.0.0", - "@metamask/multichain-api-client": "^0.7.0", - "@metamask/multichain-api-middleware": "1.2.4", + "@metamask/multichain-api-client": "^0.10.1", + "@metamask/multichain-api-middleware": "1.2.5", "@metamask/multichain-network-controller": "^2.0.0", "@metamask/multichain-transactions-controller": "^6.0.0", "@metamask/native-utils": "^0.8.0", diff --git a/yarn.lock b/yarn.lock index feacceff78f..bdf7a705e76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7409,18 +7409,18 @@ __metadata: languageName: node linkType: hard -"@metamask/chain-agnostic-permission@npm:^1.2.1, @metamask/chain-agnostic-permission@npm:^1.2.2": - version: 1.2.2 - resolution: "@metamask/chain-agnostic-permission@npm:1.2.2" +"@metamask/chain-agnostic-permission@npm:^1.2.1, @metamask/chain-agnostic-permission@npm:^1.3.0": + version: 1.3.0 + resolution: "@metamask/chain-agnostic-permission@npm:1.3.0" dependencies: "@metamask/api-specs": "npm:^0.14.0" - "@metamask/controller-utils": "npm:^11.14.1" - "@metamask/network-controller": "npm:^25.0.0" - "@metamask/permission-controller": "npm:^12.0.0" + "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/network-controller": "npm:^27.0.0" + "@metamask/permission-controller": "npm:^12.1.1" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.8.1" lodash: "npm:^4.17.21" - checksum: 10/cf647bd5e3d38de912439c52a75a9fde91cf2f47f5cbda49258d8c0a0b16e0cb503be430462d7fb96b76cc7fb52402eb2b2b9f1262c75bf028793963709a402c + checksum: 10/0a5cd61a32af9e605b0378697744a6ae964a8ca0ff599b207fa6184176b09b122f4af303c2a70abc909606a10c3eabaf06a87dc9f205f9e74626a7061e6f33d3 languageName: node linkType: hard @@ -7664,18 +7664,6 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-block-tracker@npm:^14.0.0": - version: 14.0.0 - resolution: "@metamask/eth-block-tracker@npm:14.0.0" - dependencies: - "@metamask/eth-json-rpc-provider": "npm:^5.0.1" - "@metamask/safe-event-emitter": "npm:^3.0.0" - "@metamask/utils": "npm:^11.8.1" - json-rpc-random-id: "npm:^1.0.1" - checksum: 10/63515bcc5fad22dba78d824f1fafd989e9a6770b318b91f46ba4a68326fafbeb9929146a82713f92e4920d489dc3a9bb81b5f457946053e8cf89bc4062788b36 - languageName: node - linkType: hard - "@metamask/eth-block-tracker@npm:^15.0.0": version: 15.0.0 resolution: "@metamask/eth-block-tracker@npm:15.0.0" @@ -7728,24 +7716,6 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-middleware@npm:^21.0.0": - version: 21.0.0 - resolution: "@metamask/eth-json-rpc-middleware@npm:21.0.0" - dependencies: - "@metamask/eth-block-tracker": "npm:^14.0.0" - "@metamask/eth-json-rpc-provider": "npm:^5.0.1" - "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/json-rpc-engine": "npm:^10.1.1" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.8.1" - klona: "npm:^2.0.6" - pify: "npm:^5.0.0" - safe-stable-stringify: "npm:^2.4.3" - checksum: 10/3859035c7647202160fa73a8b2837153454b0097713bf822b4c22a14e971176895546c548e5899f0e600d1adbc3b79071ad468a8a1df3f06247ff5bd8b867353 - languageName: node - linkType: hard - "@metamask/eth-json-rpc-middleware@npm:^22.0.0": version: 22.0.0 resolution: "@metamask/eth-json-rpc-middleware@npm:22.0.0" @@ -7778,7 +7748,7 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-provider@npm:^5.0.0, @metamask/eth-json-rpc-provider@npm:^5.0.1": +"@metamask/eth-json-rpc-provider@npm:^5.0.0": version: 5.0.1 resolution: "@metamask/eth-json-rpc-provider@npm:5.0.1" dependencies: @@ -8392,29 +8362,29 @@ __metadata: languageName: node linkType: hard -"@metamask/multichain-api-client@npm:^0.7.0": - version: 0.7.0 - resolution: "@metamask/multichain-api-client@npm:0.7.0" - checksum: 10/82c58ba382d651430cc27c15c1881ac9943131aebd66b1e404735795bcc4125cb20b74843504ffd207ba9e8c21272facfcd65a91c6f16846bf1aee4e83ed1fca +"@metamask/multichain-api-client@npm:^0.10.1": + version: 0.10.1 + resolution: "@metamask/multichain-api-client@npm:0.10.1" + checksum: 10/adedb90f433cd619eee54943197dc32eb30e461173e928ea698ec70bf8c6780d9ac0bb9b12a22a17f984170841c071d228aa49e6988d5060e8a30e99c433a8f5 languageName: node linkType: hard -"@metamask/multichain-api-middleware@npm:1.2.4": - version: 1.2.4 - resolution: "@metamask/multichain-api-middleware@npm:1.2.4" +"@metamask/multichain-api-middleware@npm:1.2.5": + version: 1.2.5 + resolution: "@metamask/multichain-api-middleware@npm:1.2.5" dependencies: "@metamask/api-specs": "npm:^0.14.0" - "@metamask/chain-agnostic-permission": "npm:^1.2.2" - "@metamask/controller-utils": "npm:^11.14.1" - "@metamask/json-rpc-engine": "npm:^10.1.1" - "@metamask/network-controller": "npm:^25.0.0" - "@metamask/permission-controller": "npm:^12.1.0" + "@metamask/chain-agnostic-permission": "npm:^1.3.0" + "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/json-rpc-engine": "npm:^10.2.0" + "@metamask/network-controller": "npm:^27.0.0" + "@metamask/permission-controller": "npm:^12.1.1" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.8.1" "@open-rpc/meta-schema": "npm:^1.14.6" "@open-rpc/schema-utils-js": "npm:^2.0.5" jsonschema: "npm:^1.4.1" - checksum: 10/0572858dec7bb47a75d4da531e3d030fe0ba67aee46dfd72daab7fe8669da0216d344fa17cee1cd8d0c428c4c1b975e1a2085fd7064114a8ebb5e4ce01b32782 + checksum: 10/57c90313fbb04c0d8ff2a79ab863930dc4fb0f678c219d3beb3aed55782cf419f9b4042479e0c9a2eb54ab0d0d11a4ec5ae1cd5d00e2882527c820a8b0f83104 languageName: node linkType: hard @@ -8492,35 +8462,6 @@ __metadata: languageName: node linkType: hard -"@metamask/network-controller@npm:^25.0.0": - version: 25.0.0 - resolution: "@metamask/network-controller@npm:25.0.0" - dependencies: - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.14.1" - "@metamask/eth-block-tracker": "npm:^14.0.0" - "@metamask/eth-json-rpc-infura": "npm:^10.3.0" - "@metamask/eth-json-rpc-middleware": "npm:^21.0.0" - "@metamask/eth-json-rpc-provider": "npm:^5.0.1" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/json-rpc-engine": "npm:^10.1.1" - "@metamask/messenger": "npm:^0.3.0" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/swappable-obj-proxy": "npm:^2.3.0" - "@metamask/utils": "npm:^11.8.1" - async-mutex: "npm:^0.5.0" - fast-deep-equal: "npm:^3.1.3" - immer: "npm:^9.0.6" - loglevel: "npm:^1.8.1" - reselect: "npm:^5.1.1" - uri-js: "npm:^4.4.1" - uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/error-reporting-service": ^3.0.0 - checksum: 10/1fcad4f22f4b090b7de64134fdc6e4085f5bb4bb1dea53d7d25122c708f18d90a61caa5e6098a483ecbbb8cd93cdd2a145484cb129c7e8def4a9b726e9b4ee4c - languageName: node - linkType: hard - "@metamask/network-controller@npm:^27.0.0": version: 27.0.0 resolution: "@metamask/network-controller@npm:27.0.0" @@ -34225,7 +34166,7 @@ __metadata: "@metamask/bridge-status-controller": "npm:^64.0.1" "@metamask/browser-passworder": "npm:^5.0.0" "@metamask/build-utils": "npm:^3.0.0" - "@metamask/chain-agnostic-permission": "npm:^1.2.2" + "@metamask/chain-agnostic-permission": "npm:^1.3.0" "@metamask/composable-controller": "npm:^12.0.0" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/core-backend": "npm:^4.1.0" @@ -34271,8 +34212,8 @@ __metadata: "@metamask/mobile-wallet-protocol-core": "npm:^0.3.1" "@metamask/mobile-wallet-protocol-wallet-client": "npm:^0.2.1" "@metamask/multichain-account-service": "npm:^3.0.0" - "@metamask/multichain-api-client": "npm:^0.7.0" - "@metamask/multichain-api-middleware": "npm:1.2.4" + "@metamask/multichain-api-client": "npm:^0.10.1" + "@metamask/multichain-api-middleware": "npm:1.2.5" "@metamask/multichain-network-controller": "npm:^2.0.0" "@metamask/multichain-transactions-controller": "npm:^6.0.0" "@metamask/native-utils": "npm:^0.8.0" From a47fbc7142e873661d5fe4aa9e4cf229f5947bb4 Mon Sep 17 00:00:00 2001 From: George Gkasdrogkas Date: Tue, 16 Dec 2025 18:27:26 +0200 Subject: [PATCH 09/12] feat: add destToken param to useSwapBridgeNavigation hook (#24023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add `destToken` param to `useSwapBridgeNavigation` with similar structure as `sourceToken`. Extend the functionality to use the user-provided `destToken` if exist else default back to default/native selection. `destToken` should be a valid object as no quotes will be fetched. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-3568 ## **Manual testing steps** ```gherkin The updated API is currently not used by anyone and is introduced to unblock work from other teams, thus there is not a way to test this on production app appart from ensuring that no regressions were introduced. To do that, please select various tokens from dashboards and navigate to swaps through asset screen, then verify upon entering a valid amount that quotes are fecthed. ``` ## **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. ## **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] > Adds optional destToken input and override handling to the swap/bridge navigation hook with default/native fallbacks and comprehensive tests (including Solana). > > - **Hook `useSwapBridgeNavigation`**: > - Accepts new optional `destToken` param; `goToSwaps` now supports `(tokenOverride?, destTokenOverride?)` and forwards both to `goToNativeBridge`. > - Adds destination token selection logic: prefer provided `destToken` when different from `sourceToken`; else compute via `getDefaultDestToken`; else fall back to `getNativeSourceToken`, ensuring source/dest differ. > - Refactors internals: `tokenBase` -> `sourceTokenBase`; `getEffectiveSourceChainId` naming; preserves CAIP chain IDs for non-EVM. > - **Navigation/State**: > - Dispatches `setDestToken` based on new logic; continues to reset `isDestTokenManuallySet` and set `sourceToken` before navigating. > - **Tests**: > - Adds extensive tests for dest token handling, overrides, fallbacks, and non-dispatch when identical. > - Covers Solana CAIP chain ID behavior and dest token dispatch. > - Mocks `getDefaultDestToken` and `getNativeSourceToken` in tests. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3132ffb7782b574866ca9846a6f79d90c1f8d423. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../hooks/useSwapBridgeNavigation/index.ts | 81 +++-- .../useSwapBridgeNavigation.test.ts | 289 ++++++++++++++++++ 2 files changed, 344 insertions(+), 26 deletions(-) diff --git a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts index 27f5157c9b2..8d85b3da3b5 100644 --- a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts +++ b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts @@ -47,16 +47,19 @@ export enum SwapBridgeNavigationLocation { * Returns functions that are used to navigate to the MetaMask Bridge and MetaMask Swaps routes. * @param location location of navigation call – used for analytics. * @param sourceToken token object containing address and chainId we want to set as source. + * @param destToken optional token object to set as destination. If not provided, or matches source, defaults to computed destination token. * @returns An object containing functions that can be used to navigate to the existing Bridges page in the browser and the MetaMask Swaps page. If there isn't an existing bridge page, one is created based on the current chain ID and passed token address (if provided). */ export const useSwapBridgeNavigation = ({ location, sourcePage, - sourceToken: tokenBase, + sourceToken: sourceTokenBase, + destToken: destTokenBase, }: { location: SwapBridgeNavigationLocation; sourcePage: string; sourceToken?: BridgeToken; + destToken?: BridgeToken; }) => { const navigation = useNavigation(); const dispatch = useDispatch(); @@ -68,15 +71,21 @@ export const useSwapBridgeNavigation = ({ // Unified swaps/bridge UI const goToNativeBridge = useCallback( - (bridgeViewMode: BridgeViewMode, tokenOverride?: BridgeToken) => { + ( + bridgeViewMode: BridgeViewMode, + sourceTokenOverride?: BridgeToken, + destTokenOverride?: BridgeToken, + ) => { // Use tokenOverride if provided, otherwise fall back to tokenBase - const effectiveTokenBase = tokenOverride ?? tokenBase; + const effectiveSourceTokenBase = sourceTokenOverride ?? sourceTokenBase; + // Use destTokenOverride if provided, otherwise fall back to destTokenBase + const effectiveDestTokenBase = destTokenOverride ?? destTokenBase; // Determine effective chain ID - use home page filter network when no sourceToken provided - const getEffectiveChainId = (): CaipChainId | Hex => { - if (effectiveTokenBase) { + const getEffectiveSourceChainId = (): CaipChainId | Hex => { + if (effectiveSourceTokenBase) { // If specific token provided, use its chainId - return effectiveTokenBase.chainId; + return effectiveSourceTokenBase.chainId; } // No token provided - check home page filter network @@ -92,12 +101,14 @@ export const useSwapBridgeNavigation = ({ return homePageFilterNetwork.caipChainId as CaipChainId; }; - const effectiveChainId = getEffectiveChainId(); + const effectiveSourceChainId = getEffectiveSourceChainId(); let bridgeSourceNativeAsset; try { - if (!effectiveTokenBase) { - bridgeSourceNativeAsset = getNativeAssetForChainId(effectiveChainId); + if (!effectiveSourceTokenBase) { + bridgeSourceNativeAsset = getNativeAssetForChainId( + effectiveSourceChainId, + ); } } catch (error) { // Suppress error as it's expected when the chain is not supported @@ -111,15 +122,17 @@ export const useSwapBridgeNavigation = ({ symbol: bridgeSourceNativeAsset.symbol, image: bridgeSourceNativeAsset.iconUrl ?? '', decimals: bridgeSourceNativeAsset.decimals, - chainId: isNonEvmChainId(effectiveChainId) - ? effectiveChainId - : formatChainIdToHex(effectiveChainId), // Use hex format for balance fetching compatibility, unless it's a Solana chain + chainId: isNonEvmChainId(effectiveSourceChainId) + ? effectiveSourceChainId + : formatChainIdToHex(effectiveSourceChainId), // Use hex format for balance fetching compatibility, unless it's a Solana chain } : undefined; const candidateSourceToken = - effectiveTokenBase ?? bridgeNativeSourceTokenFormatted; - const isBridgeEnabledSource = getIsBridgeEnabledSource(effectiveChainId); + effectiveSourceTokenBase ?? bridgeNativeSourceTokenFormatted; + const isBridgeEnabledSource = getIsBridgeEnabledSource( + effectiveSourceChainId, + ); let sourceToken = isBridgeEnabledSource ? candidateSourceToken : undefined; @@ -137,18 +150,29 @@ export const useSwapBridgeNavigation = ({ // Pre-populate Redux state before navigation to prevent empty button flash dispatch(setSourceToken(sourceToken)); - const defaultDestToken = getDefaultDestToken(sourceToken.chainId); - // Make sure source and dest tokens are different + // Use provided destToken if available and different from sourceToken, otherwise compute default if ( - defaultDestToken && - !areAddressesEqual(sourceToken.address, defaultDestToken.address) + effectiveDestTokenBase && + !areAddressesEqual(sourceToken.address, effectiveDestTokenBase.address) ) { - dispatch(setDestToken(defaultDestToken)); + dispatch(setDestToken(effectiveDestTokenBase)); } else { - // Fall back to native token if default dest is same as source - const nativeDestToken = getNativeSourceToken(sourceToken.chainId); - if (!areAddressesEqual(sourceToken.address, nativeDestToken.address)) { - dispatch(setDestToken(nativeDestToken)); + // Either no destToken provided, or it's the same as sourceToken - use default logic + const defaultDestToken = getDefaultDestToken(sourceToken.chainId); + // Make sure source and dest tokens are different + if ( + defaultDestToken && + !areAddressesEqual(sourceToken.address, defaultDestToken.address) + ) { + dispatch(setDestToken(defaultDestToken)); + } else { + // Fall back to native token if default dest is same as source + const nativeDestToken = getNativeSourceToken(sourceToken.chainId); + if ( + !areAddressesEqual(sourceToken.address, nativeDestToken.address) + ) { + dispatch(setDestToken(nativeDestToken)); + } } } @@ -201,7 +225,8 @@ export const useSwapBridgeNavigation = ({ [ navigation, dispatch, - tokenBase, + sourceTokenBase, + destTokenBase, sourcePage, trackEvent, createEventBuilder, @@ -213,8 +238,12 @@ export const useSwapBridgeNavigation = ({ const { networkModal } = useAddNetwork(); const goToSwaps = useCallback( - (tokenOverride?: BridgeToken) => { - goToNativeBridge(BridgeViewMode.Unified, tokenOverride); + (tokenOverride?: BridgeToken, destTokenOverride?: BridgeToken) => { + goToNativeBridge( + BridgeViewMode.Unified, + tokenOverride, + destTokenOverride, + ); }, [goToNativeBridge], ); diff --git a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts index 5401ed8ed5f..a7d839f7d21 100644 --- a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts +++ b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts @@ -39,6 +39,7 @@ jest.mock('../../../../hooks/useMetrics', () => { const mockGetIsBridgeEnabledSource = jest.fn(() => true); const mockSetIsDestTokenManuallySet = jest.fn(); +const mockSetDestToken = jest.fn(); jest.mock('../../../../../core/redux/slices/bridge', () => { const actual = jest.requireActual('../../../../../core/redux/slices/bridge'); return { @@ -50,6 +51,10 @@ jest.mock('../../../../../core/redux/slices/bridge', () => { mockSetIsDestTokenManuallySet(...args); return actual.setIsDestTokenManuallySet(...args); }, + setDestToken: (...args: unknown[]) => { + mockSetDestToken(...args); + return actual.setDestToken(...args); + }, }; }); @@ -108,6 +113,17 @@ jest.mock('@metamask/bridge-controller', () => ({ isSolanaChainId: jest.fn(), })); +// Mock token utilities +import { + getDefaultDestToken, + getNativeSourceToken, +} from '../../utils/tokenUtils'; + +jest.mock('../../utils/tokenUtils', () => ({ + getDefaultDestToken: jest.fn(), + getNativeSourceToken: jest.fn(), +})); + describe('useSwapBridgeNavigation', () => { const mockChainId = '0x1' as Hex; const mockLocation = SwapBridgeNavigationLocation.TabBar; @@ -121,6 +137,14 @@ describe('useSwapBridgeNavigation', () => { image: '', }; + const mockSourceToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000001', + symbol: 'SRC', + name: 'Source Token', + decimals: 18, + chainId: mockChainId, + }; + beforeEach(() => { jest.clearAllMocks(); @@ -157,6 +181,24 @@ describe('useSwapBridgeNavigation', () => { // Reset setIsDestTokenManuallySet mock mockSetIsDestTokenManuallySet.mockClear(); + mockSetDestToken.mockClear(); + + // Setup default mocks for token utilities + (getDefaultDestToken as jest.Mock).mockReturnValue({ + address: '0x6B175474E89094C44Da98b954EesdfDcD0E0e6F', + symbol: 'DAI', + name: 'Dai Stablecoin', + decimals: 18, + chainId: mockChainId, + }); + (getNativeSourceToken as jest.Mock).mockReturnValue({ + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + chainId: mockChainId, + image: '', + }); }); it('uses native token when no token is provided', () => { @@ -457,6 +499,226 @@ describe('useSwapBridgeNavigation', () => { }); }); + describe('destToken handling', () => { + it('dispatches provided destToken when different from sourceToken', () => { + const destToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000002', + symbol: 'DEST', + name: 'Destination Token', + decimals: 18, + chainId: mockChainId, + }; + + const { result } = renderHookWithProvider( + () => + useSwapBridgeNavigation({ + location: mockLocation, + sourcePage: mockSourcePage, + sourceToken: mockSourceToken, + destToken, + }), + { state: initialState }, + ); + + result.current.goToSwaps(); + + expect(mockSetDestToken).toHaveBeenCalledWith(destToken); + }); + + it('uses destTokenOverride when passed to goToSwaps', () => { + const configuredDestToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000002', + symbol: 'CONFIGURED', + name: 'Configured Dest Token', + decimals: 18, + chainId: mockChainId, + }; + + const overrideDestToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000003', + symbol: 'OVERRIDE', + name: 'Override Dest Token', + decimals: 18, + chainId: mockChainId, + }; + + const { result } = renderHookWithProvider( + () => + useSwapBridgeNavigation({ + location: mockLocation, + sourcePage: mockSourcePage, + sourceToken: mockSourceToken, + destToken: configuredDestToken, + }), + { state: initialState }, + ); + + result.current.goToSwaps(undefined, overrideDestToken); + + expect(mockSetDestToken).toHaveBeenCalledWith(overrideDestToken); + }); + + it('falls back to default when destToken same as sourceToken', () => { + const sameAsSourceToken: BridgeToken = { + ...mockSourceToken, + }; + + const defaultToken = { + address: '0x6B175474E89094C44Da98b954EesdfDcD0E0e6F', + symbol: 'DAI', + name: 'Dai Stablecoin', + decimals: 18, + chainId: mockChainId, + }; + + (getDefaultDestToken as jest.Mock).mockReturnValue(defaultToken); + + const { result } = renderHookWithProvider( + () => + useSwapBridgeNavigation({ + location: mockLocation, + sourcePage: mockSourcePage, + sourceToken: mockSourceToken, + destToken: sameAsSourceToken, + }), + { state: initialState }, + ); + + result.current.goToSwaps(); + + expect(mockSetDestToken).toHaveBeenCalledWith(defaultToken); + }); + + it('uses both sourceTokenOverride and destTokenOverride when passed to goToSwaps', () => { + const sourceOverride: BridgeToken = { + address: '0x0000000000000000000000000000000000000004', + symbol: 'SRC_OVERRIDE', + name: 'Source Override Token', + decimals: 18, + chainId: mockChainId, + }; + + const destOverride: BridgeToken = { + address: '0x0000000000000000000000000000000000000005', + symbol: 'DEST_OVERRIDE', + name: 'Dest Override Token', + decimals: 18, + chainId: mockChainId, + }; + + const { result } = renderHookWithProvider( + () => + useSwapBridgeNavigation({ + location: mockLocation, + sourcePage: mockSourcePage, + sourceToken: mockSourceToken, + }), + { state: initialState }, + ); + + result.current.goToSwaps(sourceOverride, destOverride); + + expect(mockNavigate).toHaveBeenCalledWith('Bridge', { + screen: 'BridgeView', + params: { + sourceToken: sourceOverride, + sourcePage: mockSourcePage, + bridgeViewMode: BridgeViewMode.Unified, + }, + }); + expect(mockSetDestToken).toHaveBeenCalledWith(destOverride); + }); + + it('falls back to native token when default dest same as source', () => { + const nativeToken = { + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + chainId: mockChainId, + image: '', + }; + + // Make default token same as source token + (getDefaultDestToken as jest.Mock).mockReturnValue({ + ...mockSourceToken, + }); + (getNativeSourceToken as jest.Mock).mockReturnValue(nativeToken); + + const { result } = renderHookWithProvider( + () => + useSwapBridgeNavigation({ + location: mockLocation, + sourcePage: mockSourcePage, + sourceToken: mockSourceToken, + }), + { state: initialState }, + ); + + result.current.goToSwaps(); + + expect(mockSetDestToken).toHaveBeenCalledWith(nativeToken); + }); + + it('falls back to native token when getDefaultDestToken returns null', () => { + const nativeToken = { + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + chainId: mockChainId, + image: '', + }; + + (getDefaultDestToken as jest.Mock).mockReturnValue(null); + (getNativeSourceToken as jest.Mock).mockReturnValue(nativeToken); + + const { result } = renderHookWithProvider( + () => + useSwapBridgeNavigation({ + location: mockLocation, + sourcePage: mockSourcePage, + sourceToken: mockSourceToken, + }), + { state: initialState }, + ); + + result.current.goToSwaps(); + + expect(mockSetDestToken).toHaveBeenCalledWith(nativeToken); + }); + + it('does not dispatch destToken when native token same as source', () => { + // Source token is the native token + const nativeSourceToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + chainId: mockChainId, + }; + + // Default and native both match source + (getDefaultDestToken as jest.Mock).mockReturnValue(nativeSourceToken); + (getNativeSourceToken as jest.Mock).mockReturnValue(nativeSourceToken); + + const { result } = renderHookWithProvider( + () => + useSwapBridgeNavigation({ + location: mockLocation, + sourcePage: mockSourcePage, + sourceToken: nativeSourceToken, + }), + { state: initialState }, + ); + + result.current.goToSwaps(); + + // setDestToken should not be called since all options match source + expect(mockSetDestToken).not.toHaveBeenCalled(); + }); + }); + describe('Solana', () => { it('keeps Solana chain ID in CAIP format for Bridge', () => { // Mock home page filter network as Solana @@ -609,6 +871,33 @@ describe('useSwapBridgeNavigation', () => { }, }); }); + + it('dispatches destToken with CAIP chain ID format', () => { + const solanaDestToken: BridgeToken = { + symbol: 'SOL', + name: 'Solana', + address: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + decimals: 9, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44/501.png', + chainId: SolScope.Mainnet, + }; + + const { result } = renderHookWithProvider( + () => + useSwapBridgeNavigation({ + location: mockLocation, + sourcePage: mockSourcePage, + sourceToken: mockSourceToken, + destToken: solanaDestToken, + }), + { state: initialState }, + ); + + result.current.goToSwaps(); + + expect(mockSetDestToken).toHaveBeenCalledWith(solanaDestToken); + }); }); describe('Analytics Tracking', () => { From d9451b21b5b96e40e875326c7e16b32093d21f8f Mon Sep 17 00:00:00 2001 From: hunty Date: Tue, 16 Dec 2025 11:01:15 -0600 Subject: [PATCH 10/12] fix: rounds down the min received amount in swaps (#23851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The "Minimum Received" amount in the bridge feature was rounding up when displaying values with decimal precision, which could show users a minimum amount higher than they might actually receive (e.g., 0.01257 displayed as 0.013). This change ensures the minimum received amount always rounds down by flooring the value before formatting, so users never see a minimum that exceeds what they'll actually receive. ## **Changelog** CHANGELOG entry: improved the minimum received bridge label by rounding down ## **Related issues** Fixes: [add your ticket link here] ## **Manual testing steps** Feature: Bridge Minimum Received Display Scenario: user views minimum received amount in bridge quote Given user is on the bridge screen with a valid quote And the estimated destination amount has decimal precision (e.g., 0.01257) When user views the quote details Then the "Minimum received" amount should be rounded down And the displayed minimum should never exceed the estimated amount ## **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. ## **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] > Adds a formatter that floors minimum received to 8 decimals and uses it in QuoteDetailsCard, with unit tests. > > - **Bridge Utils**: > - Add `formatMinimumReceived` in `utils/currencyUtils` to floor values to 8 decimals and format via locale. > - Add tests in `utils/currencyUtils.test.ts` covering flooring behavior, string parsing, invalid input, and locale usage. > - **UI**: > - Update `components/QuoteDetailsCard/QuoteDetailsCard.tsx` to use `formatMinimumReceived` for displaying `minimum_received`, replacing direct intl formatting. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f7abb276748a8dba075b2e06ed395c1a58d51e62. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../QuoteDetailsCard/QuoteDetailsCard.tsx | 13 ++--- .../UI/Bridge/utils/currencyUtils.test.ts | 56 ++++++++++++++++++- .../UI/Bridge/utils/currencyUtils.ts | 16 ++++++ 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx index 5487a31a728..6c859302ec9 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import { TouchableOpacity, Platform, UIManager } from 'react-native'; import { useNavigation } from '@react-navigation/native'; -import I18n, { strings } from '../../../../../../locales/i18n'; +import { strings } from '../../../../../../locales/i18n'; import Text, { TextColor, TextVariant, @@ -30,7 +30,7 @@ import { selectSourceToken, } from '../../../../../core/redux/slices/bridge'; import { getNativeSourceToken } from '../../utils/tokenUtils'; -import { getIntlNumberFormatter } from '../../../../../util/intl'; +import { formatMinimumReceived } from '../../utils/currencyUtils'; import { useRewards } from '../../hooks/useRewards'; import RewardsAnimations, { RewardAnimationState, @@ -55,11 +55,6 @@ const QuoteDetailsCard: React.FC = () => { const navigation = useNavigation(); const styles = createStyles(theme); - const locale = I18n.locale; - const intlNumberFormatter = getIntlNumberFormatter(locale, { - maximumSignificantDigits: 8, - }); - const { formattedQuoteData, activeQuote, @@ -142,8 +137,8 @@ const QuoteDetailsCard: React.FC = () => { const gasIncluded7702 = !!activeQuote?.quote.gasIncluded7702; const isGasless = gasIncluded7702 || gasIncluded; - const formattedMinToTokenAmount = intlNumberFormatter.format( - parseFloat(activeQuote?.minToTokenAmount?.amount || '0'), + const formattedMinToTokenAmount = formatMinimumReceived( + activeQuote?.minToTokenAmount?.amount || '0', ); return ( diff --git a/app/components/UI/Bridge/utils/currencyUtils.test.ts b/app/components/UI/Bridge/utils/currencyUtils.test.ts index f21abcde7d3..3bbbc3b9592 100644 --- a/app/components/UI/Bridge/utils/currencyUtils.test.ts +++ b/app/components/UI/Bridge/utils/currencyUtils.test.ts @@ -1,6 +1,6 @@ import I18n from '../../../../../locales/i18n'; import { getIntlNumberFormatter } from '../../../../util/intl'; -import { formatCurrency } from './currencyUtils'; +import { formatCurrency, formatMinimumReceived } from './currencyUtils'; jest.mock('../../../../../locales/i18n', () => ({ locale: 'en-US', @@ -400,3 +400,57 @@ describe('formatCurrency', () => { }); }); }); + +describe('formatMinimumReceived', () => { + const mockFormat = jest.fn(); + const mockGetIntlNumberFormatter = + getIntlNumberFormatter as jest.MockedFunction< + typeof getIntlNumberFormatter + >; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetIntlNumberFormatter.mockReturnValue({ + format: mockFormat, + } as unknown as Intl.NumberFormat); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('floors values down instead of rounding up', () => { + mockFormat.mockImplementation((val) => String(val)); + + formatMinimumReceived(0.012579999); + + expect(mockFormat).toHaveBeenCalledWith(0.01257999); + }); + + it('parses string amounts', () => { + mockFormat.mockImplementation((val) => String(val)); + + formatMinimumReceived('1.2345'); + + expect(mockFormat).toHaveBeenCalledWith(1.2345); + }); + + it('returns "0" for invalid input', () => { + const result = formatMinimumReceived('not-a-number'); + + expect(result).toBe('0'); + }); + + it('uses locale from I18n', () => { + mockFormat.mockImplementation((val) => String(val)); + (I18n as { locale: string }).locale = 'de-DE'; + + formatMinimumReceived(1.234); + + expect(mockGetIntlNumberFormatter).toHaveBeenCalledWith('de-DE', { + maximumSignificantDigits: 8, + }); + + (I18n as { locale: string }).locale = 'en-US'; + }); +}); diff --git a/app/components/UI/Bridge/utils/currencyUtils.ts b/app/components/UI/Bridge/utils/currencyUtils.ts index 647a5bc53a9..1612d7c075b 100644 --- a/app/components/UI/Bridge/utils/currencyUtils.ts +++ b/app/components/UI/Bridge/utils/currencyUtils.ts @@ -1,6 +1,22 @@ import I18n from '../../../../../locales/i18n'; import { getIntlNumberFormatter } from '../../../../util/intl'; +/** + * Formats a minimum received amount, always rounding down to ensure users + * never see a minimum higher than they might actually receive. + */ +export function formatMinimumReceived(amount: number | string): string { + const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount; + if (isNaN(numAmount)) return '0'; + + // Floor to 8 decimal places before formatting + const flooredAmount = Math.floor(numAmount * 1e8) / 1e8; + + return getIntlNumberFormatter(I18n.locale, { + maximumSignificantDigits: 8, + }).format(flooredAmount); +} + /** * Formats currency amounts using the device's locale * @param amount - The amount to format (number or string) From 740da4cdfaf56054b5e06527b708093dbc0bc69d Mon Sep 17 00:00:00 2001 From: Amanda Yeoh <147617420+amandaye0h@users.noreply.github.com> Date: Wed, 17 Dec 2025 01:11:40 +0800 Subject: [PATCH 11/12] chore: Refine Custom Network button and avatar styles (#23581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## **Changelog** CHANGELOG entry: chore: Refine Custom Network button and avatar styles ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/jira/software/c/projects/MDP/boards/2972?search=custom&selectedIssue=MDP-242 ## **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** Screenshot 2025-12-03 at 1 02 59 PM ## **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. ## **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] > Centers cell row content, updates Custom Network selector styling (button design, themed styles), and increases network avatar size; adjusts tests and snapshots accordingly. > > - **UI**: > - **Cells**: Center-align row content by adding `alignItems: 'center'` in `components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts`. > - **Custom Network Selector**: > - Refactor `CustomNetworkSelector.styles.ts` to accept themed params and update styles (rounded/padded `addNetworkButtonContainer`, new `iconContainer` with muted background, 32x32, centered). > - Update footer button: wrap `Add` icon in `iconContainer`, switch icon/text to primary color, text variant to `BodyMDMedium`. > - Increase avatar size in list items to `AvatarSize.Md`. > - **Tests/Snapshots**: > - Update `useStyles` call expectation to use `createStyles` with `{}`. > - Refresh snapshots to reflect centered rows and updated layouts. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 605ea1404eafcc402391f727c6483c2e11e7476f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Prithpal Sooriya --- .../CellSelectWithMenu.styles.ts | 1 + .../CellSelectWithMenu.test.tsx.snap | 1 + .../Cell/__snapshots__/Cell.test.tsx.snap | 2 ++ .../CustomNetworkSelector.styles.ts | 25 +++++++++++++++---- .../CustomNetworkSelector.test.tsx | 9 ++----- .../CustomNetworkSelector.tsx | 19 +++++++------- 6 files changed, 36 insertions(+), 21 deletions(-) diff --git a/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts b/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts index 3e62a8a257d..860adbd5f24 100644 --- a/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts +++ b/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts @@ -33,6 +33,7 @@ const styleSheet = (params: { cellBase: Object.assign( { flexDirection: 'row', + alignItems: 'center', } as ViewStyle, style, ) as ViewStyle, diff --git a/app/component-library/components-temp/CellSelectWithMenu/__snapshots__/CellSelectWithMenu.test.tsx.snap b/app/component-library/components-temp/CellSelectWithMenu/__snapshots__/CellSelectWithMenu.test.tsx.snap index d42d98111a2..91b81cf6876 100644 --- a/app/component-library/components-temp/CellSelectWithMenu/__snapshots__/CellSelectWithMenu.test.tsx.snap +++ b/app/component-library/components-temp/CellSelectWithMenu/__snapshots__/CellSelectWithMenu.test.tsx.snap @@ -50,6 +50,7 @@ exports[`CellSelectWithMenu should render with default settings correctly 1`] = - StyleSheet.create({ +const createStyles = (params: { + theme: Theme; + vars: Record; +}) => { + const { + theme: { colors }, + } = params; + return StyleSheet.create({ // custom network styles container: { flex: 1, @@ -11,14 +18,22 @@ const createStyles = () => paddingBottom: 100, }, addNetworkButtonContainer: { - paddingHorizontal: 16, - paddingVertical: 12, + borderRadius: 8, + padding: 16, flexDirection: 'row', alignItems: 'center', }, iconContainer: { - marginRight: 14, + backgroundColor: colors.background.muted, + borderRadius: 8, + padding: 8, + marginRight: 16, + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', }, }); +}; export default createStyles; diff --git a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.test.tsx b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.test.tsx index 2855abece5d..ab6dc68309a 100644 --- a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.test.tsx +++ b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.test.tsx @@ -360,7 +360,7 @@ describe('CustomNetworkSelector', () => { expect(mockUseSafeAreaInsets).toHaveBeenCalled(); }); - it('calls useStyles with theme colors', () => { + it('calls useStyles with createStyles', () => { renderWithProvider( { />, ); - expect(mockUseStyles).toHaveBeenCalledWith(expect.any(Function), { - colors: expect.objectContaining({ - icon: expect.any(Object), - text: expect.any(Object), - }), - }); + expect(mockUseStyles).toHaveBeenCalledWith(expect.any(Function), {}); }); }); diff --git a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.tsx b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.tsx index 2abbccd3767..a496e0351ab 100644 --- a/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.tsx +++ b/app/components/UI/CustomNetworkSelector/CustomNetworkSelector.tsx @@ -53,7 +53,7 @@ const CustomNetworkSelector = ({ openRpcModal, }: CustomNetworkSelectorProps) => { const { colors } = useTheme(); - const { styles } = useStyles(createStyles, { colors }); + const { styles } = useStyles(createStyles, {}); const { navigate } = useNavigation(); const safeAreaInsets = useSafeAreaInsets(); @@ -126,7 +126,7 @@ const CustomNetworkSelector = ({ variant: AvatarVariant.Network, name, imageSource: item.imageSource as ImageSourcePropType, - size: AvatarSize.Sm, + size: AvatarSize.Md, }} buttonIcon={IconName.MoreVertical} buttonProps={{ @@ -149,14 +149,15 @@ const CustomNetworkSelector = ({ style={styles.addNetworkButtonContainer} onPress={goToNetworkSettings} > - + + + - + {strings('app_settings.network_add_custom_network')} From eb593b819795f66eda38b279b432263eb805b6ab Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Tue, 16 Dec 2025 18:13:30 +0100 Subject: [PATCH 12/12] fix: stop re-rendering wallet whenever we navigate from trending to home screen (#24062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** After profiling the app I encountered quite a big performance and UX issue when switching between home screen and trending screen: - As you can see whenever we navigate to any screen and go back to the main screen, the state and UI is kept and not re-rendered - But when we navigate to trending and going back to the main screen the whole UI is re-rendered ## **Changelog** CHANGELOG entry: avoid re-rendering home when navigating back from trending ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-2153 ## **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** https://github.com/user-attachments/assets/e2eac97d-da55-454d-a283-bf445a837cd1 ### **After** https://github.com/user-attachments/assets/5175b72c-6c1e-4da1-be96-634443a88bd9 ## **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. ## **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] > Change trending tab to call `navigation.navigate` to `Routes.TRENDING_VIEW` (when enabled) instead of `navigation.reset`, with tests updated accordingly. > > - **Navigation** > - `TabBar`: For `Routes.TRENDING_VIEW`, replace `navigation.reset(...)` with `navigation.navigate(Routes.TRENDING_VIEW)` when the `assetsTrendingTokens` feature flag is enabled. > - **Tests** > - Update `app/component-library/components/Navigation/TabBar/TabBar.test.tsx` to expect `navigation.navigate(Routes.TRENDING_VIEW)` and not `reset`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5140c8c2c43638d2433c2f5b6f452dc8926bf02f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../components/Navigation/TabBar/TabBar.test.tsx | 5 +---- .../components/Navigation/TabBar/TabBar.tsx | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/app/component-library/components/Navigation/TabBar/TabBar.test.tsx b/app/component-library/components/Navigation/TabBar/TabBar.test.tsx index ef2ef0e021e..2680330faab 100644 --- a/app/component-library/components/Navigation/TabBar/TabBar.test.tsx +++ b/app/component-library/components/Navigation/TabBar/TabBar.test.tsx @@ -222,10 +222,7 @@ describe('TabBar', () => { ); fireEvent.press(getByTestId(`tab-bar-item-${TabBarIconKey.Trending}`)); - expect(navigation.reset).toHaveBeenCalledWith({ - index: 0, - routes: [{ name: Routes.TRENDING_VIEW }], - }); + expect(navigation.navigate).toHaveBeenCalledWith(Routes.TRENDING_VIEW); }); it('does not navigate to trending when trending feature flag is disabled', () => { diff --git a/app/component-library/components/Navigation/TabBar/TabBar.tsx b/app/component-library/components/Navigation/TabBar/TabBar.tsx index 5f4e089180f..e4682f3a54b 100644 --- a/app/component-library/components/Navigation/TabBar/TabBar.tsx +++ b/app/component-library/components/Navigation/TabBar/TabBar.tsx @@ -95,10 +95,7 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => { break; case Routes.TRENDING_VIEW: if (isAssetsTrendingTokensEnabled) { - navigation.reset({ - index: 0, - routes: [{ name: Routes.TRENDING_VIEW }], - }); + navigation.navigate(Routes.TRENDING_VIEW); } break; }