From 007a9ceaa715b8beb7420b38fc01734d431a73bc Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:41:50 +0800 Subject: [PATCH 1/4] fix(perps): invalid decimals in pnl card (#22174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixed incorrect decimal precision for Entry price display on the PnL card and transaction details. **Problem:** Entry prices were using `PRICE_RANGES_MINIMAL_VIEW` (2 decimal places, fiat-style) instead of `PRICE_RANGES_UNIVERSAL` (5 significant digits, up to 6 decimals) that Mark prices use. This caused: - PUMP: Displayed "<$0.01" instead of actual value - ETH: Displayed "xxxx.60" instead of "xxxx.6" (trailing zero) - XRP: Displayed "$2.26" instead of "$2.2626" (insufficient precision) **Solution:** Changed Entry price formatting to use `PRICE_RANGES_UNIVERSAL` to match Mark price decimal logic, ensuring consistent precision across all price displays. ## **Changelog** CHANGELOG entry: Fixed Entry price decimal precision on PnL card to match Mark price formatting ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-1976 ## **Manual testing steps** ```gherkin Feature: Entry price decimal consistency Scenario: user views PnL card for low-value asset (PUMP) Given user has an open position in PUMP When user navigates to the PnL hero card (Share your trade view) Then Entry price should display actual value with appropriate decimals And Entry price should match Mark price decimal formatting Scenario: user views PnL card for ETH Given user has an open position in ETH When user navigates to the PnL hero card Then Entry price should display "xxxx.6" without trailing zeros And Entry price should match Mark price decimal formatting Scenario: user views PnL card for XRP Given user has an open position in XRP When user navigates to the PnL hero card Then Entry price should display "$2.2626" with full precision And Entry price should match Mark price decimal formatting Scenario: user views transaction history Given user has closed positions When user views position transaction details Then Entry/Close price should display with universal decimal precision ``` ## **Screenshots/Recordings** ### **Before** PUMP: Entry shows "<$0.01" ETH: Entry shows "xxxx.60" (trailing zero) XRP: Entry shows "$2.26" (insufficient precision) ### **After** Entry prices now match Mark price formatting: - PUMP: Shows actual value (e.g., "$0.003889") - ETH: Shows "xxxx.6" (no trailing zero) - XRP: Shows "$2.2626" (full precision) https://github.com/user-attachments/assets/22528486-a079-41a3-abfa-1f344ef5dbb6 ## **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 (existing tests pass) - [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. --- ## **Technical Details** ### Files Modified: 1. `app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx:235` - Changed Entry price from `PRICE_RANGES_MINIMAL_VIEW` � `PRICE_RANGES_UNIVERSAL` 2. `app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx:107-109` - Added `PRICE_RANGES_UNIVERSAL` config to Entry/Close price formatting ### Verification: -  ESLint: No errors -  Prettier: Formatting correct -  Unit tests: All tests pass (21 tests for PerpsHeroCardView, 2 tests for PerpsPositionTransactionView) ### Reference Implementation: - `PerpsPositionCard.tsx` already uses `PRICE_RANGES_UNIVERSAL` (lines 414-417) - `PerpsTPSLView.tsx` already uses `PRICE_RANGES_UNIVERSAL` (lines 445-447) - Consistent with `fix/perps/decimal-consistency` branch work --- > [!NOTE] > Switch Entry/Close price formatting to PRICE_RANGES_UNIVERSAL in PnL hero card and transaction details for consistent precision. > > - **Perps UI**: > - **PnL Hero Card (`PerpsHeroCardView.tsx`)**: Format `entryPrice` with `formatPerpsFiat(..., { ranges: `PRICE_RANGES_UNIVERSAL` })`. > - **Transaction Details (`PerpsPositionTransactionView.tsx`)**: Format Entry/Close price with `PRICE_RANGES_UNIVERSAL` for consistent decimal precision. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1a13254a364dab24096524eb4213383325d8affa. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx | 3 +-- .../PerpsTransactionsView/PerpsPositionTransactionView.tsx | 5 ++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx b/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx index f442eb268fd3..8f6c63ad4765 100644 --- a/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx +++ b/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx @@ -35,7 +35,6 @@ import RewardsReferralCodeTag from '../../../Rewards/components/RewardsReferralC import { formatPerpsFiat, parseCurrencyString, - PRICE_RANGES_MINIMAL_VIEW, PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; import MetaMaskLogo from '../../../../../images/branding/metamask-name.png'; @@ -232,7 +231,7 @@ const PerpsHeroCardView: React.FC = () => { variant={TextVariant.BodySMMedium} > {formatPerpsFiat(data.entryPrice, { - ranges: PRICE_RANGES_MINIMAL_VIEW, + ranges: PRICE_RANGES_UNIVERSAL, })} diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx index 83b03042643f..42f1dd396021 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx @@ -32,6 +32,7 @@ import { import { formatPerpsFiat, formatTransactionDate, + PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; import { styleSheet } from './PerpsPositionTransactionView.styles'; @@ -103,7 +104,9 @@ const PerpsPositionTransactionView: React.FC = () => { transaction.fill?.action === 'Closed' ? strings('perps.transactions.position.close_price') : strings('perps.transactions.position.entry_price'), - value: formatPerpsFiat(transaction.fill.entryPrice), + value: formatPerpsFiat(transaction.fill.entryPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + }), }, ].filter(Boolean); From 13f461cebb41ca7c97096cde44b07a46499e22c7 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:31:37 +0800 Subject: [PATCH 2/4] fix(perps): missing referral code inpnl card tat-1981 (#22175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes the missing MetaMask referral code display in the Perps PnL Hero Card (shareable card view). ### What is the reason for the change? Users who are opted into MetaMask Rewards and have a referral code were not seeing it displayed on the shareable PnL Hero Card when accessing Perps directly, even though the UI design and display logic were already implemented. The referral code would only appear if users had previously visited the Rewards section of the app. Issue: https://consensyssoftware.atlassian.net/browse/TAT-1981 ### What is the improvement/solution? The root cause was that the `useReferralDetails()` hook (which fetches the user's referral code from the backend) was only being called in Rewards components, not in the Perps Hero Card view. This meant: - ✅ User visits Rewards first → code loads → shows on Hero Card - ❌ User goes directly to Perps → code never fetched → missing from Hero Card **Fix implemented:** - Added `useReferralDetails()` hook to `PerpsHeroCardView.tsx` - The hook automatically fetches referral details when the Hero Card opens - Code gets stored in Redux and picked up by the existing UI (which was already implemented at lines 262-281) - No UI changes needed - the design with `RewardsReferralCodeTag` component was already there **Technical details:** - Only 2 lines added (import + hook call) - Uses existing `useReferralDetails` hook from Rewards - Leverages existing `selectReferralCode` Redux selector - Respects user opt-in status (only shows for opted-in users) ## **Changelog** CHANGELOG entry: Fixed missing MetaMask referral code display in Perps PnL shareable card for opted-in users ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-1981 ## **Manual testing steps** ```gherkin Feature: Display referral code on Perps PnL Hero Card Scenario: User with referral code accesses PnL Hero Card directly Given user is opted into MetaMask Rewards And user has a referral code assigned (e.g., "MMCSI") And user has NOT visited the Rewards section yet in this session When user navigates to Perps tab And user opens an existing position And user taps the "Share" button to open PnL Hero Card Then the referral code should appear in the top-right corner of the shareable card And the referral code should display using the RewardsReferralCodeTag component And the referral code should be included when sharing the card Scenario: User without opt-in does not see referral code Given user has NOT opted into MetaMask Rewards And user does NOT have a referral code When user navigates to Perps tab And user opens an existing position And user taps the "Share" button to open PnL Hero Card Then no referral code should be displayed And the card should render normally without the referral section Scenario: Referral code persists across multiple card views Given user is opted into MetaMask Rewards And user has a referral code assigned When user opens PnL Hero Card for position A Then referral code should be displayed When user goes back and opens PnL Hero Card for position B Then referral code should still be displayed without refetching ``` ## **Screenshots/Recordings** ### **Before** Referral code missing from PnL Hero Card when accessing Perps directly (only appeared if user visited Rewards section first). ### **After** Referral code now displays correctly in top-right corner of PnL Hero Card for all opted-in users, regardless of navigation path. The design matches the provided mockup with referral code displayed as a tag (e.g., "MMCSI" or "BUDDHA" in the example). https://github.com/user-attachments/assets/2bd2b1fe-faa0-4051-b1b0-41064455a7e3 ## **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. --- ## Technical Implementation Details ### Files Modified **Single file changed:** - `app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx` - Added import: `useReferralDetails` hook from Rewards - Added hook call: `useReferralDetails()` to fetch referral code on component mount ### Code Changes **Lines added: 2** ```typescript // Import added at line 63 import { useReferralDetails } from '../../../Rewards/hooks/useReferralDetails'; // Hook call added at lines 91-92 // Fetch referral details to ensure code is available for display useReferralDetails(); ``` ### Why This Works 1. **Existing Infrastructure**: The UI for displaying referral codes was already implemented at lines 262-281 of the Hero Card 2. **Conditional Rendering**: Code already checks `rewardsReferralCode` and only displays if present 3. **Redux Integration**: `useReferralDetails()` fetches data and stores it via `setReferralDetails` action 4. **Automatic Updates**: The `useSelector(selectReferralCode)` hook automatically re-renders when data arrives 5. **Opt-in Respect**: Only displays for users who have opted into Rewards and have a referral code ### Data Flow ``` PerpsHeroCardView mounts ↓ useReferralDetails() called ↓ Fetches from RewardsController ↓ dispatch(setReferralDetails({ referralCode: "MMCSI" })) ↓ Redux state updated ↓ useSelector(selectReferralCode) re-renders ↓ RewardsReferralCodeTag component renders with code ``` ### Performance Impact - **Minimal**: Single API call on Hero Card mount (only if not already cached) - **Cached**: Data persists in Redux for the session - **No extra renders**: Component already had the selector, just needed the data ### Testing Verification ✅ ESLint passes - no linting errors ✅ TypeScript compilation - types are correct ✅ No breaking changes - purely additive ✅ Backward compatible - gracefully handles missing referral codes --- > [!NOTE] > Invoke `useReferralDetails()` in `PerpsHeroCardView` so referral code loads and displays (and is used in share message); update tests to mock the hook/design tokens. > > - **Perps PnL Hero Card (`PerpsHeroCardView.tsx`)**: > - Fetch referral details on mount by calling `useReferralDetails()` and importing the hook. > - Leverages existing `selectReferralCode` usage to display the referral tag and include code in share message. > - **Tests (`PerpsHeroCardView.test.tsx`)**: > - Mock `useReferralDetails` and add `brandColor` to design tokens mock. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5f70c4002bcc09d46a208bc1d3fcd5dc6532d7da. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/PerpsHeroCardView/PerpsHeroCardView.test.tsx | 7 +++++++ .../UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.test.tsx b/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.test.tsx index a5ff8d3193d2..315fad9e843f 100644 --- a/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.test.tsx @@ -88,7 +88,14 @@ jest.mock('../../../Rewards/utils', () => ({ (code) => `https://link.metamask.io/rewards?referral=${code}`, ), })); +jest.mock('../../../Rewards/hooks/useReferralDetails', () => ({ + useReferralDetails: jest.fn(), +})); jest.mock('@metamask/design-tokens', () => ({ + brandColor: { + black: '#000000', + white: '#FFFFFF', + }, darkTheme: { colors: { background: { diff --git a/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx b/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx index 8f6c63ad4765..7dde0afb2d9b 100644 --- a/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx +++ b/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx @@ -59,6 +59,7 @@ import { PerpsHeroCardViewSelectorsIDs, getPerpsHeroCardViewSelector, } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; +import { useReferralDetails } from '../../../Rewards/hooks/useReferralDetails'; // To add a new card, add the image to the array. const CARD_IMAGES: { image: ImageSourcePropType; id: number; name: string }[] = @@ -86,6 +87,9 @@ const PerpsHeroCardView: React.FC = () => { const rewardsReferralCode = useSelector(selectReferralCode); + // Fetch referral details to ensure code is available for display + useReferralDetails(); + const { track } = usePerpsEventTracking(); const { showToast, PerpsToastOptions } = usePerpsToasts(); From 34112b97b07c68e885d7a917d3c768f5274a6229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Manuel?= <6741785+jluque0101@users.noreply.github.com> Date: Wed, 5 Nov 2025 11:03:59 +0100 Subject: [PATCH 3/4] chore(INFRA-3081): add branch name validation step for changelog refresh (#21853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Improve changelog management ## **Changelog** CHANGELOG entry: null ## **Related issues** INFRA-3031 Fixes: - Run `Update Release Changelog PR` only when release branch matches `release/x.y.z` ## **Manual testing steps** - Test release branch name `release/x.y.z` -> https://github.com/consensys-test/metamask-mobile-test-workflow/actions/runs/18942043247/job/54083117562 - Test release branch name `release/x.y.z-Changelog` -> https://github.com/consensys-test/metamask-mobile-test-workflow/actions/runs/18942263401/job/54083874574 - Test invalid branch name `release/test-branch` -> https://github.com/consensys-test/metamask-mobile-test-workflow/actions/runs/18942308096/job/54084029210 - Test invalid branch, incomplete semver `release/x.y` -> https://github.com/consensys-test/metamask-mobile-test-workflow/actions/runs/18942339330/job/54084135741 ## **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. --- .../workflows/update-release-changelog.yml | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/update-release-changelog.yml b/.github/workflows/update-release-changelog.yml index b5bfa166975b..3a68e31a1ed1 100644 --- a/.github/workflows/update-release-changelog.yml +++ b/.github/workflows/update-release-changelog.yml @@ -1,5 +1,7 @@ -# On every push to a release branch (Version-v* or release/*), this workflow rebuilds the matching +# On every push to a release branch (release/x.y.z format), this workflow rebuilds the matching # changelog branch, re-runs the auto-changelog script, and either updates or recreates the changelog PR. +# Note: This workflow validates the branch name to ensure it matches the semantic versioning pattern +# (release/x.y.z) and skips execution for other branch names like release/x.y.z-Changelog. name: Update Release Changelog PR on: @@ -12,7 +14,34 @@ concurrency: cancel-in-progress: false jobs: + validate-branch: + name: Validate release branch format + runs-on: ubuntu-latest + outputs: + is-valid-release: ${{ steps.check.outputs.is-valid }} + version: ${{ steps.check.outputs.version }} + steps: + - name: Check if branch matches release/x.y.z pattern + id: check + run: | + BRANCH_NAME="${{ github.ref_name }}" + echo "Checking branch: $BRANCH_NAME" + + # Validate branch matches release/x.y.z format (semantic versioning) + if [[ "$BRANCH_NAME" =~ ^release/[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + VERSION="${BRANCH_NAME#release/}" + echo "Valid release branch detected: $BRANCH_NAME (version: $VERSION)" + echo "is-valid=true" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + else + echo "Branch '$BRANCH_NAME' does not match release/x.y.z pattern. Skipping changelog update." + echo "is-valid=false" >> "$GITHUB_OUTPUT" + fi + refresh-changelog: + name: Update changelog + needs: validate-branch + if: needs.validate-branch.outputs.is-valid-release == 'true' permissions: contents: write pull-requests: write From 0771424180b84b70bdd7034132529fc4c6e208d2 Mon Sep 17 00:00:00 2001 From: Gaurav Goel Date: Wed, 5 Nov 2025 16:14:11 +0530 Subject: [PATCH 4/4] feat: updated import wallet ui for Add Wallet flow in home screen (#21686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** In this PR, I've aligned the Import Wallet UI in the 'Add Wallet' flow with the existing 'Import SRP' onboarding design for consistency Jira Link: https://consensyssoftware.atlassian.net/browse/SL-238, https://consensyssoftware.atlassian.net/browse/SL-274, https://consensyssoftware.atlassian.net/browse/SL-275 BugFixes: https://consensyssoftware.atlassian.net/browse/SL-267 https://github.com/MetaMask/metamask-mobile/issues/15928 https://github.com/MetaMask/metamask-mobile/issues/15929 Figma Link: https://www.figma.com/design/pViOUcmjwhEzFsdrwknpNc/Onboarding?node-id=18161-80663&t=1c5E4QKtEnnYjijl-0 ## **Changelog** CHANGELOG entry: Align Import Wallet UI in Add Wallet flow with Import SRP onboarding design for consistency. ## **Related issues** Fixes: 1) https://consensyssoftware.atlassian.net/browse/SL-267 2) https://github.com/MetaMask/metamask-mobile/issues/15928 3) https://github.com/MetaMask/metamask-mobile/issues/15929 ## **Manual testing steps** ```gherkin Feature: my feature name 1) Open the App. 2) Create a new wallet or import an existing one 3)On the home page, click "Add Wallet" from the account dropdown 4) Verify that the Import Wallet UI matches the design in the provided Figma link ``` ## **Screenshots/Recordings** ### **Before** https://www.figma.com/design/pViOUcmjwhEzFsdrwknpNc/Onboarding?node-id=18161-80216&t=1c5E4QKtEnnYjijl-0 ### **After** https://github.com/user-attachments/assets/76e3db92-27c3-4b3a-9623-263347712a33 https://github.com/user-attachments/assets/da90b1a1-e077-47bc-ba3a-5fc032b4b6a2 ## **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] > Introduces a reusable `SrpInputGrid` with paste/clear, validation, and QR support, and refactors SRP import screens and navigation to use it with updated UI, tests, and locales. > > - **UI/Flows**: > - Refactor `ImportFromSecretRecoveryPhrase` and `ImportNewSecretRecoveryPhrase` to use new grid-based SRP input, with updated layout, navbar, and SafeArea handling. > - Disable/enable continue/import buttons based on SRP validity; show inline errors; add paste/clear affordances. > - **Components**: > - Add reusable `app/components/UI/SrpInputGrid` (styles, types, logic) supporting textarea→grid switch, keyboard navigation, word validation, and QR-filled input. > - Update `SrpInput` input style typings to `TextStyle`. > - **Navigation**: > - Update `ImportSRPView` stack to `mode="modal"`; add routes for `QRTabSwitcher` and `SeedphraseModal` with transparent card style. > - **Validation/Utils**: > - Add `app/util/srp/srpInputUtils` (lengths, space handling, word validation, helpers). > - **Tests**: > - Add unit snapshots for `SrpInputGrid`; expand tests for both import screens (paste/clear, QR, errors, keyboard/backspace, focus); update E2E selectors and page objects. > - **Localization**: > - Add/adjust strings for new import wallet copy, placeholders, and QR error messages. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6010425f1db48362e87029807336db497fdfe129. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: ieow <4881057+ieow@users.noreply.github.com> --- app/components/Nav/App/App.tsx | 9 + .../UI/SrpInputGrid/SrpInputGrid.styles.ts | 107 ++ .../UI/SrpInputGrid/SrpInputGrid.types.ts | 46 + .../__snapshots__/index.test.tsx.snap | 1295 +++++++++++++++++ app/components/UI/SrpInputGrid/index.test.tsx | 59 + app/components/UI/SrpInputGrid/index.tsx | 463 ++++++ .../__snapshots__/index.test.tsx.snap | 101 +- .../ImportFromSecretRecoveryPhrase/index.js | 390 +---- .../index.test.tsx | 28 +- .../index.test.tsx | 1128 +++++++++++--- .../ImportNewSecretRecoveryPhrase/index.tsx | 446 +++--- .../ImportNewSecretRecoveryPhrase/styles.ts | 85 +- .../Views/SrpInput/Input/indes.styles.ts | 6 +- app/components/Views/SrpInput/Input/index.tsx | 4 +- app/components/Views/SrpInput/index.tsx | 4 +- app/util/srp/srpInputUtils.ts | 45 + e2e/pages/importSrp/ImportSrpView.ts | 56 +- e2e/selectors/MultiSRP/SRPImport.selectors.js | 3 + e2e/specs/multisrp/utils.ts | 11 +- locales/languages/en.json | 5 + 20 files changed, 3361 insertions(+), 930 deletions(-) create mode 100644 app/components/UI/SrpInputGrid/SrpInputGrid.styles.ts create mode 100644 app/components/UI/SrpInputGrid/SrpInputGrid.types.ts create mode 100644 app/components/UI/SrpInputGrid/__snapshots__/index.test.tsx.snap create mode 100644 app/components/UI/SrpInputGrid/index.test.tsx create mode 100644 app/components/UI/SrpInputGrid/index.tsx create mode 100644 app/util/srp/srpInputUtils.ts diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index 7fc582cd9c74..3fc74038b913 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -597,6 +597,7 @@ const ImportPrivateKeyView = () => ( const ImportSRPView = () => ( ( name={Routes.MULTI_SRP.IMPORT} component={ImportNewSecretRecoveryPhrase} /> + + ); diff --git a/app/components/UI/SrpInputGrid/SrpInputGrid.styles.ts b/app/components/UI/SrpInputGrid/SrpInputGrid.styles.ts new file mode 100644 index 000000000000..85690f8ea063 --- /dev/null +++ b/app/components/UI/SrpInputGrid/SrpInputGrid.styles.ts @@ -0,0 +1,107 @@ +import { StyleSheet, Platform, TextStyle } from 'react-native'; +import { fontStyles, colors as commonColors } from '../../../styles/common'; +import { Colors } from '../../../util/theme/models'; + +/** + * Creates styles for the SrpInputGrid component + * @param colors - Theme colors object + * @returns StyleSheet object with all component styles + */ +export const createStyles = (colors: Colors) => + StyleSheet.create({ + seedPhraseRoot: { + flexDirection: 'column' as const, + gap: 4, + marginBottom: 24, + }, + seedPhraseContainer: { + paddingTop: 16, + backgroundColor: colors.background.section, + borderRadius: 10, + marginTop: 16, + minHeight: 264, + maxHeight: 'auto', + }, + seedPhraseInnerContainer: { + paddingHorizontal: Platform.select({ + ios: 16, + macos: 16, + default: 14, + }), + }, + seedPhraseInputContainer: { + flexDirection: 'row' as const, + flexWrap: 'wrap' as const, + width: '100%', + }, + seedPhraseDefaultInput: { + borderWidth: 0, + paddingHorizontal: 0, + display: 'flex' as const, + flex: 1, + backgroundColor: commonColors.transparent, + }, + seedPhraseInputItem: { + width: '31.33%', + marginRight: '3%', + marginBottom: 8, + flex: 0, + minWidth: 0, + }, + seedPhraseInputItemLast: { + marginRight: 0, + }, + textAreaInput: { + display: 'flex' as const, + height: 66, + color: colors.text.alternative, + backgroundColor: commonColors.transparent, + ...fontStyles.normal, + fontSize: 16, + lineHeight: 20, + paddingTop: Platform.OS === 'ios' ? 12 : 8, + paddingBottom: 12, + } satisfies TextStyle, + inputItem: { + flex: 1, + minWidth: 0, + maxWidth: '100%', + paddingRight: 8, + } satisfies TextStyle, + input: { + paddingVertical: Platform.select({ + ios: 4, + macos: 4, + default: 0, + }), + borderRadius: 8, + backgroundColor: colors.background.default, + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'flex-start' as const, + height: 40, + fontSize: 16, + color: colors.text.default, + ...fontStyles.normal, + textAlignVertical: 'center' as const, + paddingLeft: 8, + overflow: 'hidden' as const, + } satisfies TextStyle, + inputIndex: { + marginRight: -4, + }, + pasteText: { + textAlign: 'right' as const, + paddingTop: 12, + paddingBottom: 16, + alignSelf: 'flex-end' as const, + } satisfies TextStyle, + + hiddenInput: { + opacity: 0, + position: 'absolute' as const, + height: 0, + top: 0, + left: 0, + } satisfies TextStyle, + }); diff --git a/app/components/UI/SrpInputGrid/SrpInputGrid.types.ts b/app/components/UI/SrpInputGrid/SrpInputGrid.types.ts new file mode 100644 index 000000000000..ef68f100372a --- /dev/null +++ b/app/components/UI/SrpInputGrid/SrpInputGrid.types.ts @@ -0,0 +1,46 @@ +/** + * Props for the SrpInputGrid component + * This component provides a reusable Secret Recovery Phrase input grid + * that handles both single textarea and multi-input modes + */ +export interface SrpInputGridProps { + /** + * Array of seed phrase words + */ + seedPhrase: string[]; + + /** + * Callback when seed phrase array changes + */ + onSeedPhraseChange: React.Dispatch>; + + /** + * Callback when error state changes + */ + onError?: (error: string) => void; + + /** + * External error message to display from parent + */ + externalError?: string; + + /** + * Prefix for test IDs (e.g., 'seed-phrase-input' or 'import-from-seed-input') + */ + testIdPrefix: string; + + /** + * Placeholder text for the first input (textarea mode) + */ + placeholderText: string; + + /** + * Unique ID for key generation (optional, will generate if not provided) + */ + uniqueId?: string; + + /** + * Whether the inputs should be disabled + */ + disabled?: boolean; +} diff --git a/app/components/UI/SrpInputGrid/__snapshots__/index.test.tsx.snap b/app/components/UI/SrpInputGrid/__snapshots__/index.test.tsx.snap new file mode 100644 index 000000000000..d21fa24bec8f --- /dev/null +++ b/app/components/UI/SrpInputGrid/__snapshots__/index.test.tsx.snap @@ -0,0 +1,1295 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SrpInputGrid renders with custom uniqueId 1`] = ` + + + + + + + + + + + + + + + + + + + Paste + + +`; + +exports[`SrpInputGrid renders with disabled state 1`] = ` + + + + + + + + + + + + + + + + + + + Paste + + +`; + +exports[`SrpInputGrid renders with empty seed phrase 1`] = ` + + + + + + + + + + + + + + + + + + + Paste + + +`; + +exports[`SrpInputGrid renders with multiple words 1`] = ` + + + + + + + + 1 + . + + + + + + + + + + 2 + . + + + + + + + + + + 3 + . + + + + + + + + + + + + + + + + Clear all + + +`; diff --git a/app/components/UI/SrpInputGrid/index.test.tsx b/app/components/UI/SrpInputGrid/index.test.tsx new file mode 100644 index 000000000000..7efc2ef6b712 --- /dev/null +++ b/app/components/UI/SrpInputGrid/index.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { ImportSRPIDs } from '../../../../e2e/selectors/MultiSRP/SRPImport.selectors'; +import renderWithProvider from '../../../util/test/renderWithProvider'; +import SrpInputGrid from './index'; + +// Mock Keyboard +jest.mock('react-native/Libraries/Components/Keyboard/Keyboard', () => ({ + dismiss: jest.fn(), +})); + +describe('SrpInputGrid', () => { + const mockOnSeedPhraseChange = jest.fn(); + const mockOnError = jest.fn(); + + const defaultProps = { + seedPhrase: [''], + onSeedPhraseChange: mockOnSeedPhraseChange, + onError: mockOnError, + testIdPrefix: ImportSRPIDs.SEED_PHRASE_INPUT_ID, + placeholderText: 'Enter your Secret Recovery Phrase', + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('renders with empty seed phrase', () => { + const { toJSON } = renderWithProvider(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with multiple words', () => { + const seedPhrase = ['word1', 'word2', 'word3']; + const { toJSON } = renderWithProvider( + , + ); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with disabled state', () => { + const { toJSON } = renderWithProvider( + , + ); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with custom uniqueId', () => { + const { toJSON } = renderWithProvider( + , + ); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/UI/SrpInputGrid/index.tsx b/app/components/UI/SrpInputGrid/index.tsx new file mode 100644 index 000000000000..446ea443fbe8 --- /dev/null +++ b/app/components/UI/SrpInputGrid/index.tsx @@ -0,0 +1,463 @@ +import React, { + useCallback, + useMemo, + useRef, + useState, + useEffect, +} from 'react'; +import { View, Keyboard } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; +import { v4 as uuidv4 } from 'uuid'; +import Text, { + TextVariant, + TextColor, +} from '../../../component-library/components/Texts/Text'; +import SrpInput from '../../Views/SrpInput'; +import { TextFieldSize } from '../../../component-library/components/Form/TextField'; +import { useAppTheme } from '../../../util/theme'; +import { createStyles } from './SrpInputGrid.styles'; +import { SrpInputGridProps } from './SrpInputGrid.types'; +import { strings } from '../../../../locales/i18n'; +import { + getTrimmedSeedPhraseLength, + isFirstInput as isFirstInputUtil, + getInputValue, + SRP_LENGTHS, + SPACE_CHAR, + checkValidSeedWord, +} from '../../../util/srp/srpInputUtils'; +import { isValidMnemonic } from '../../../util/validators'; +import { formatSeedPhraseToSingleLine } from '../../../util/string'; +import Logger from '../../../util/Logger'; + +export interface SrpInputGridRef { + handleSeedPhraseChange: (seedPhraseText: string) => void; +} +/** + * SrpInputGrid Component + * + * A reusable component for Secret Recovery Phrase input that supports: + * - Single textarea mode for initial input + * - Dynamic grid mode after paste/input + * - Paste/Clear functionality + * - Error validation and display + * - Keyboard navigation + * + */ +const SrpInputGrid = React.forwardRef( + ( + { + seedPhrase, + onSeedPhraseChange, + onError, + externalError = '', + testIdPrefix, + placeholderText, + uniqueId = uuidv4(), + disabled = false, + }, + ref, + ) => { + const { colors } = useAppTheme(); + const styles = createStyles(colors); + + // Internal state + const [ + nextSeedPhraseInputFocusedIndex, + setNextSeedPhraseInputFocusedIndex, + ] = useState(null); + const [errorWordIndexes, setErrorWordIndexes] = useState< + Record + >({}); + + const seedPhraseInputRefs = useRef void; blur: () => void } + > | null>(null); + + // Calculate trimmed seed phrase length + const trimmedSeedPhraseLength = useMemo( + () => getTrimmedSeedPhraseLength(seedPhrase), + [seedPhrase], + ); + + // Determine if we're in single input (textarea) mode + const isFirstInput = useMemo( + () => isFirstInputUtil(seedPhrase), + [seedPhrase], + ); + + // Initialize seed phrase input refs + const getSeedPhraseInputRef = useCallback(() => { + if (!seedPhraseInputRefs.current) { + seedPhraseInputRefs.current = new Map(); + } + return seedPhraseInputRefs.current; + }, []); + + // Handle seed phrase change at a specific index (for grid mode) + const handleSeedPhraseChangeAtIndex = useCallback( + (seedPhraseText: string, index: number) => { + try { + const text = formatSeedPhraseToSingleLine(seedPhraseText); + + if (text.includes(SPACE_CHAR)) { + const isEndWithSpace = text.at(-1) === SPACE_CHAR; + const splitArray = text + .trim() + .split(SPACE_CHAR) + .filter((word) => word.trim() !== ''); + + if (splitArray.length === 0) { + onSeedPhraseChange((prev) => { + const newSeedPhrase = [...prev]; + newSeedPhrase[index] = ''; + return newSeedPhrase; + }); + return; + } + + const mergedSeedPhrase = [ + ...seedPhrase.slice(0, index), + ...splitArray, + ...seedPhrase.slice(index + 1), + ]; + + const normalizedWords = mergedSeedPhrase + .map((w) => w.trim()) + .filter((w) => w !== ''); + const maxAllowed = Math.max(...SRP_LENGTHS); + const hasReachedMax = normalizedWords.length >= maxAllowed; + const isCompleteAndValid = + SRP_LENGTHS.includes(normalizedWords.length) && + isValidMnemonic(normalizedWords.join(SPACE_CHAR)); + + let nextSeedPhraseState = normalizedWords; + if ( + isEndWithSpace && + index === seedPhrase.length - 1 && + !isCompleteAndValid && + !hasReachedMax + ) { + nextSeedPhraseState = [...normalizedWords, '']; + } + + onSeedPhraseChange(nextSeedPhraseState); + + if (isCompleteAndValid || hasReachedMax) { + Keyboard.dismiss(); + setNextSeedPhraseInputFocusedIndex(null); + return; + } + + const targetIndex = Math.min( + nextSeedPhraseState.length - 1, + index + splitArray.length, + ); + setNextSeedPhraseInputFocusedIndex(targetIndex); + return; + } + + if (seedPhrase[index] !== text) { + onSeedPhraseChange((prev) => { + const newSeedPhrase = [...prev]; + newSeedPhrase[index] = text; + return newSeedPhrase; + }); + + if (text.trim() === '') { + setErrorWordIndexes((prev) => ({ + ...prev, + [index]: false, + })); + } + } + } catch (err) { + Logger.error(err as Error, 'Error handling seed phrase change'); + } + }, + [seedPhrase, onSeedPhraseChange], + ); + + const handleSeedPhraseChangeAtIndexRef = useRef( + handleSeedPhraseChangeAtIndex, + ); + + useEffect(() => { + handleSeedPhraseChangeAtIndexRef.current = handleSeedPhraseChangeAtIndex; + }, [handleSeedPhraseChangeAtIndex]); + + // Helper to validate words + const validateWords = useCallback((words: string[]) => { + const errorsMap: Record = {}; + words.forEach((word, index) => { + const trimmedWord = word.trim(); + if (trimmedWord && !checkValidSeedWord(trimmedWord)) { + errorsMap[index] = true; + } + }); + return errorsMap; + }, []); + + // Handle seed phrase change in first input + const handleSeedPhraseChange = useCallback( + (seedPhraseText: string) => { + const text = formatSeedPhraseToSingleLine(seedPhraseText); + const trimmedText = text.trim(); + const updatedTrimmedText = trimmedText + .split(SPACE_CHAR) + .filter((word) => word !== ''); + + if (SRP_LENGTHS.includes(updatedTrimmedText.length)) { + onSeedPhraseChange(updatedTrimmedText); + + // Validate complete phrases that might have invalid words + setErrorWordIndexes(validateWords(updatedTrimmedText)); + setNextSeedPhraseInputFocusedIndex(null); + seedPhraseInputRefs.current?.get(0)?.blur(); + Keyboard.dismiss(); + } else { + handleSeedPhraseChangeAtIndexRef.current?.(text, 0); + } + }, + [onSeedPhraseChange, validateWords], + ); + + // Handle focus change with validation + const handleOnFocus = useCallback((index: number) => { + setNextSeedPhraseInputFocusedIndex(index); + }, []); + + const handleOnBlur = useCallback( + (index: number) => { + const currentWord = seedPhrase[index]; + const trimmedWord = currentWord ? currentWord.trim() : ''; + if (trimmedWord) { + const checkValid = checkValidSeedWord(trimmedWord); + setErrorWordIndexes((prev) => ({ + ...prev, + [index]: !checkValid, + })); + } + }, + [seedPhrase], + ); + + const handleKeyPress = useCallback( + (e: { nativeEvent: { key: string } }, index: number) => { + if (e.nativeEvent.key === 'Backspace') { + if (seedPhrase[index] === '') { + const newData = seedPhrase.filter((_, idx) => idx !== index); + if (index > 0) { + const prevInputRef = seedPhraseInputRefs.current?.get(index - 1); + if (prevInputRef) { + prevInputRef.focus(); + } + setNextSeedPhraseInputFocusedIndex(index - 1); + } + onSeedPhraseChange([...newData]); + } + } + }, + [seedPhrase, onSeedPhraseChange], + ); + + const handleEnterKeyPress = useCallback( + (index: number) => { + handleSeedPhraseChangeAtIndexRef.current( + `${seedPhrase[index]}${SPACE_CHAR}`, + index, + ); + }, + [seedPhrase], + ); + + // Validate seed phrase and show errors + const error = useMemo(() => { + const hasWordErrors = Object.values(errorWordIndexes).some(Boolean); + if (hasWordErrors) { + return strings('import_from_seed.spellcheck_error'); + } + return ''; + }, [errorWordIndexes]); + + useEffect(() => { + onError?.(error); + }, [error, onError]); + + const handlePaste = useCallback(async () => { + const text = await Clipboard.getString(); + if (text.trim() !== '') { + handleSeedPhraseChange(text); + } + }, [handleSeedPhraseChange]); + + const handleClear = useCallback(() => { + onSeedPhraseChange(['']); + setErrorWordIndexes({}); + setNextSeedPhraseInputFocusedIndex(null); + }, [onSeedPhraseChange]); + + useEffect(() => { + if (nextSeedPhraseInputFocusedIndex === null) return; + + const refElement = seedPhraseInputRefs.current?.get( + nextSeedPhraseInputFocusedIndex, + ); + + refElement?.focus(); + }, [nextSeedPhraseInputFocusedIndex]); + + React.useImperativeHandle(ref, () => ({ + handleSeedPhraseChange, + })); + + return ( + + + + + {/* Grid Inputs on multiple words mode. hidden when first input mode. + Need to use style hidden instead of condition rendering to avoid + keyboard flicker when change input */} + {seedPhrase.map((item, index) => ( + { + const inputRefs = getSeedPhraseInputRef(); + if (itemRef) { + inputRefs.set(index, itemRef); + } else { + inputRefs.delete(index); + } + }} + startAccessory={ + !isFirstInput && ( + + {index + 1}. + + ) + } + value={getInputValue(isFirstInput, index, item, seedPhrase)} + onFocus={() => { + handleOnFocus(index); + }} + onBlur={() => { + handleOnBlur(index); + }} + onChangeText={(text) => { + isFirstInput + ? handleSeedPhraseChange(text) + : handleSeedPhraseChangeAtIndex(text, index); + }} + onSubmitEditing={() => { + handleEnterKeyPress(index); + }} + placeholder="" + placeholderTextColor={colors.text.muted} + size={TextFieldSize.Md} + style={ + isFirstInput + ? styles.hiddenInput + : [ + styles.input, + styles.seedPhraseInputItem, + (index + 1) % 3 === 0 && + styles.seedPhraseInputItemLast, + ] + } + inputStyle={ + isFirstInput ? styles.textAreaInput : styles.inputItem + } + submitBehavior="submit" + autoComplete="off" + textAlignVertical={isFirstInput ? 'top' : 'center'} + showSoftInputOnFocus + isError={errorWordIndexes[index]} + autoCapitalize="none" + testID={`${testIdPrefix}_${index}`} + keyboardType="default" + autoCorrect={false} + textContentType="oneTimeCode" + spellCheck={false} + autoFocus={index === nextSeedPhraseInputFocusedIndex} + onKeyPress={(e) => handleKeyPress(e, index)} + isDisabled={disabled} + /> + ))} + + {/* Textarea Input on first input mode hidden during multiple works */} + { + handleOnFocus(0); + }} + onBlur={() => { + handleOnBlur(0); + }} + onChangeText={(text) => { + handleSeedPhraseChange(text); + }} + placeholder={placeholderText} + placeholderTextColor={colors.text.alternative} + size={TextFieldSize.Md} + style={ + isFirstInput + ? styles.seedPhraseDefaultInput + : styles.hiddenInput + } + inputStyle={styles.textAreaInput} + submitBehavior="submit" + autoComplete="off" + textAlignVertical="top" + showSoftInputOnFocus + autoCapitalize="none" + testID={testIdPrefix} + keyboardType="default" + autoCorrect={false} + textContentType="oneTimeCode" + spellCheck={false} + autoFocus={isFirstInput} + multiline + onKeyPress={(e) => handleKeyPress(e, 0)} + isDisabled={disabled} + /> + + + + + {/* Paste/Clear Button */} + { + if (trimmedSeedPhraseLength >= 1) { + handleClear(); + } else { + handlePaste(); + } + }} + > + {trimmedSeedPhraseLength >= 1 + ? strings('import_from_seed.clear_all') + : strings('import_from_seed.paste')} + + + {/* Error Text */} + {Boolean(externalError || error) && ( + + {externalError || error} + + )} + + ); + }, +); + +export default SrpInputGrid; diff --git a/app/components/Views/ImportFromSecretRecoveryPhrase/__snapshots__/index.test.tsx.snap b/app/components/Views/ImportFromSecretRecoveryPhrase/__snapshots__/index.test.tsx.snap index 2cc17ebb3b2b..b8c4a09f9307 100644 --- a/app/components/Views/ImportFromSecretRecoveryPhrase/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/ImportFromSecretRecoveryPhrase/__snapshots__/index.test.tsx.snap @@ -562,7 +562,101 @@ exports[`ImportFromSecretRecoveryPhrase Import a wallet UI render matches snapsh } } accessible={true} - focusable={true} + focusable={false} + onClick={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + { + "alignItems": "center", + "backgroundColor": "#ffffff", + "borderColor": "#b7bbc8", + "borderRadius": 8, + "borderWidth": 1, + "flexDirection": "row", + "height": 0, + "left": 0, + "opacity": 0, + "paddingHorizontal": 16, + "position": "absolute", + "top": 0, + } + } + testID="textfield" + > + + + + + wordlist.includes(text); +import SrpInputGrid from '../../UI/SrpInputGrid'; /** * View where users can set restore their account @@ -102,16 +99,8 @@ const ImportFromSecretRecoveryPhrase = ({ const { colors, themeAppearance } = useTheme(); const styles = createStyles(colors); - const seedPhraseInputRefs = useRef(null); const confirmPasswordInput = useRef(); - function getSeedPhraseInputRef() { - if (!seedPhraseInputRefs.current) { - seedPhraseInputRefs.current = new Map(); - } - return seedPhraseInputRefs.current; - } - const { toastRef } = useContext(ToastContext); const passwordSetupAttemptTraceCtxRef = useRef(null); @@ -122,14 +111,12 @@ const ImportFromSecretRecoveryPhrase = ({ const [error, setError] = useState(''); const [hideSeedPhraseInput, setHideSeedPhraseInput] = useState(true); const [seedPhrase, setSeedPhrase] = useState(['']); - const [seedPhraseInputFocusedIndex, setSeedPhraseInputFocusedIndex] = - useState(null); - const [nextSeedPhraseInputFocusedIndex, setNextSeedPhraseInputFocusedIndex] = - useState(null); const [currentStep, setCurrentStep] = useState(0); const [learnMore, setLearnMore] = useState(false); const [showPasswordIndex, setShowPasswordIndex] = useState([0, 1]); + const srpInputGridRef = useRef(null); + const { fetchAccountsWithActivity } = useAccountsWithNetworkActivitySync({ onFirstLoad: false, onTransactionComplete: false, @@ -143,6 +130,13 @@ const ImportFromSecretRecoveryPhrase = ({ return !SRP_LENGTHS.includes(updatedSeedPhraseLength); }, [seedPhrase]); + useEffect(() => { + if (error) { + setError(''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [seedPhrase]); + const { isEnabled: isMetricsEnabled } = useMetrics(); const track = (event, properties) => { @@ -151,155 +145,6 @@ const ImportFromSecretRecoveryPhrase = ({ trackOnboarding(eventBuilder.build(), saveOnboardingEvent); }; - const [errorWordIndexes, setErrorWordIndexes] = useState({}); - - const handleClear = useCallback(() => { - setSeedPhrase(['']); - setErrorWordIndexes({}); - setError(''); - setSeedPhraseInputFocusedIndex(0); - setNextSeedPhraseInputFocusedIndex(0); - }, []); - - const handleSeedPhraseChangeAtIndex = useCallback( - (seedPhraseText, index) => { - try { - const text = formatSeedPhraseToSingleLine(seedPhraseText); - - if (text.includes(SPACE_CHAR)) { - const isEndWithSpace = text.at(-1) === SPACE_CHAR; - // handle use pasting multiple words / whole seed phrase separated by spaces - const splitArray = text - .trim() - .split(' ') - .filter((word) => word.trim() !== ''); - - // If no valid words (only spaces), don't navigate to next field - if (splitArray.length === 0) { - // User typed only spaces, stay in current field - setSeedPhrase((prev) => { - const newSeedPhrase = [...prev]; - newSeedPhrase[index] = ''; // Clear the spaces - return newSeedPhrase; - }); - return; - } - - // Build the new seed phrase array - const mergedSeedPhrase = [ - ...seedPhrase.slice(0, index), - ...splitArray, - ...seedPhrase.slice(index + 1), - ]; - - const normalizedWords = mergedSeedPhrase - .map((w) => w.trim()) - .filter((w) => w !== ''); - const maxAllowed = Math.max(...SRP_LENGTHS); - const hasReachedMax = normalizedWords.length >= maxAllowed; - const isCompleteAndValid = - SRP_LENGTHS.includes(normalizedWords.length) && - isValidMnemonic(normalizedWords.join(' ')); - - // Prepare next state, retaining a single trailing empty input only when appropriate - let nextSeedPhraseState = normalizedWords; - if ( - isEndWithSpace && - index === seedPhrase.length - 1 && - !isCompleteAndValid && - !hasReachedMax - ) { - nextSeedPhraseState = [...normalizedWords, '']; - } - - // Always update component state before handling keyboard/focus - setSeedPhrase(nextSeedPhraseState); - - if (isCompleteAndValid || hasReachedMax) { - Keyboard.dismiss(); - setSeedPhraseInputFocusedIndex(null); - setNextSeedPhraseInputFocusedIndex(null); - return; - } - - const targetIndex = Math.min( - nextSeedPhraseState.length - 1, - index + splitArray.length, - ); - setTimeout(() => { - setNextSeedPhraseInputFocusedIndex(targetIndex); - }, 0); - return; - } - - // Only update state if the value is different from what's stored - if (seedPhrase[index] !== text) { - setSeedPhrase((prev) => { - const newSeedPhrase = [...prev]; - newSeedPhrase[index] = text; - return newSeedPhrase; - }); - } - } catch (error) { - Logger.error('Error handling seed phrase change:', error); - } - }, - [seedPhrase], - ); - - const handleSeedPhraseChange = useCallback( - (seedPhraseText) => { - const text = formatSeedPhraseToSingleLine(seedPhraseText); - const trimmedText = text.trim(); - const updatedTrimmedText = trimmedText - .split(' ') - .filter((word) => word !== ''); - - if (SRP_LENGTHS.includes(updatedTrimmedText.length)) { - setSeedPhrase(updatedTrimmedText); - } else { - handleSeedPhraseChangeAtIndex(text, 0); - } - - if (updatedTrimmedText.length > 1) { - // no focus on any input - setTimeout(() => { - setSeedPhraseInputFocusedIndex(null); - setNextSeedPhraseInputFocusedIndex(null); - seedPhraseInputRefs.current.get(0)?.blur(); - Keyboard.dismiss(); - }, 100); - } - }, - [handleSeedPhraseChangeAtIndex, setSeedPhrase], - ); - - const checkForWordErrors = useCallback( - (seedPhraseArr) => { - const errorsMap = {}; - seedPhraseArr.forEach((word, index) => { - // Trim the word for validation but keep the original for cursor position - const trimmedWord = word.trim(); - if (trimmedWord && !checkValidSeedWord(trimmedWord)) { - errorsMap[index] = true; - } - }); - setErrorWordIndexes(errorsMap); - return errorsMap; - }, - [setErrorWordIndexes], - ); - - useEffect(() => { - const wordErrorMap = checkForWordErrors(seedPhrase); - const hasWordErrors = Object.values(wordErrorMap).some(Boolean); - if (hasWordErrors) { - setError(strings('import_from_seed.spellcheck_error')); - } else { - setError(''); - } - }, [seedPhrase, checkForWordErrors]); - const onQrCodePress = useCallback(() => { let shouldHideSRP = true; if (!hideSeedPhraseInput) { @@ -312,8 +157,7 @@ const ImportFromSecretRecoveryPhrase = ({ disableTabber: true, onScanSuccess: ({ seed = undefined }) => { if (seed) { - handleClear(); - handleSeedPhraseChange(seed); + srpInputGridRef.current?.handleSeedPhraseChange(seed); } else { Alert.alert( strings('import_from_seed.invalid_qr_code_title'), @@ -326,7 +170,7 @@ const ImportFromSecretRecoveryPhrase = ({ setHideSeedPhraseInput(shouldHideSRP); }, }); - }, [hideSeedPhraseInput, navigation, handleClear, handleSeedPhraseChange]); + }, [hideSeedPhraseInput, navigation]); const onBackPress = () => { if (currentStep === 0) { @@ -446,19 +290,12 @@ const ImportFromSecretRecoveryPhrase = ({ current && current.focus(); }; - const handlePaste = useCallback(async () => { - const text = await Clipboard.getString(); // Get copied text - if (text.trim() !== '') { - handleSeedPhraseChange(text); - } - }, [handleSeedPhraseChange]); - const validateSeedPhrase = () => { // Trim each word before joining to ensure proper validation const phrase = seedPhrase .map((item) => item.trim()) .filter((item) => item !== '') - .join(' '); + .join(SPACE_CHAR); const seedPhraseLength = seedPhrase.length; if (!SRP_LENGTHS.includes(seedPhraseLength)) { toastRef?.current?.showToast({ @@ -517,10 +354,10 @@ const ImportFromSecretRecoveryPhrase = ({ }; const onPressImport = async () => { - seedPhraseInputRefs.current.get(seedPhraseInputFocusedIndex)?.blur(); - // Trim each word before joining for processing - const trimmedSeedPhrase = seedPhrase.map((item) => item.trim()).join(' '); + const trimmedSeedPhrase = seedPhrase + .map((item) => item.trim()) + .join(SPACE_CHAR); const vaultSeed = await parseVaultValue(password, trimmedSeedPhrase); const parsedSeed = parseSeedPhrase(vaultSeed || trimmedSeedPhrase); @@ -660,15 +497,6 @@ const ImportFromSecretRecoveryPhrase = ({ }); }; - const getInputValue = (isFirstInput, index, item) => { - if (isFirstInput) { - return seedPhrase?.[0] || ''; - } - - // Show all words by default - return item; - }; - const learnMoreLink = () => { navigation.push('Webview', { screen: 'SimpleWebview', @@ -679,65 +507,8 @@ const ImportFromSecretRecoveryPhrase = ({ }); }; - useEffect(() => { - if (nextSeedPhraseInputFocusedIndex === null) return; - - const refElement = seedPhraseInputRefs.current.get( - nextSeedPhraseInputFocusedIndex, - ); - - refElement?.focus(); - }, [nextSeedPhraseInputFocusedIndex]); - - const handleOnFocus = useCallback( - (index) => { - const currentWord = seedPhrase[seedPhraseInputFocusedIndex]; - const trimmedWord = currentWord ? currentWord.trim() : ''; - - if (trimmedWord && !checkValidSeedWord(trimmedWord)) { - setErrorWordIndexes((prev) => ({ - ...prev, - [seedPhraseInputFocusedIndex]: true, - })); - } else { - setErrorWordIndexes((prev) => ({ - ...prev, - [seedPhraseInputFocusedIndex]: false, - })); - } - setSeedPhraseInputFocusedIndex(index); - setNextSeedPhraseInputFocusedIndex(index); - }, - [seedPhrase, seedPhraseInputFocusedIndex], - ); - - const trimmedSeedPhraseLength = useMemo( - () => seedPhrase.filter((word) => word !== '').length, - [seedPhrase], - ); - const uniqueId = useMemo(() => uuidv4(), []); - const isFirstInput = useMemo(() => seedPhrase.length <= 1, [seedPhrase]); - - const handleKeyPress = (e, index) => { - if (e.nativeEvent.key === 'Backspace') { - if (seedPhrase[index] === '') { - const newData = seedPhrase.filter((_, idx) => idx !== index); - if (index > 0) { - setNextSeedPhraseInputFocusedIndex(index - 1); - } - setTimeout(() => { - setSeedPhrase(index === 0 ? [''] : [...newData]); - }, 0); - } - } - }; - - const handleEnterKeyPress = (index) => { - handleSeedPhraseChangeAtIndex(`${seedPhrase[index]} `, index); - }; - return ( - - - - - {seedPhrase.map((item, index) => ( - { - const inputRefs = getSeedPhraseInputRef(); - if (ref) { - inputRefs.set(index, ref); - } else { - inputRefs.delete(index); - } - }} - startAccessory={ - !isFirstInput && ( - - {index + 1}. - - ) - } - value={getInputValue(isFirstInput, index, item)} - onFocus={(e) => { - handleOnFocus(index); - }} - onInputFocus={() => { - setNextSeedPhraseInputFocusedIndex(index); - }} - onChangeText={(text) => { - isFirstInput - ? handleSeedPhraseChange(text) - : handleSeedPhraseChangeAtIndex(text, index); - }} - onSubmitEditing={() => { - handleEnterKeyPress(index); - }} - placeholder={ - isFirstInput - ? strings('import_from_seed.srp_placeholder') - : '' - } - placeholderTextColor={ - isFirstInput - ? colors.text.alternative - : colors.text.muted - } - size={TextFieldSize.Md} - style={ - isFirstInput - ? styles.seedPhraseDefaultInput - : [ - styles.input, - styles.seedPhraseInputItem, - (index + 1) % 3 === 0 && - styles.seedPhraseInputItemLast, - ] - } - inputStyle={ - isFirstInput - ? styles.textAreaInput - : styles.inputItem - } - submitBehavior="submit" - autoComplete="off" - textAlignVertical={isFirstInput ? 'top' : 'center'} - showSoftInputOnFocus - isError={errorWordIndexes[index]} - autoCapitalize="none" - numberOfLines={1} - testID={ - isFirstInput - ? ImportFromSeedSelectorsIDs.SEED_PHRASE_INPUT_ID - : `${ImportFromSeedSelectorsIDs.SEED_PHRASE_INPUT_ID}_${index}` - } - keyboardType="default" - autoCorrect={false} - textContentType="oneTimeCode" - spellCheck={false} - autoFocus={ - isFirstInput || - index === nextSeedPhraseInputFocusedIndex - } - multiline={isFirstInput} - onKeyPress={(e) => handleKeyPress(e, index)} - /> - ))} - - - - { - if (trimmedSeedPhraseLength >= 1) { - handleClear(); - } else { - handlePaste(); - } - }} - > - {trimmedSeedPhraseLength >= 1 - ? strings('import_from_seed.clear_all') - : strings('import_from_seed.paste')} - - {Boolean(error) && ( - - {error} - - )} - +