From c4049aa64da45e2dfbb662455aae3d1ec0911ce9 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Wed, 6 May 2026 17:27:10 +0530 Subject: [PATCH 01/27] fix: metrics functionality fixes for money account transactions (#29723) ## **Description** Metrics functionality fixes for money account transactions ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1341 ## **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** NA ## **Pre-merge author checklist** - [X] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I've included tests if applicable - [X] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [X] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Updates transaction-type classification for metrics and activity filtering, which could affect analytics attribution and visibility of transactions in activity feeds if mis-tagged. Changes are mostly additive and covered by new unit tests, but touch shared transaction metrics utilities. > > **Overview** > Adds *money account* support to the transaction metrics pipeline by introducing `MonetizedPrimitive.MoneyAccount`, mapping `moneyAccountDeposit`/`moneyAccountWithdraw` in `getMonetizedPrimitive`, and ensuring base `transaction_type` string derivation returns `money_account_deposit|withdraw` (including when nested). > > Updates MM Pay metrics to treat money account deposit/withdraw as PAY types and derive `mm_pay_use_case` accordingly, and adjusts the confirmation UI metrics hook to include `simulation_sending_assets_total_value` for money account **deposits** (but not withdraws). Also expands staged A/B test attribution and activity `PAY_TYPES` filtering to include money account transactions, with accompanying test coverage. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit bac530a24a45ec47d6fb2f68fe91c2a17fa88a2a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../pay/useTransactionPayMetrics.test.ts | 42 +++++++++++ .../hooks/pay/useTransactionPayMetrics.ts | 5 +- app/core/Analytics/MetaMetrics.types.ts | 1 + .../Analytics/events/transactions/utils.ts | 3 + .../metrics_properties/base.test.ts | 2 + .../metrics_properties/base.ts | 12 ++++ .../metrics_properties/metamask-pay.test.ts | 69 +++++++++++++++++++ .../metrics_properties/metamask-pay.ts | 4 ++ .../swap-transaction-ab-tests.test.ts | 30 ++++++++ .../swap-transaction-ab-tests.ts | 2 + app/util/activity/index.ts | 1 + 11 files changed, 170 insertions(+), 1 deletion(-) diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts index a4ec0278757..5914481b92d 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts @@ -234,6 +234,48 @@ describe('useTransactionPayMetrics', () => { }); }); + it('includes simulation_sending_assets_total_value for money account deposit', async () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN_MOCK, + setPayToken: noop, + } as ReturnType); + + runHook({ type: TransactionType.moneyAccountDeposit }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + simulation_sending_assets_total_value: 1.23, + }), + sensitiveProperties: {}, + }, + }); + }); + + it('omits simulation_sending_assets_total_value for money account withdraw', async () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN_MOCK, + setPayToken: noop, + } as ReturnType); + + runHook({ type: TransactionType.moneyAccountWithdraw }); + + await act(async () => noop()); + + const calledProps = ( + updateConfirmationMetricMock.mock.calls[0]?.[0] as { + params: { properties: Record }; + } + )?.params?.properties; + + expect(calledProps).not.toHaveProperty( + 'simulation_sending_assets_total_value', + ); + }); + describe('mm_pay_quote_requested', () => { it('is false initially', async () => { useTransactionPayTokenMock.mockReturnValue({ diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts index 283e436f2af..0a06177a7c0 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts @@ -87,7 +87,10 @@ export function useTransactionPayMetrics() { if ( payToken && (hasTransactionType(transactionMeta, [TransactionType.perpsDeposit]) || - hasTransactionType(transactionMeta, [TransactionType.predictDeposit])) + hasTransactionType(transactionMeta, [TransactionType.predictDeposit]) || + hasTransactionType(transactionMeta, [ + TransactionType.moneyAccountDeposit, + ])) ) { properties.simulation_sending_assets_total_value = sendingValue; } diff --git a/app/core/Analytics/MetaMetrics.types.ts b/app/core/Analytics/MetaMetrics.types.ts index abb4658e3b5..1b663295cf6 100644 --- a/app/core/Analytics/MetaMetrics.types.ts +++ b/app/core/Analytics/MetaMetrics.types.ts @@ -161,6 +161,7 @@ export enum MonetizedPrimitive { Ramps = 'ramps', Predict = 'predict', MmPay = 'mm_pay', + MoneyAccount = 'money_account', } /** diff --git a/app/core/Analytics/events/transactions/utils.ts b/app/core/Analytics/events/transactions/utils.ts index 08ee3f2c01a..f531ff786b1 100644 --- a/app/core/Analytics/events/transactions/utils.ts +++ b/app/core/Analytics/events/transactions/utils.ts @@ -23,6 +23,9 @@ export function getMonetizedPrimitive( case TransactionType.predictWithdraw: case TransactionType.predictClaim: return MonetizedPrimitive.Predict; + case TransactionType.moneyAccountDeposit: + case TransactionType.moneyAccountWithdraw: + return MonetizedPrimitive.MoneyAccount; default: return undefined; } diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/base.test.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/base.test.ts index acad8bf98c3..1df3ef755da 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/base.test.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/base.test.ts @@ -104,6 +104,8 @@ describe('getTransactionTypeValue', () => { ['predict_deposit', TransactionType.predictDeposit], ['predict_withdraw', TransactionType.predictWithdraw], ['perps_withdraw', TransactionType.perpsWithdraw], + ['money_account_deposit', TransactionType.moneyAccountDeposit], + ['money_account_withdraw', TransactionType.moneyAccountWithdraw], ['musd_conversion', TransactionType.musdConversion], ['musd_claim', TransactionType.musdClaim], ])('returns %s if nested transaction type is %s', (expected, nestedType) => { diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/base.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/base.ts index dd1abdae43b..f3892d459c0 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/base.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/base.ts @@ -74,6 +74,18 @@ export function getTransactionTypeValue( return 'predict_claim'; } + if ( + hasTransactionType(transactionMeta, [TransactionType.moneyAccountDeposit]) + ) { + return 'money_account_deposit'; + } + + if ( + hasTransactionType(transactionMeta, [TransactionType.moneyAccountWithdraw]) + ) { + return 'money_account_withdraw'; + } + if (hasTransactionType(transactionMeta, [TransactionType.musdConversion])) { return 'musd_conversion'; } diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts index df0f7c4c620..d849fea411d 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts @@ -83,6 +83,75 @@ describe('Metamask Pay Metrics', () => { }); }); + it.each([ + TransactionType.moneyAccountDeposit, + TransactionType.moneyAccountWithdraw, + ])('returns nothing if %s without controller state', (type) => { + request.transactionMeta.type = type; + + const result = getMetaMaskPayProperties(request); + + expect(result).toStrictEqual({ + properties: {}, + sensitiveProperties: {}, + }); + }); + + it.each([ + [TransactionType.moneyAccountDeposit, 'money_account_deposit'], + [TransactionType.moneyAccountWithdraw, 'money_account_withdraw'], + ])( + 'derives mm_pay_use_case=%s for %s parent', + (parentType, expectedUseCase) => { + getStateMock.mockReturnValue({ + engine: { + backgroundState: { + TokensController: { allTokens: {} }, + TransactionPayController: { + transactionData: { + 'parent-1': { + paymentToken: { symbol: 'USDC', chainId: '0x1' }, + quotes: [{ strategy: TransactionPayStrategy.Relay }], + tokens: [{ skipIfBalance: false, amountUsd: '50' }], + totals: { + targetAmount: { usd: '49.5', fiat: '49.5' }, + fees: { + metaMask: { usd: '0', fiat: '0' }, + provider: { usd: '0.2', fiat: '0.2' }, + sourceNetwork: { estimate: { usd: '0.1', fiat: '0.1' } }, + targetNetwork: { usd: '0', fiat: '0' }, + }, + }, + }, + }, + }, + }, + }, + } as never); + + request.allTransactions = [ + { + id: 'parent-1', + type: parentType, + metamaskPay: { chainId: '0x1', tokenAddress: '0xA0b8' }, + requiredTransactionIds: ['child-1'], + } as unknown as TransactionMeta, + ]; + + const result = getMetaMaskPayProperties(request); + + expect(result).toStrictEqual({ + properties: expect.objectContaining({ + mm_pay: true, + mm_pay_use_case: expectedUseCase, + mm_pay_token_selected: 'USDC', + mm_pay_chain_selected: '0x1', + }), + sensitiveProperties: {}, + }); + }, + ); + it('derives parent mm_pay_* properties for child transaction from controller state', () => { getStateMock.mockReturnValue({ engine: { diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts index 72fe5cebe67..11e090c0531 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts @@ -20,6 +20,8 @@ import { BigNumber } from 'bignumber.js'; const FOUR_BYTE_SAFE_PROXY_CREATE = '0xa1884d2c'; const PAY_TYPES = [ + TransactionType.moneyAccountDeposit, + TransactionType.moneyAccountWithdraw, TransactionType.perpsDeposit, TransactionType.perpsWithdraw, TransactionType.predictDeposit, @@ -31,6 +33,8 @@ const USE_CASE_MAP: [TransactionType[], string][] = [ [[TransactionType.predictDeposit], 'predict_deposit'], [[TransactionType.perpsDeposit], 'perps_deposit'], [[TransactionType.perpsWithdraw], 'perps_withdraw'], + [[TransactionType.moneyAccountDeposit], 'money_account_deposit'], + [[TransactionType.moneyAccountWithdraw], 'money_account_withdraw'], ]; export const getMetaMaskPayProperties: TransactionMetricsBuilder = ({ diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.test.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.test.ts index f4526a23efa..ad5180be115 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.test.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.test.ts @@ -108,4 +108,34 @@ describe('getSwapTransactionActiveAbTestProperties', () => { sensitiveProperties: {}, }); }); + + it.each([ + TransactionType.moneyAccountDeposit, + TransactionType.moneyAccountWithdraw, + ])('returns active_ab_tests for %s Transaction Added', (type) => { + const abTests = [ + { key: 'homeTMCU470AbtestTrendingSections', value: 'trendingSections' }, + ]; + registerTransactionAbTestAttributionForIds([TX_ID], abTests); + const request = createMockRequest({ + transactionMeta: { + id: TX_ID, + type, + } as never, + }); + + expect(getSwapTransactionActiveAbTestProperties(request)).toEqual({ + properties: { + active_ab_tests: [ + { + key: 'homeTMCU470AbtestTrendingSections', + value: 'trendingSections', + key_value_pair: + 'homeTMCU470AbtestTrendingSections=trendingSections', + }, + ], + }, + sensitiveProperties: {}, + }); + }); }); diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.ts index 1cd60d3b5c2..420d1732f35 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.ts @@ -18,6 +18,8 @@ const TRANSACTION_TYPES_FOR_ACTIVE_AB_TESTS: ReadonlySet = TransactionType.swapApproval, TransactionType.swapAndSend, TransactionType.bridgeApproval, + TransactionType.moneyAccountDeposit, + TransactionType.moneyAccountWithdraw, TransactionType.perpsAcrossDeposit, TransactionType.perpsDeposit, TransactionType.perpsDepositAndOrder, diff --git a/app/util/activity/index.ts b/app/util/activity/index.ts index d154c6d5bef..9912e6371cb 100644 --- a/app/util/activity/index.ts +++ b/app/util/activity/index.ts @@ -11,6 +11,7 @@ import { BridgeHistoryItem } from '@metamask/bridge-status-controller'; import { AddressBookControllerState } from '@metamask/address-book-controller'; export const PAY_TYPES = [ + TransactionType.moneyAccountDeposit, TransactionType.perpsDeposit, TransactionType.predictDeposit, ]; From 6b17dc5be0f1e9d747f04dd57fb3b44fb8ba5a43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caain=C3=A3=20Jeronimo?= Date: Wed, 6 May 2026 09:17:45 -0300 Subject: [PATCH 02/27] fix(predict): keep live position data in sync across screens (#29527) ## **Description** This PR fixes inconsistencies in Predict position values across the home screen, market details, and card/list surfaces. It moves active-position live updates into `usePredictPositions` via `usePredictLivePositions`, syncs websocket-derived values back into the shared React Query positions cache, removes duplicate component-level live subscriptions, scopes live subscriptions to focused screens only, and fixes market websocket unsubscribe behavior so overlapping token subscriptions do not break updates on other active screens. It also updates the positions header to read claimable positions from `usePredictPositions` instead of Redux/controller state so it stays aligned with the shared query source. ## **Changelog** CHANGELOG entry: Fixed live prediction position values so they stay updated across the home screen and market details views. ## **Related issues** Refs: https://consensyssoftware.atlassian.net/browse/PRED-820 ## **Manual testing steps** ```gherkin Feature: live predict positions stay synchronized across screens Scenario: home positions continue updating after visiting market details Given the user has at least one active Predict position And the user is on the Wallet home screen with the Predictions positions section visible When the user waits for live position values to update And opens one of the active positions into market details And waits for live position values to update on market details And navigates back to the Wallet home screen Then the same home position continues receiving live value updates Scenario: only the focused screen keeps market subscriptions active Given the user has active Predict positions on the Wallet home screen When the user opens a single position in market details Then market details only shows live updates for the focused position tokens And no unrelated position updates continue streaming from the hidden home screen Scenario: claimable positions still render correctly Given the user has claimable Predict positions When the user opens surfaces that show claimable positions Then claimable amounts still render correctly And the positions header claim button amount matches the claimable positions list ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Moderate risk because it changes how live price updates propagate through shared React Query caches and alters WebSocket subscription/unsubscribe behavior, which could affect update frequency and data correctness across multiple screens. > > **Overview** > Ensures Predict **active position** values stay consistent across home, market details, and card surfaces by adding an opt-in `livePriceUpdates` flag to `usePredictPositions` that enables `usePredictLivePositions` to *sync websocket-derived PnL/value updates back into the shared positions query cache*. > > Removes component-level live position mapping (`PredictPicks`, `PredictPicksForCard`) in favor of consuming already-updated `usePredictPositions` data, and scopes live subscriptions to focused screens while skipping claimable positions. > > Fixes Polymarket market-price WebSocket unsubscribe logic to avoid unsubscribing token IDs still required by other active subscriptions, and updates `PredictPositionsHeader` to derive won/claimable positions from `usePredictPositions` instead of Redux state; tests were updated/added to cover the new live-update and unsubscribe semantics. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8eda7a8481589bc305adc0894f5f334b8250d30b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../PredictGameDetailsContent.tsx | 1 + .../PredictHome/PredictHomePositions.tsx | 2 +- .../PredictPicks/PredictPicks.test.tsx | 7 - .../components/PredictPicks/PredictPicks.tsx | 6 +- .../PredictPicks/PredictPicksForCard.test.tsx | 15 +- .../PredictPicks/PredictPicksForCard.tsx | 8 +- .../PredictPositionsHeader.test.tsx | 81 +-- .../PredictPositionsHeader.tsx | 21 +- .../PredictSportCardFooter.test.tsx | 2 +- .../PredictSportCardFooter.tsx | 2 +- app/components/UI/Predict/hooks/index.ts | 6 - .../hooks/usePredictLivePositions.test.ts | 488 ++++++++++++++---- .../Predict/hooks/usePredictLivePositions.ts | 170 ++++-- .../Predict/hooks/usePredictPositions.test.ts | 117 +++++ .../UI/Predict/hooks/usePredictPositions.ts | 13 +- .../polymarket/WebSocketManager.test.ts | 52 ++ .../providers/polymarket/WebSocketManager.ts | 26 +- .../PredictMarketDetails.test.tsx | 8 + .../PredictMarketDetails.tsx | 1 + .../Predictions/PredictionsSection.test.tsx | 35 ++ .../usePredictPositionsForHomepage.test.ts | 31 +- .../hooks/usePredictPositionsForHomepage.ts | 1 + tests/component-view/mocks.ts | 2 + 23 files changed, 866 insertions(+), 229 deletions(-) diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx index 1fef385dea2..7dcc0b4c590 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx @@ -80,6 +80,7 @@ const PredictGameDetailsContent: React.FC = ({ marketId: market.id, childMarketIds: market.childMarketIds, claimable: false, + livePriceUpdates: true, }); const { data: claimablePositions = [] } = usePredictPositions({ marketId: market.id, diff --git a/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx b/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx index 5753c9818ec..9a2c259a40e 100644 --- a/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx +++ b/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx @@ -42,7 +42,7 @@ const PredictHomePositions = forwardRef< refetch, isLoading: isActiveLoading, error: activeError, - } = usePredictPositions({ claimable: false }); + } = usePredictPositions({ claimable: false, livePriceUpdates: true }); const { data: claimablePositions = [], diff --git a/app/components/UI/Predict/components/PredictPicks/PredictPicks.test.tsx b/app/components/UI/Predict/components/PredictPicks/PredictPicks.test.tsx index c673a6d1b98..005c6b2e767 100644 --- a/app/components/UI/Predict/components/PredictPicks/PredictPicks.test.tsx +++ b/app/components/UI/Predict/components/PredictPicks/PredictPicks.test.tsx @@ -45,13 +45,6 @@ jest.mock('../../hooks/usePredictCashOut', () => ({ usePredictCashOut: () => ({ onCashOut: mockOnCashOut }), })); -jest.mock('../../hooks/usePredictLivePositions', () => ({ - usePredictLivePositions: jest.fn((positions: unknown[]) => ({ - livePositions: positions ?? [], - isConnected: false, - lastUpdateTime: null, - })), -})); jest.mock('../../utils/format'); const mockUseSelector = useSelector as jest.MockedFunction; diff --git a/app/components/UI/Predict/components/PredictPicks/PredictPicks.tsx b/app/components/UI/Predict/components/PredictPicks/PredictPicks.tsx index 0d560fdda2d..47568ad316a 100644 --- a/app/components/UI/Predict/components/PredictPicks/PredictPicks.tsx +++ b/app/components/UI/Predict/components/PredictPicks/PredictPicks.tsx @@ -1,7 +1,6 @@ import { Box } from '@metamask/design-system-react-native'; import React from 'react'; import { useSelector } from 'react-redux'; -import { usePredictLivePositions } from '../../hooks/usePredictLivePositions'; import { usePredictCashOut } from '../../hooks/usePredictCashOut'; import { PredictMarket, @@ -29,7 +28,6 @@ const PredictPicks: React.FC = ({ claimablePositions, testID = PREDICT_PICKS_TEST_ID, }) => { - const { livePositions } = usePredictLivePositions(positions); const { onCashOut } = usePredictCashOut({ market, callerName: 'PredictPicks', @@ -43,7 +41,7 @@ const PredictPicks: React.FC = ({ if (usePositionDetail) { return ( - {livePositions.map((position) => ( + {positions.map((position) => ( = ({ return ( - {livePositions.map((position) => ( + {positions.map((position) => ( ({ - usePredictLivePositions: jest.fn((positions: unknown[]) => ({ - livePositions: positions ?? [], - isConnected: false, - lastUpdateTime: null, - })), -})); jest.mock('../../utils/format'); const mockUsePredictPositions = usePredictPositions as jest.Mock; @@ -327,12 +320,12 @@ describe('PredictPicksForCard', () => { expect(mockUsePredictPositions).toHaveBeenCalledWith({ marketId: 'specific-market-456', - refetchInterval: 10000, enabled: true, + livePriceUpdates: true, }); }); - it('passes refetchInterval of 10000ms to hook when no positions prop', () => { + it('enables livePriceUpdates when no positions prop', () => { mockUsePredictPositions.mockReturnValue({ data: [], isLoading: false, @@ -345,7 +338,7 @@ describe('PredictPicksForCard', () => { expect(mockUsePredictPositions).toHaveBeenCalledWith( expect.objectContaining({ - refetchInterval: 10000, + livePriceUpdates: true, }), ); }); @@ -362,8 +355,8 @@ describe('PredictPicksForCard', () => { expect(mockUsePredictPositions).toHaveBeenCalledWith({ marketId: 'market-1', - refetchInterval: undefined, enabled: false, + livePriceUpdates: false, }); }); }); diff --git a/app/components/UI/Predict/components/PredictPicks/PredictPicksForCard.tsx b/app/components/UI/Predict/components/PredictPicks/PredictPicksForCard.tsx index f2049d1248a..92e5ac875e3 100644 --- a/app/components/UI/Predict/components/PredictPicks/PredictPicksForCard.tsx +++ b/app/components/UI/Predict/components/PredictPicks/PredictPicksForCard.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { Box } from '@metamask/design-system-react-native'; import { usePredictPositions } from '../../hooks/usePredictPositions'; -import { usePredictLivePositions } from '../../hooks/usePredictLivePositions'; import type { PredictPosition } from '../../types'; import PredictPicksForCardItem from './PredictPicksForCardItem'; import { @@ -33,14 +32,13 @@ const PredictPicksForCard: React.FC = ({ }) => { const { data: fetchedPositions = [] } = usePredictPositions({ marketId, - refetchInterval: positionsProp ? undefined : 10000, enabled: !positionsProp, + livePriceUpdates: !positionsProp, }); const basePositions = positionsProp ?? fetchedPositions; - const { livePositions } = usePredictLivePositions(basePositions); - if (livePositions.length === 0) { + if (basePositions.length === 0) { return null; } @@ -52,7 +50,7 @@ const PredictPicksForCard: React.FC = ({ twClassName="h-px bg-border-muted my-2" /> )} - {livePositions.map((position) => ( + {basePositions.map((position) => ( ({ })); const mockRefetchClaimablePositions = jest.fn(); -jest.mock('../../hooks/usePredictPositions', () => ({ - usePredictPositions: () => ({ - data: [{ id: 'position-1' }], - isLoading: false, - error: null, - refetch: mockRefetchClaimablePositions, - }), -})); +let mockActivePositions: PredictPosition[] = []; +let mockClaimablePositions: PredictPosition[] = []; +jest.mock('../../hooks/usePredictPositions'); const mockClaim = jest.fn(); jest.mock('../../hooks/usePredictClaim', () => ({ @@ -127,36 +123,13 @@ jest.mock('../../../../../../locales/i18n', () => ({ }), })); -function createTestState( - _availableBalance?: number, - claimableAmount?: number, - privacyMode = false, -) { +function createTestState(_availableBalance?: number, privacyMode = false) { const testAddress = '0x1234567890123456789012345678901234567890'; const testAccountId = 'test-account-id'; - const claimablePositions = claimableAmount - ? ([ - { - id: 'position-1', - status: PredictPositionStatus.WON, - cashPnl: claimableAmount, - currentValue: claimableAmount, - marketId: 'market-1', - title: 'Test Market', - outcome: 'Yes', - }, - ] as unknown as PredictPosition[]) - : []; - return { engine: { backgroundState: { - PredictController: { - claimablePositions: { - [testAddress]: claimablePositions, - }, - }, AccountsController: { internalAccounts: { selectedAccount: testAccountId, @@ -185,11 +158,16 @@ describe('MarketsWonCard', () => { const mockUseUnrealizedPnL = useUnrealizedPnL as jest.MockedFunction< typeof useUnrealizedPnL >; + const mockUsePredictPositions = usePredictPositions as jest.MockedFunction< + typeof usePredictPositions + >; beforeEach(() => { jest.clearAllMocks(); mockBalanceResult.data = 100.5; mockBalanceResult.isLoading = false; + mockActivePositions = [{ id: 'position-1' } as PredictPosition]; + mockClaimablePositions = []; mockUseUnrealizedPnL.mockReturnValue({ data: { @@ -201,13 +179,35 @@ describe('MarketsWonCard', () => { isFetching: false, error: null, } as unknown as ReturnType); + mockUsePredictPositions.mockImplementation( + ({ claimable }: { claimable?: boolean } = {}) => + ({ + data: claimable ? mockClaimablePositions : mockActivePositions, + isLoading: false, + error: null, + refetch: mockRefetchClaimablePositions, + }) as unknown as ReturnType, + ); }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); describe('rendering', () => { + it('does not enable live updates for active position count query', () => { + const state = createTestState(100.5); + + renderWithProvider(, { state }); + + const activePositionsCall = mockUsePredictPositions.mock.calls.find( + ([options]) => options?.claimable === false, + ); + + expect(activePositionsCall?.[0]).toMatchObject({ claimable: false }); + expect(activePositionsCall?.[0]?.livePriceUpdates).toBeUndefined(); + }); + it('displays available balance and unrealized P&L', () => { const state = createTestState(100.5); @@ -230,7 +230,18 @@ describe('MarketsWonCard', () => { }); it('hides monetary values when privacy mode is enabled', () => { - const state = createTestState(100.5, 24.66, true); + mockClaimablePositions = [ + { + id: 'position-1', + status: PredictPositionStatus.WON, + cashPnl: 24.66, + currentValue: 24.66, + marketId: 'market-1', + title: 'Test Market', + outcome: 'Yes', + } as PredictPosition, + ]; + const state = createTestState(100.5, true); renderWithProvider(, { state }); @@ -238,7 +249,7 @@ describe('MarketsWonCard', () => { expect(screen.queryByText('+$8.63 (+3.9%)')).toBeNull(); expect(screen.queryByText('Claim $24.66')).toBeNull(); expect(screen.getByText('••••••••••••')).toBeOnTheScreen(); - expect(screen.getByText('•••••••••')).toBeOnTheScreen(); + expect(screen.getAllByText('•••••••••').length).toBeGreaterThan(0); }); }); diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx index 6bca1d097b8..6d37d864d30 100644 --- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx +++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx @@ -46,8 +46,7 @@ import { usePredictDeposit } from '../../hooks/usePredictDeposit'; import { useUnrealizedPnL } from '../../hooks/useUnrealizedPnL'; import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; import { usePredictPositions } from '../../hooks/usePredictPositions'; -import { selectPredictWonPositions } from '../../selectors/predictController'; -import { PredictPosition } from '../../types'; +import { PredictPosition, PredictPositionStatus } from '../../types'; import { PredictNavigationParamList } from '../../types/navigation'; import { formatPercentage, @@ -95,12 +94,20 @@ const PredictPositionsHeader = forwardRef< const evmAccount = getEvmAccountFromSelectedAccountGroup(); const selectedAddress = evmAccount?.address ?? '0x0'; const { isDepositPending } = usePredictDeposit(); - const wonPositions = useSelector( - selectPredictWonPositions({ address: selectedAddress }), - ); - - const { data: activePositions } = usePredictPositions({ claimable: false }); + const { data: activePositions } = usePredictPositions({ + claimable: false, + }); + const { data: claimablePositions = [] } = usePredictPositions({ + claimable: true, + }); const hasPositions = (activePositions?.length ?? 0) > 0; + const wonPositions = useMemo( + () => + claimablePositions.filter( + (position) => position.status === PredictPositionStatus.WON, + ), + [claimablePositions], + ); const { data: pnlData, diff --git a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx index 02d2100f1ca..bd700a62a77 100644 --- a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx +++ b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx @@ -306,7 +306,7 @@ describe('PredictSportCardFooter', () => { expect(mockUsePredictPositions).toHaveBeenCalledWith({ marketId: 'specific-market-123', claimable: false, - refetchInterval: 10000, + livePriceUpdates: true, }); }); diff --git a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx index 0f3b6af467a..69792b7c999 100644 --- a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx +++ b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx @@ -53,7 +53,7 @@ const PredictSportCardFooter: React.FC = ({ const { data: positions = [], isLoading } = usePredictPositions({ marketId: market.id, claimable: false, - refetchInterval: 10000, + livePriceUpdates: true, }); const { data: claimablePositions = [] } = usePredictPositions({ diff --git a/app/components/UI/Predict/hooks/index.ts b/app/components/UI/Predict/hooks/index.ts index 1515de44b18..e22c2dda532 100644 --- a/app/components/UI/Predict/hooks/index.ts +++ b/app/components/UI/Predict/hooks/index.ts @@ -13,12 +13,6 @@ export { type UseLiveMarketPricesResult, } from './useLiveMarketPrices'; -export { - usePredictLivePositions, - type UseLivePositionsOptions, - type UseLivePositionsResult, -} from './usePredictLivePositions'; - export { usePredictTabs, type FeedTab, diff --git a/app/components/UI/Predict/hooks/usePredictLivePositions.test.ts b/app/components/UI/Predict/hooks/usePredictLivePositions.test.ts index b091e2f8e79..91ec1f57b18 100644 --- a/app/components/UI/Predict/hooks/usePredictLivePositions.test.ts +++ b/app/components/UI/Predict/hooks/usePredictLivePositions.test.ts @@ -1,10 +1,19 @@ -import { renderHook } from '@testing-library/react-native'; +import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react-native'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { usePredictLivePositions } from './usePredictLivePositions'; import { useLiveMarketPrices } from './useLiveMarketPrices'; import { PredictPosition, PredictPositionStatus, PriceUpdate } from '../types'; +import { predictQueries } from '../queries'; import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants'; jest.mock('./useLiveMarketPrices'); +const mockUseIsFocused = jest.fn(() => true); +jest.mock('@react-navigation/native', () => ({ + useIsFocused: () => mockUseIsFocused(), +})); + +const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; const createMockPosition = ( overrides: Partial = {}, @@ -42,11 +51,52 @@ const createMockPriceUpdate = ( ...overrides, }); +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, cacheTime: Infinity } }, + }); + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + return { Wrapper, queryClient }; +}; + +const renderLivePositionsHook = ( + positions: PredictPosition[], + options?: Parameters[1], + cachedPositions?: PredictPosition[], +) => { + const { Wrapper, queryClient } = createWrapper(); + if (cachedPositions) { + queryClient.setQueryData( + predictQueries.positions.keys.byAddress(MOCK_ADDRESS), + cachedPositions, + ); + } + const renderResult = renderHook( + () => usePredictLivePositions(positions, options), + { + wrapper: Wrapper, + }, + ); + + return { + ...renderResult, + queryClient, + }; +}; + describe('usePredictLivePositions', () => { const mockUseLiveMarketPrices = useLiveMarketPrices as jest.Mock; + const getCachedPositions = (queryClient: QueryClient) => + queryClient.getQueryData( + predictQueries.positions.keys.byAddress(MOCK_ADDRESS), + ); + beforeEach(() => { jest.clearAllMocks(); + mockUseIsFocused.mockReturnValue(true); mockUseLiveMarketPrices.mockReturnValue({ prices: new Map(), isConnected: false, @@ -61,7 +111,7 @@ describe('usePredictLivePositions', () => { createMockPosition({ id: 'position-2', outcomeTokenId: 'token-2' }), ]; - renderHook(() => usePredictLivePositions(positions)); + renderLivePositionsHook(positions); expect(mockUseLiveMarketPrices).toHaveBeenCalledWith( ['token-1', 'token-2'], @@ -70,7 +120,7 @@ describe('usePredictLivePositions', () => { }); it('disables subscription when positions array is empty', () => { - renderHook(() => usePredictLivePositions([])); + renderLivePositionsHook([]); expect(mockUseLiveMarketPrices).toHaveBeenCalledWith([], { enabled: false, @@ -80,7 +130,7 @@ describe('usePredictLivePositions', () => { it('disables subscription when enabled option is false', () => { const positions = [createMockPosition()]; - renderHook(() => usePredictLivePositions(positions, { enabled: false })); + renderLivePositionsHook(positions, { enabled: false }); expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(['token-1'], { enabled: false, @@ -90,16 +140,37 @@ describe('usePredictLivePositions', () => { it('passes enabled true when positions exist and enabled is not specified', () => { const positions = [createMockPosition()]; - renderHook(() => usePredictLivePositions(positions)); + renderLivePositionsHook(positions); expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(['token-1'], { enabled: true, }); }); + + it('skips claimable positions when building live subscriptions', () => { + const positions = [createMockPosition({ claimable: true })]; + + renderLivePositionsHook(positions); + + expect(mockUseLiveMarketPrices).toHaveBeenCalledWith([], { + enabled: false, + }); + }); + + it('disables subscription when the screen is not focused', () => { + mockUseIsFocused.mockReturnValue(false); + const positions = [createMockPosition()]; + + renderLivePositionsHook(positions); + + expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(['token-1'], { + enabled: false, + }); + }); }); describe('live position calculation', () => { - it('returns original positions when no price updates are available', () => { + it('preserves cached positions when no price updates are available', async () => { const positions = [createMockPosition({ currentValue: 100, cashPnl: 0 })]; mockUseLiveMarketPrices.mockReturnValue({ prices: new Map(), @@ -107,13 +178,18 @@ describe('usePredictLivePositions', () => { lastUpdateTime: null, }); - const { result } = renderHook(() => usePredictLivePositions(positions)); + const { queryClient } = renderLivePositionsHook( + positions, + { cacheAddress: MOCK_ADDRESS }, + positions, + ); - expect(result.current.livePositions[0].currentValue).toBe(100); - expect(result.current.livePositions[0].cashPnl).toBe(0); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toBe(positions); + }); }); - it('calculates currentValue as size multiplied by bestBid', () => { + it('calculates currentValue as size multiplied by bestBid', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', size: 200, @@ -129,12 +205,19 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].currentValue).toBe(120); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].currentValue).toBe(120); + }); }); - it('calculates cashPnl as currentValue minus initialValue', () => { + it('calculates cashPnl as currentValue minus initialValue', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', size: 200, @@ -150,12 +233,19 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].cashPnl).toBe(20); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].cashPnl).toBe(20); + }); }); - it('calculates percentPnl correctly for positive gains', () => { + it('calculates percentPnl correctly for positive gains', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', size: 200, @@ -171,12 +261,19 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].percentPnl).toBe(20); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].percentPnl).toBe(20); + }); }); - it('calculates percentPnl correctly for negative losses', () => { + it('calculates percentPnl correctly for negative losses', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', size: 200, @@ -192,14 +289,21 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].currentValue).toBe(80); - expect(result.current.livePositions[0].cashPnl).toBe(-20); - expect(result.current.livePositions[0].percentPnl).toBe(-20); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].currentValue).toBe(80); + expect(cached?.[0].cashPnl).toBe(-20); + expect(cached?.[0].percentPnl).toBe(-20); + }); }); - it('returns zero percentPnl when initialValue is zero', () => { + it('returns zero percentPnl when initialValue is zero', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', size: 200, @@ -215,12 +319,19 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].percentPnl).toBe(0); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].percentPnl).toBe(0); + }); }); - it('updates price field with bestBid value', () => { + it('updates price field with bestBid value', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', price: 0.5, @@ -235,14 +346,21 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].price).toBe(0.65); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].price).toBe(0.65); + }); }); }); describe('multiple positions', () => { - it('updates only positions with matching price updates', () => { + it('updates only positions with matching price updates', async () => { const positions = [ createMockPosition({ id: 'position-1', @@ -269,15 +387,22 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions(positions)); + const { queryClient } = renderLivePositionsHook( + positions, + { cacheAddress: MOCK_ADDRESS }, + positions, + ); - expect(result.current.livePositions[0].currentValue).toBe(70); - expect(result.current.livePositions[0].cashPnl).toBe(20); - expect(result.current.livePositions[1].currentValue).toBe(100); - expect(result.current.livePositions[1].cashPnl).toBe(0); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].currentValue).toBe(70); + expect(cached?.[0].cashPnl).toBe(20); + expect(cached?.[1].currentValue).toBe(100); + expect(cached?.[1].cashPnl).toBe(0); + }); }); - it('updates all positions when all have price updates', () => { + it('updates all positions when all have price updates', async () => { const positions = [ createMockPosition({ id: 'position-1', @@ -308,122 +433,265 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions(positions)); + const { queryClient } = renderLivePositionsHook( + positions, + { cacheAddress: MOCK_ADDRESS }, + positions, + ); - expect(result.current.livePositions[0].currentValue).toBe(60); - expect(result.current.livePositions[1].currentValue).toBe(160); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].currentValue).toBe(60); + expect(cached?.[1].currentValue).toBe(160); + }); }); }); - describe('connection status', () => { - it('returns isConnected from useLiveMarketPrices', () => { + describe('cache synchronization', () => { + it('syncs live values into the address cache for passed active positions', async () => { + const activePosition = createMockPosition({ + id: 'active-position', + currentValue: 100, + cashPnl: 0, + percentPnl: 0, + }); + const untouchedPosition = createMockPosition({ + id: 'untouched-position', + outcomeTokenId: 'token-2', + currentValue: 55, + cashPnl: 5, + percentPnl: 10, + }); + const priceUpdate = createMockPriceUpdate({ + tokenId: activePosition.outcomeTokenId, + bestBid: 0.6, + }); + mockUseLiveMarketPrices.mockReturnValue({ - prices: new Map(), + prices: new Map([[activePosition.outcomeTokenId, priceUpdate]]), isConnected: true, - lastUpdateTime: null, + lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([])); + const { queryClient } = renderLivePositionsHook( + [activePosition], + { + cacheAddress: MOCK_ADDRESS, + }, + [activePosition, untouchedPosition], + ); - expect(result.current.isConnected).toBe(true); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toEqual([ + expect.objectContaining({ + id: activePosition.id, + currentValue: 120, + cashPnl: 20, + percentPnl: 20, + price: 0.6, + }), + untouchedPosition, + ]); + }); }); - it('returns false for isConnected when disconnected', () => { - mockUseLiveMarketPrices.mockReturnValue({ - prices: new Map(), - isConnected: false, - lastUpdateTime: null, + it('ignores claimable positions when syncing cache', async () => { + const claimablePosition = createMockPosition({ + id: 'claimable-position', + claimable: true, + currentValue: 80, + cashPnl: 30, + percentPnl: 60, }); + const { queryClient } = renderLivePositionsHook( + [claimablePosition], + { + cacheAddress: MOCK_ADDRESS, + }, + [claimablePosition], + ); - const { result } = renderHook(() => usePredictLivePositions([])); - - expect(result.current.isConnected).toBe(false); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toEqual([claimablePosition]); + }); }); - it('returns lastUpdateTime from useLiveMarketPrices', () => { - const timestamp = 1704067200000; + it('does not rewrite cache when live values are unchanged', async () => { + const livePosition = createMockPosition({ + currentValue: 120, + cashPnl: 20, + percentPnl: 20, + price: 0.6, + }); + const priceUpdate = createMockPriceUpdate({ + tokenId: livePosition.outcomeTokenId, + bestBid: 0.6, + }); mockUseLiveMarketPrices.mockReturnValue({ - prices: new Map(), + prices: new Map([[livePosition.outcomeTokenId, priceUpdate]]), isConnected: true, - lastUpdateTime: timestamp, + lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([])); + const cachedPositions = [livePosition]; + const { queryClient } = renderLivePositionsHook( + [livePosition], + { + cacheAddress: MOCK_ADDRESS, + }, + cachedPositions, + ); - expect(result.current.lastUpdateTime).toBe(timestamp); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toBe(cachedPositions); + }); }); - it('returns null lastUpdateTime when no updates received', () => { + it('disables cache sync when enabled is false', async () => { + const activePosition = createMockPosition({ + currentValue: 100, + cashPnl: 0, + percentPnl: 0, + }); + const priceUpdate = createMockPriceUpdate({ + tokenId: activePosition.outcomeTokenId, + bestBid: 0.6, + }); mockUseLiveMarketPrices.mockReturnValue({ - prices: new Map(), + prices: new Map([[activePosition.outcomeTokenId, priceUpdate]]), isConnected: true, - lastUpdateTime: null, + lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([])); + const cachedPositions = [activePosition]; + const { queryClient } = renderLivePositionsHook( + [activePosition], + { + enabled: false, + cacheAddress: MOCK_ADDRESS, + }, + cachedPositions, + ); - expect(result.current.lastUpdateTime).toBeNull(); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toBe(cachedPositions); + }); }); - }); - describe('empty state', () => { - it('returns empty array for empty positions input', () => { - const { result } = renderHook(() => usePredictLivePositions([])); + it('disables cache sync when the screen is not focused', async () => { + mockUseIsFocused.mockReturnValue(false); + const activePosition = createMockPosition({ + currentValue: 100, + cashPnl: 0, + percentPnl: 0, + }); + const priceUpdate = createMockPriceUpdate({ + tokenId: activePosition.outcomeTokenId, + bestBid: 0.6, + }); + mockUseLiveMarketPrices.mockReturnValue({ + prices: new Map([[activePosition.outcomeTokenId, priceUpdate]]), + isConnected: true, + lastUpdateTime: Date.now(), + }); - expect(result.current.livePositions).toEqual([]); + const cachedPositions = [activePosition]; + const { queryClient } = renderLivePositionsHook( + [activePosition], + { + cacheAddress: MOCK_ADDRESS, + }, + cachedPositions, + ); + + await waitFor(() => { + expect(getCachedPositions(queryClient)).toBe(cachedPositions); + }); }); - it('preserves position order in output', () => { - const positions = [ - createMockPosition({ id: 'first', outcomeTokenId: 'token-1' }), - createMockPosition({ id: 'second', outcomeTokenId: 'token-2' }), - createMockPosition({ id: 'third', outcomeTokenId: 'token-3' }), - ]; + it('disables cache sync when cacheAddress is missing', async () => { + const activePosition = createMockPosition({ + currentValue: 100, + cashPnl: 0, + percentPnl: 0, + }); + const priceUpdate = createMockPriceUpdate({ + tokenId: activePosition.outcomeTokenId, + bestBid: 0.6, + }); + mockUseLiveMarketPrices.mockReturnValue({ + prices: new Map([[activePosition.outcomeTokenId, priceUpdate]]), + isConnected: true, + lastUpdateTime: Date.now(), + }); - const { result } = renderHook(() => usePredictLivePositions(positions)); + const cachedPositions = [activePosition]; + const { queryClient } = renderLivePositionsHook( + [activePosition], + undefined, + cachedPositions, + ); - expect(result.current.livePositions[0].id).toBe('first'); - expect(result.current.livePositions[1].id).toBe('second'); - expect(result.current.livePositions[2].id).toBe('third'); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toBe(cachedPositions); + }); }); }); - describe('position data preservation', () => { - it('preserves all original position fields not related to value calculation', () => { - const position = createMockPosition({ - id: 'test-id', - providerId: 'test-provider', - marketId: 'test-market', - outcomeId: 'test-outcome', - outcome: 'Test Outcome', - title: 'Test Title', - icon: 'test-icon', - status: PredictPositionStatus.OPEN, - claimable: true, - endDate: '2025-06-15', - negRisk: true, - }); - const priceUpdate = createMockPriceUpdate({ tokenId: 'token-1' }); + describe('empty state', () => { + it('preserves position order in cache output', async () => { + const positions = [ + createMockPosition({ + id: 'first', + outcomeTokenId: 'token-1', + size: 100, + initialValue: 50, + }), + createMockPosition({ + id: 'second', + outcomeTokenId: 'token-2', + size: 200, + initialValue: 100, + }), + createMockPosition({ + id: 'third', + outcomeTokenId: 'token-3', + size: 300, + initialValue: 150, + }), + ]; + const pricesMap = new Map([ + [ + 'token-1', + createMockPriceUpdate({ tokenId: 'token-1', bestBid: 0.6 }), + ], + [ + 'token-2', + createMockPriceUpdate({ tokenId: 'token-2', bestBid: 0.7 }), + ], + [ + 'token-3', + createMockPriceUpdate({ tokenId: 'token-3', bestBid: 0.8 }), + ], + ]); mockUseLiveMarketPrices.mockReturnValue({ - prices: new Map([['token-1', priceUpdate]]), + prices: pricesMap, isConnected: true, lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); - - const livePosition = result.current.livePositions[0]; - expect(livePosition.id).toBe('test-id'); - expect(livePosition.providerId).toBe('test-provider'); - expect(livePosition.marketId).toBe('test-market'); - expect(livePosition.outcomeId).toBe('test-outcome'); - expect(livePosition.outcome).toBe('Test Outcome'); - expect(livePosition.title).toBe('Test Title'); - expect(livePosition.icon).toBe('test-icon'); - expect(livePosition.status).toBe(PredictPositionStatus.OPEN); - expect(livePosition.claimable).toBe(true); - expect(livePosition.endDate).toBe('2025-06-15'); - expect(livePosition.negRisk).toBe(true); + const { queryClient } = renderLivePositionsHook( + positions, + { cacheAddress: MOCK_ADDRESS }, + positions, + ); + + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].id).toBe('first'); + expect(cached?.[1].id).toBe('second'); + expect(cached?.[2].id).toBe('third'); + }); }); }); }); diff --git a/app/components/UI/Predict/hooks/usePredictLivePositions.ts b/app/components/UI/Predict/hooks/usePredictLivePositions.ts index 80530359d25..7878b3d39f0 100644 --- a/app/components/UI/Predict/hooks/usePredictLivePositions.ts +++ b/app/components/UI/Predict/hooks/usePredictLivePositions.ts @@ -1,64 +1,74 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useIsFocused } from '@react-navigation/native'; import { PredictPosition } from '../types'; +import { predictQueries } from '../queries'; import { useLiveMarketPrices } from './useLiveMarketPrices'; +/** + * Stable empty Map reference to avoid unnecessary useEffect cycles. + * When livePositionUpdates computes an empty Map, returning this constant + * preserves referential equality and prevents the cache-sync effect from firing. + */ +const EMPTY_POSITION_UPDATES = new Map< + string, + Pick +>(); + export interface UseLivePositionsOptions { /** * Whether to enable live price updates * @default true */ enabled?: boolean; -} - -export interface UseLivePositionsResult { - /** - * Positions with live-updated values based on current market prices - */ - livePositions: PredictPosition[]; - /** - * Whether the WebSocket connection is active - */ - isConnected: boolean; /** - * Timestamp of the last price update + * Address-scoped positions cache to sync live values into + * @internal */ - lastUpdateTime: number | null; + cacheAddress?: string; } /** - * Hook that takes positions and returns live-updated positions based on real-time market prices. - * - * Uses the bestBid price from live market data to calculate: - * - currentValue: size * bestBid (what you can sell for right now) - * - cashPnl: currentValue - initialValue (profit/loss) - * - percentPnl: ((currentValue - initialValue) / initialValue) * 100 + * Side-effect hook that subscribes to live market prices and syncs + * computed position values (currentValue, cashPnl, percentPnl, price) + * into the address-scoped positions query cache. * * @param positions - Array of positions to track (from usePredictPositions) - * @param options - Configuration options (enabled: boolean) - * @returns Live-updated positions, connection status, and last update timestamp + * @param options - Configuration options + * @internal Only consumed by usePredictPositions */ export const usePredictLivePositions = ( positions: PredictPosition[], options: UseLivePositionsOptions = {}, -): UseLivePositionsResult => { - const { enabled = true } = options; +): void => { + const { enabled = true, cacheAddress } = options; + const queryClient = useQueryClient(); + const isScreenFocused = useIsFocused(); const tokenIds = useMemo( - () => positions.map((position) => position.outcomeTokenId), + () => + positions + .filter((position) => !position.claimable) + .map((position) => position.outcomeTokenId), [positions], ); - const { prices, isConnected, lastUpdateTime } = useLiveMarketPrices( - tokenIds, - { enabled: enabled && positions.length > 0 }, - ); + const { prices } = useLiveMarketPrices(tokenIds, { + enabled: enabled && isScreenFocused && tokenIds.length > 0, + }); const livePositions = useMemo(() => { if (positions.length === 0) { return []; } - return positions.map((position) => { + let hasChanges = false; + + const nextPositions = positions.map((position) => { + if (position.claimable) { + return position; + } + const priceUpdate = prices.get(position.outcomeTokenId); if (!priceUpdate) { @@ -75,6 +85,17 @@ export const usePredictLivePositions = ( 100 : 0; + if ( + position.currentValue === liveCurrentValue && + position.cashPnl === liveCashPnl && + position.percentPnl === livePercentPnl && + position.price === bestBid + ) { + return position; + } + + hasChanges = true; + return { ...position, currentValue: liveCurrentValue, @@ -83,11 +104,90 @@ export const usePredictLivePositions = ( price: bestBid, }; }); + + return hasChanges ? nextPositions : positions; }, [positions, prices]); - return { - livePositions, - isConnected, - lastUpdateTime, - }; + const livePositionUpdates = useMemo(() => { + const updates = new Map< + string, + Pick + >(); + + livePositions.forEach((livePosition, index) => { + const originalPosition = positions[index]; + + if ( + !originalPosition || + originalPosition.id !== livePosition.id || + originalPosition === livePosition || + livePosition.claimable + ) { + return; + } + + updates.set(livePosition.id, { + currentValue: livePosition.currentValue, + cashPnl: livePosition.cashPnl, + percentPnl: livePosition.percentPnl, + price: livePosition.price, + }); + }); + + return updates.size > 0 ? updates : EMPTY_POSITION_UPDATES; + }, [livePositions, positions]); + + useEffect(() => { + if ( + !enabled || + !isScreenFocused || + !cacheAddress || + livePositionUpdates.size === 0 + ) { + return; + } + + queryClient.setQueryData( + predictQueries.positions.keys.byAddress(cacheAddress), + (cachedPositions) => { + if (!cachedPositions || cachedPositions.length === 0) { + return cachedPositions; + } + + let hasChanges = false; + + const nextPositions = cachedPositions.map((cachedPosition) => { + const livePositionUpdate = livePositionUpdates.get(cachedPosition.id); + + if (!livePositionUpdate || cachedPosition.claimable) { + return cachedPosition; + } + + if ( + cachedPosition.currentValue === livePositionUpdate.currentValue && + cachedPosition.cashPnl === livePositionUpdate.cashPnl && + cachedPosition.percentPnl === livePositionUpdate.percentPnl && + cachedPosition.price === livePositionUpdate.price + ) { + return cachedPosition; + } + + hasChanges = true; + + return { + ...cachedPosition, + ...livePositionUpdate, + }; + }); + + return hasChanges ? nextPositions : cachedPositions; + }, + ); + }, [ + cacheAddress, + enabled, + isScreenFocused, + livePositionUpdates, + queryClient, + ]); }; diff --git a/app/components/UI/Predict/hooks/usePredictPositions.test.ts b/app/components/UI/Predict/hooks/usePredictPositions.test.ts index 4c80ce6de69..5d94210e9f6 100644 --- a/app/components/UI/Predict/hooks/usePredictPositions.test.ts +++ b/app/components/UI/Predict/hooks/usePredictPositions.test.ts @@ -34,6 +34,10 @@ jest.mock('react-redux', () => ({ useSelector: (selector: () => unknown) => selector(), })); +jest.mock('./usePredictLivePositions', () => ({ + usePredictLivePositions: jest.fn(), +})); + const mockGetPositions = jest.fn< Promise, [{ address: string }] @@ -91,11 +95,16 @@ const createWrapper = () => { return { Wrapper, queryClient }; }; +const mockUsePredictLivePositions = jest.requireMock( + './usePredictLivePositions', +).usePredictLivePositions as jest.Mock; + describe('usePredictPositions', () => { beforeEach(() => { jest.clearAllMocks(); mockEnsurePolygonNetworkExists.mockResolvedValue(undefined); mockGetPositions.mockResolvedValue([]); + mockUsePredictLivePositions.mockImplementation(() => undefined); }); it('returns empty positions when query returns no positions', async () => { @@ -154,6 +163,13 @@ describe('usePredictPositions', () => { }); expect(result.current.data).toEqual([activePosition]); + expect(mockUsePredictLivePositions).toHaveBeenLastCalledWith( + [activePosition], + { + enabled: false, + cacheAddress: MOCK_ADDRESS, + }, + ); }); it('returns only claimable positions when claimable is true', async () => { @@ -176,6 +192,13 @@ describe('usePredictPositions', () => { }); expect(result.current.data).toEqual([claimablePosition]); + expect(mockUsePredictLivePositions).toHaveBeenLastCalledWith( + [claimablePosition], + { + enabled: false, + cacheAddress: MOCK_ADDRESS, + }, + ); }); it('filters positions by marketId', async () => { @@ -220,6 +243,10 @@ describe('usePredictPositions', () => { expect(mockGetPositions).not.toHaveBeenCalled(); expect(result.current.data).toBeUndefined(); expect(result.current.isFetching).toBe(false); + expect(mockUsePredictLivePositions).toHaveBeenLastCalledWith([], { + enabled: false, + cacheAddress: MOCK_ADDRESS, + }); }); it('returns query error message when query fails', async () => { @@ -371,4 +398,94 @@ describe('usePredictPositions', () => { expect(result.current.data).toEqual([parentPosition]); }); }); + + it('updates returned data through cache sync while keeping claimable rows unchanged', async () => { + const { Wrapper } = createWrapper(); + const activePosition = createPosition('active-cache-sync', { + claimable: false, + currentValue: 100, + cashPnl: 8, + percentPnl: 12, + }); + const claimablePosition = createPosition('claimable-cache-sync', { + claimable: true, + currentValue: 40, + cashPnl: 30, + percentPnl: 300, + marketId: 'market-claimable', + }); + mockGetPositions.mockResolvedValue([activePosition, claimablePosition]); + + mockUsePredictLivePositions.mockImplementation( + ( + positions: PredictPosition[], + options?: { enabled?: boolean; cacheAddress?: string }, + ) => { + const { useEffect } = jest.requireActual('react'); + const { useQueryClient } = jest.requireActual('@tanstack/react-query'); + const queryClient = useQueryClient(); + + useEffect(() => { + if (!options?.enabled || !options.cacheAddress) { + return; + } + + queryClient.setQueryData( + ['predict', 'positions', options.cacheAddress], + (cachedPositions: PredictPosition[] | undefined) => { + if (!cachedPositions) { + return cachedPositions; + } + + let hasChanges = false; + + const nextPositions = cachedPositions.map( + (position: PredictPosition) => { + if (position.id !== activePosition.id || position.claimable) { + return position; + } + + if ( + position.currentValue === 150 && + position.cashPnl === 58 && + position.percentPnl === 63 + ) { + return position; + } + + hasChanges = true; + + return { + ...position, + currentValue: 150, + cashPnl: 58, + percentPnl: 63, + }; + }, + ); + + return hasChanges ? nextPositions : cachedPositions; + }, + ); + }, [options?.cacheAddress, options?.enabled, queryClient, positions]); + }, + ); + + const { result } = renderHook( + () => usePredictPositions({ livePriceUpdates: true }), + { wrapper: Wrapper }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual([ + expect.objectContaining({ + id: activePosition.id, + currentValue: 150, + cashPnl: 58, + percentPnl: 63, + }), + claimablePosition, + ]); + }); + }); }); diff --git a/app/components/UI/Predict/hooks/usePredictPositions.ts b/app/components/UI/Predict/hooks/usePredictPositions.ts index ca1ce9fd36e..ac3e7ca5ef8 100644 --- a/app/components/UI/Predict/hooks/usePredictPositions.ts +++ b/app/components/UI/Predict/hooks/usePredictPositions.ts @@ -3,11 +3,13 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import type { PredictPosition } from '../types'; import { usePredictNetworkManagement } from './usePredictNetworkManagement'; +import { usePredictLivePositions } from './usePredictLivePositions'; import { getEvmAccountFromSelectedAccountGroup } from '../utils/accounts'; import { predictQueries } from '../queries'; import { selectSelectedAccountGroupId } from '../../../../selectors/multichainAccounts/accountTreeController'; const OPTIMISTIC_POLL_INTERVAL = 2_000; +const EMPTY_POSITIONS: PredictPosition[] = []; interface UsePredictPositionsOptions { enabled?: boolean; @@ -15,6 +17,7 @@ interface UsePredictPositionsOptions { claimable?: boolean; marketId?: string; childMarketIds?: string[]; + livePriceUpdates?: boolean; } function buildSelect( @@ -51,6 +54,7 @@ export function usePredictPositions(options: UsePredictPositionsOptions = {}) { claimable, marketId, childMarketIds, + livePriceUpdates = false, } = options; const { ensurePolygonNetworkExists } = usePredictNetworkManagement(); @@ -74,7 +78,7 @@ export function usePredictPositions(options: UsePredictPositionsOptions = {}) { (p: PredictPosition) => p.optimistic, ); - return useQuery({ + const query = useQuery({ ...queryOpts, enabled, refetchInterval: hasOptimistic @@ -82,4 +86,11 @@ export function usePredictPositions(options: UsePredictPositionsOptions = {}) { : (refetchInterval ?? false), select: buildSelect(claimable, marketId, childMarketIds), }); + + usePredictLivePositions(query.data ?? EMPTY_POSITIONS, { + enabled: enabled && livePriceUpdates, + cacheAddress: address, + }); + + return query; } diff --git a/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts b/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts index 9ded1b0bd71..775b51b8b02 100644 --- a/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts +++ b/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts @@ -525,6 +525,58 @@ describe('WebSocketManager', () => { }), ); }); + + it('does not unsubscribe overlapping tokens still needed by another subscription', () => { + const manager = WebSocketManager.getInstance(); + const homepageCallback = jest.fn(); + const marketDetailsCallback = jest.fn(); + + manager.subscribeToMarketPrices(['token1', 'token2'], homepageCallback); + const unsubscribeMarketDetails = manager.subscribeToMarketPrices( + ['token1'], + marketDetailsCallback, + ); + mockWebSocketInstances[0].simulateOpen(); + mockWebSocketInstances[0].send.mockClear(); + + unsubscribeMarketDetails(); + + expect(mockWebSocketInstances[0].send).not.toHaveBeenCalledWith( + JSON.stringify({ + operation: 'unsubscribe', + assets_ids: ['token1'], + }), + ); + }); + + it('only unsubscribes tokens no longer needed by remaining subscriptions', () => { + const manager = WebSocketManager.getInstance(); + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + manager.subscribeToMarketPrices(['token1', 'token2'], callback1); + const unsubscribe = manager.subscribeToMarketPrices( + ['token2', 'token3'], + callback2, + ); + mockWebSocketInstances[0].simulateOpen(); + mockWebSocketInstances[0].send.mockClear(); + + unsubscribe(); + + expect(mockWebSocketInstances[0].send).toHaveBeenCalledWith( + JSON.stringify({ + operation: 'unsubscribe', + assets_ids: ['token3'], + }), + ); + expect(mockWebSocketInstances[0].send).not.toHaveBeenCalledWith( + JSON.stringify({ + operation: 'unsubscribe', + assets_ids: ['token2', 'token3'], + }), + ); + }); }); describe('crypto price subscriptions', () => { diff --git a/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts b/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts index 32854b837a1..935e7c2628d 100644 --- a/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts +++ b/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts @@ -306,7 +306,14 @@ export class WebSocketManager { callbacks.delete(callback); if (callbacks.size === 0) { this.priceSubscriptions.delete(subscriptionKey); - this.sendMarketUnsubscribe(tokenIds); + const remainingTokenIds = this.getSubscribedMarketTokenIds(); + const tokenIdsToUnsubscribe = tokenIds.filter( + (tokenId) => !remainingTokenIds.has(tokenId), + ); + + if (tokenIdsToUnsubscribe.length > 0) { + this.sendMarketUnsubscribe(tokenIdsToUnsubscribe); + } } } @@ -449,12 +456,23 @@ export class WebSocketManager { ); } - private resubscribeAllMarkets(): void { - const allTokenIds = new Set(); + private getSubscribedMarketTokenIds(): Set { + const subscribedTokenIds = new Set(); + this.priceSubscriptions.forEach((_, key) => { - key.split(',').forEach((id) => allTokenIds.add(id)); + key.split(',').forEach((tokenId) => { + if (tokenId) { + subscribedTokenIds.add(tokenId); + } + }); }); + return subscribedTokenIds; + } + + private resubscribeAllMarkets(): void { + const allTokenIds = this.getSubscribedMarketTokenIds(); + if (allTokenIds.size > 0) { this.sendMarketSubscribe(Array.from(allTokenIds)); } diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index 851ce1c2098..de562391a54 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -211,6 +211,10 @@ jest.mock('../../hooks/usePredictPositions', () => ({ })), })); +jest.mock('../../hooks/usePredictLivePositions', () => ({ + usePredictLivePositions: jest.fn(), +})); + jest.mock('../../hooks/usePredictBalance', () => ({ usePredictBalance: jest.fn(() => ({ data: 100, @@ -562,6 +566,9 @@ function setupPredictMarketDetailsTest( const { usePredictPositions } = jest.requireMock( '../../hooks/usePredictPositions', ); + const { usePredictLivePositions } = jest.requireMock( + '../../hooks/usePredictLivePositions', + ); const { usePredictEligibility } = jest.requireMock( '../../hooks/usePredictEligibility', ); @@ -624,6 +631,7 @@ function setupPredictMarketDetailsTest( ({ claimable }: { claimable?: boolean }) => claimable ? claimablePositionsHook : activePositionsHook, ); + usePredictLivePositions.mockImplementation(() => undefined); // Set up usePredictOrderPreview mock to return preview data matching position currentValue mockUsePredictOrderPreview.mockImplementation( diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index 334846bcc7d..75999c08629 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -129,6 +129,7 @@ const PredictMarketDetails: React.FC = () => { childMarketIds: market?.childMarketIds, claimable: false, enabled: !isMarketLoading && Boolean(resolvedMarketId), + livePriceUpdates: true, }); // "claimable" positions diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx index 51afe896083..2e72d424395 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx @@ -314,6 +314,41 @@ describe('PredictionsSection', () => { }); }); + it('renders the current active position values from the hook data', async () => { + mockUsePredictPositionsForHomepage.mockImplementation( + ({ + claimable = false, + }: { maxPositions?: number; claimable?: boolean } = {}) => ({ + positions: claimable + ? [] + : [ + { + ...mockActivePositions[0], + currentValue: 99, + percentPnl: 890, + }, + mockActivePositions[1], + ], + isLoading: false, + error: null, + totalClaimableValue: 0, + refetch: jest.fn(), + }), + ); + + renderWithProvider( + , + ); + + await waitFor(() => { + expect(screen.getByText('Test Position 1')).toBeOnTheScreen(); + }); + + expect(screen.getByText('$99')).toBeOnTheScreen(); + expect(screen.getByText('890%')).toBeOnTheScreen(); + expect(screen.queryByText('$12')).not.toBeOnTheScreen(); + }); + it('shows position skeletons when loading positions', () => { mockUsePredictPositionsForHomepage.mockImplementation( ({ diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.test.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.test.ts index b70836a0ed3..87a7e68db8f 100644 --- a/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.test.ts +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.test.ts @@ -3,6 +3,7 @@ import { usePredictPositionsForHomepage } from './usePredictPositionsForHomepage import type { PredictPosition } from '../../../../../UI/Predict/types'; const mockRefetch = jest.fn().mockResolvedValue(undefined); +const mockUsePredictPositions = jest.fn(); let mockUsePredictPositionsReturn: { data: PredictPosition[] | undefined; isLoading: boolean; @@ -16,7 +17,12 @@ let mockUsePredictPositionsReturn: { }; jest.mock('../../../../../UI/Predict/hooks/usePredictPositions', () => ({ - usePredictPositions: () => mockUsePredictPositionsReturn, + usePredictPositions: ( + ...args: Parameters + ) => { + mockUsePredictPositions(...args); + return mockUsePredictPositionsReturn; + }, })); const createMockPosition = (id: string, currentValue = 12): PredictPosition => @@ -36,6 +42,7 @@ const createMockPosition = (id: string, currentValue = 12): PredictPosition => describe('usePredictPositionsForHomepage', () => { beforeEach(() => { jest.clearAllMocks(); + mockUsePredictPositions.mockClear(); mockUsePredictPositionsReturn = { data: [ createMockPosition('1'), @@ -157,6 +164,28 @@ describe('usePredictPositionsForHomepage', () => { expect(result.current.totalClaimableValue).toBe(0); }); + it('enables live updates for active positions', () => { + renderHook(() => usePredictPositionsForHomepage({ claimable: false })); + + expect(mockUsePredictPositions).toHaveBeenCalledWith( + expect.objectContaining({ + claimable: false, + livePriceUpdates: true, + }), + ); + }); + + it('disables live updates for claimable positions', () => { + renderHook(() => usePredictPositionsForHomepage({ claimable: true })); + + expect(mockUsePredictPositions).toHaveBeenCalledWith( + expect.objectContaining({ + claimable: true, + livePriceUpdates: false, + }), + ); + }); + it('treats undefined currentValue as 0 in totalClaimableValue sum', () => { mockUsePredictPositionsReturn.data = [ { diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.ts index 8b47633ad2b..2967e5343d0 100644 --- a/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.ts +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.ts @@ -32,6 +32,7 @@ export const usePredictPositionsForHomepage = ( const { data, isLoading, error, refetch } = usePredictPositions({ claimable, enabled, + livePriceUpdates: !claimable, }); const allPositions = useMemo(() => data ?? [], [data]); diff --git a/tests/component-view/mocks.ts b/tests/component-view/mocks.ts index 73694f2bb1e..345e9c7b445 100644 --- a/tests/component-view/mocks.ts +++ b/tests/component-view/mocks.ts @@ -187,6 +187,8 @@ jest.mock('../../app/core/Engine', () => { getBalance: jest.fn().mockResolvedValue(0), getPositions: jest.fn().mockResolvedValue([]), getPrices: jest.fn().mockResolvedValue({ providerId: '', results: [] }), + subscribeToMarketPrices: jest.fn(() => () => undefined), + getConnectionStatus: jest.fn(() => ({ marketConnected: false })), trackFeedViewed: jest.fn(), trackTabChanged: jest.fn(), trackMarketDetailsOpened: jest.fn(), From c2e75318adb128d1f1f0167ded412cf1ea33b6ea Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Wed, 6 May 2026 09:29:27 -0300 Subject: [PATCH 03/27] feat(card): update cashback to mUSD back on Card feature (#29683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Updates Card feature user-facing terminology from "cashback" to "mUSD back" to align with product language. **Why:** The term "cashback" was used inconsistently and doesn't reflect the actual reward mechanism (mUSD tokens). **What changed:** 11 locale strings in the English translation file were updated. Internal code (variable names, function names, routes, types) remains unchanged to maintain flexibility if the reward token changes in the future. ## **Changelog** CHANGELOG entry: Updated Card feature to display "mUSD back" instead of "cashback" in all user-facing text ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Card mUSD Back terminology Scenario: User views MetaMask Card promotional content Given user is on a screen showing MetaMask Card benefits When user views the card benefits Then user sees "mUSD back" terminology instead of "cashback" And user sees percentages displayed as "X% mUSD back" Scenario: User views Card Home manage options Given user has a MetaMask Card linked And user is on the Card Home screen When user views the manage card options Then user sees "mUSD Back" as the rewards option title And user sees "Earn X% mUSD back on all spending" as the description Scenario: User views mUSD Back withdrawal screen Given user has available mUSD back rewards And user navigates to the mUSD Back screen When user views the screen Then user sees "mUSD Back" as the screen title And user sees "Available mUSD back" label And user sees "No mUSD back available" when balance is zero ``` ## **Screenshots/Recordings** ### **Before** N/A - Text-only changes in locale file. Strings previously displayed "cashback", "Cashback", "cash back" variants. ### **After** image Simulator Screenshot - iPhone 17 Pro
- 2026-05-04 at 15 31 05 Simulator Screenshot - iPhone 17 Pro
- 2026-05-04 at 15 31 16 **Updated strings:** | Location | New Text | |----------|----------| | Card promo | "{{percentage}}% mUSD back" | | Card link bullet | "Up to 3% mUSD back" | | Benefits section | "1-3% mUSD back" | | Manage options title | "mUSD Back" | | Manage options desc | "Earn 1% mUSD back on all spending" | | Manage options desc (metal) | "Earn 3% mUSD back on all spending" | | Rewards screen title | "mUSD Back" | | Available balance label | "Available mUSD back" | | Empty state | "No mUSD back available" | | Loading error | "Failed to load mUSD back. Please try again." | | Funding required | "...before redeeming mUSD back." | ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - N/A - locale string changes only, no runtime impact - [ ] I've tested with a power user scenario - N/A - locale string changes only - [ ] I've instrumented key operations with Sentry traces for production performance metrics - N/A - no new operations added ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk copy-only change: updates English locale strings and a couple of UI tests to reflect new wording, with no logic, API, or data-flow changes. > > **Overview** > Updates MetaMask Card user-facing copy to use **“mUSD back”** terminology instead of **“cashback”** across card marketing, benefits, manage options, and the rewards/withdrawal screen via `locales/languages/en.json`. > > Adjusts related UI tests (`Cashback.test.tsx`, `MoneyWhatYouGet.test.tsx`) to assert the new strings (e.g., “Available mUSD”, “1-3% mUSD back”). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 663913a34569a994da6e758e836bee40591b124a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../UI/Card/Views/Cashback/Cashback.test.tsx | 4 +-- .../MoneyWhatYouGet/MoneyWhatYouGet.test.tsx | 2 +- locales/languages/en.json | 34 +++++++++---------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/app/components/UI/Card/Views/Cashback/Cashback.test.tsx b/app/components/UI/Card/Views/Cashback/Cashback.test.tsx index ee0c465ba45..eda6842fcd9 100644 --- a/app/components/UI/Card/Views/Cashback/Cashback.test.tsx +++ b/app/components/UI/Card/Views/Cashback/Cashback.test.tsx @@ -60,7 +60,7 @@ jest.mock('../../../../../util/theme', () => { jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => { const translations: Record = { - 'card.cashback_screen.available_cashback': 'Available cashback', + 'card.cashback_screen.available_cashback': 'Available mUSD', 'card.cashback_screen.network_fee': 'Network fee', 'card.cashback_screen.expected_to_receive': 'Expected to receive', 'card.cashback_screen.withdraw': 'Withdraw', @@ -221,7 +221,7 @@ describe('Cashback Component', () => { render(); expect(screen.getByTestId(CashbackSelectors.CONTAINER)).toBeOnTheScreen(); - expect(screen.queryByText('Available cashback')).toBeOnTheScreen(); + expect(screen.queryByText('Available mUSD')).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.test.tsx b/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.test.tsx index f9a9af60cba..a89016af816 100644 --- a/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.test.tsx +++ b/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.test.tsx @@ -27,7 +27,7 @@ describe('MoneyWhatYouGet', () => { expect(container).toHaveTextContent(/Auto-earn/); expect(container).toHaveTextContent(/dollar-backed stablecoin/); expect(container).toHaveTextContent(/Get full liquidity/); - expect(container).toHaveTextContent(/1-3% cashback/); + expect(container).toHaveTextContent(/1-3% mUSD back/); expect(container).toHaveTextContent( /Transfer money to any of your wallets/, ); diff --git a/locales/languages/en.json b/locales/languages/en.json index 7ef2b7ed478..dd767d04837 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6569,11 +6569,11 @@ "subtitle": "Spend your money anywhere.", "virtual_card": "Virtual card", "metal_card": "Metal card", - "cashback": "{{percentage}}% cashback", + "cashback": "{{percentage}}% mUSD back", "get_now": "Get now", "link_title": "Link MetaMask Card", "link_subtitle": "Spend your Money balance and earn.", - "link_bullet_cashback": "Up to 3% cash back", + "link_bullet_cashback": "Up to 3% mUSD back", "link_bullet_apy": "Up to {{apy}}% APY", "link_card": "Link card" }, @@ -6583,7 +6583,7 @@ "benefit_dollar_backed": "Keep your money secure in mUSD, a 1:1 dollar-backed stablecoin", "benefit_liquidity": "Get full liquidity with no lockups, so you can trade or withdraw anytime", "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", - "benefit_spend_cashback": "1-3% cashback", + "benefit_spend_cashback": "1-3% mUSD back", "benefit_transfer": "Transfer money to any of your wallets across MetaMask", "benefit_global": "Send and receive money globally", "learn_more": "Learn more" @@ -6684,7 +6684,7 @@ "faq_q7": "Does the APY rate change?", "faq_q8": "Is this a savings account or a spending account?", "faq_q9": "Who controls my money?", - "faq_q10": "What cash back do I get with the MetaMask Card?", + "faq_q10": "What mUSD back do I get with the MetaMask Card?", "sounds_good": "Sounds good" } }, @@ -7620,17 +7620,17 @@ "price": "Free", "feature_1": "Virtual card for Apple Pay and Google Pay", "feature_2": "Pay with crypto (USDC, USDT, WETH, and more)", - "feature_3": "1% USDC cashback on every purchase" + "feature_3": "1% mUSD back on every purchase" }, "metal_card": { "name": "Metal Card", "price": "$199/year", "everything_in_virtual": "Everything in virtual, plus:", "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "feature_2": "3% mUSD back on first $10,000/year", "feature_3": "No foreign transaction fees" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", + "earn_up_to_badge": "Earn up to $300 in mUSD back annually", "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { @@ -7651,7 +7651,7 @@ "order_completed": { "title": "YOUR CARD\nIS ORDERED", "subtitle": "It should arrive in 4 to 6 weeks.", - "description": "Set up your virtual card and add it to your digital wallet to start earning cashback.", + "description": "Set up your virtual card and add it to your digital wallet to start earning mUSD back.", "set_up_card_button": "Set up card", "back_to_card_button": "Back to Card" }, @@ -7670,7 +7670,7 @@ }, "card_onboarding": { "title": "Spend\nand Earn", - "description": "The MetaMask Card is the fast and\neasy way to spend your crypto and\nearn up to 3% cashback.", + "description": "The MetaMask Card is the fast and\neasy way to spend your crypto and\nearn up to 3% mUSD back.", "apply_now_button": "Set up now", "login_button": "Log in", "not_now_button": "Not now", @@ -7952,9 +7952,9 @@ "card_tos_title": "Terms and conditions", "order_metal_card": "Metal Card", "order_metal_card_description": "Order your physical Metal Card now", - "cashback": "Cashback", - "cashback_description": "Earn 1% back on all spending", - "cashback_description_metal": "Earn 3% back on all spending", + "cashback": "mUSD Back", + "cashback_description": "Earn 1% mUSD back on all spending", + "cashback_description_metal": "Earn 3% mUSD back on all spending", "freeze_card": "Freeze card", "unfreeze_card": "Unfreeze card", "freeze_card_description": "Pause all spending on your card", @@ -7996,8 +7996,8 @@ "token_label": "Token" }, "cashback_screen": { - "title": "Cashback", - "available_cashback": "Available cashback", + "title": "mUSD Back", + "available_cashback": "Available mUSD", "network_fee": "Network fee", "expected_to_receive": "Expected to receive", "withdraw": "Withdraw", @@ -8005,11 +8005,11 @@ "withdrawal_initiated": "Withdrawal has been initiated", "withdrawal_success": "Withdrawal completed successfully", "withdrawal_failed": "Withdrawal failed. Please try again.", - "no_cashback": "No cashback available", - "loading_error": "Failed to load cashback. Please try again.", + "no_cashback": "No mUSD back available", + "loading_error": "Failed to load mUSD back. Please try again.", "funding_required": { "title": "Set up Linea funding", - "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "description": "You need at least one approved funding source on Linea before redeeming mUSD back.", "confirm_button_label": "Set up funding" } }, From 48636514912f9e932c59383f6afd95f275d9261e Mon Sep 17 00:00:00 2001 From: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> Date: Wed, 6 May 2026 08:43:09 -0400 Subject: [PATCH 04/27] refactor(accounts): migrate wallet AccountSelector to design system (#29598) ## **Description** migrate the wallet **AccountSelector** screen (`app/components/Views/AccountSelector`) to `@metamask/design-system-react-native` (BottomSheet, Button, Text). ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUL-1685 ## **Manual testing steps** - Open the "Account List" and check that there aren't any UI regressions ## **Screenshots/Recordings** https://github.com/user-attachments/assets/460303fc-20e0-4eb6-83be-d32c698ec6ef ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Medium risk because this refactors a user-facing modal/sheet UI, removes the existing reanimated slide/backdrop behavior, and changes safe-area/keyboard offset handling, which could introduce visual regressions or layout issues. > > **Overview** > Migrates `AccountSelector` UI from custom `StyleSheet`/legacy component-library `Button`+`Text` and reanimated backdrop/slide-in animation to design-system `Box`/`Button`/`Text` with Tailwind classes, deleting `AccountSelector.styles.ts`. > > Simplifies close behavior to a direct `navigation.goBack()` and adjusts `KeyboardAvoidingView` positioning using `useSafeAreaFrame`/insets; also changes Sentry tracing to start in `useLayoutEffect` and end after paint instead of tying trace completion to the removed animation callback. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4b27c5869c2dbaba56ec47929f79f9c55879c66e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Cursor --- .../AccountSelector/AccountSelector.styles.ts | 45 ---- .../Views/AccountSelector/AccountSelector.tsx | 251 +++++++----------- 2 files changed, 95 insertions(+), 201 deletions(-) delete mode 100644 app/components/Views/AccountSelector/AccountSelector.styles.ts diff --git a/app/components/Views/AccountSelector/AccountSelector.styles.ts b/app/components/Views/AccountSelector/AccountSelector.styles.ts deleted file mode 100644 index f2566f57c3e..00000000000 --- a/app/components/Views/AccountSelector/AccountSelector.styles.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { Theme } from '../../../util/theme/models'; -import { colors as importedColors } from '../../../styles/common'; - -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - const { colors } = theme; - - return StyleSheet.create({ - accountSelectorFooterContent: { - paddingHorizontal: 16, - paddingTop: 24, - // Extra space above safe-area inset so the footer actions are not flush with the screen edge - paddingBottom: 20, - }, - backdrop: { - ...StyleSheet.absoluteFillObject, - backgroundColor: colors.overlay.default, - }, - keyboardAvoidingView: { - flex: 1, - backgroundColor: importedColors.transparent, - }, - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - addWalletModalContainer: { - flex: 1, - backgroundColor: colors.background.default, - }, - accountSelectorFooter: { - flexDirection: 'row', - }, - footerButton: { - flex: 1, - }, - footerButtonSubsequent: { - flex: 1, - marginLeft: 16, - }, - }); -}; - -export default styleSheet; diff --git a/app/components/Views/AccountSelector/AccountSelector.tsx b/app/components/Views/AccountSelector/AccountSelector.tsx index de18e3297b0..f4c18b90492 100644 --- a/app/components/Views/AccountSelector/AccountSelector.tsx +++ b/app/components/Views/AccountSelector/AccountSelector.tsx @@ -8,24 +8,29 @@ import React, { useState, } from 'react'; import { - KeyboardAvoidingView, - Platform, ActivityIndicator, + KeyboardAvoidingView, Modal, - useWindowDimensions, - View, + Platform, } from 'react-native'; import { StackActions, useNavigation } from '@react-navigation/native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - withSpring, - runOnJS, - useDerivedValue, - interpolate, -} from 'react-native-reanimated'; +import { + useSafeAreaFrame, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Button, + ButtonSize, + ButtonVariant, + FontWeight, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; // External dependencies. import MultichainAccountSelectorList from '../../../component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList'; @@ -36,13 +41,6 @@ import { store } from '../../../store'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { strings } from '../../../../locales/i18n'; import { useAccounts } from '../../hooks/useAccounts'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../component-library/components/Buttons/Button'; -import { TextVariant } from '../../../component-library/components/Texts/Text'; -import Text from '../../../component-library/components/Texts/Text/Text'; import AddAccountActions from '../AddAccountActions'; import { AccountListBottomSheetSelectorsIDs } from './AccountListBottomSheet.testIds'; import { CommonSelectorsIDs } from '../../../util/Common.testIds'; @@ -50,12 +48,10 @@ import { selectSelectedAccountGroup } from '../../../selectors/multichainAccount import { AccountGroupObject } from '@metamask/account-tree-controller'; // Internal dependencies. -import { useStyles } from '../../../component-library/hooks'; import { AccountSelectorProps, AccountSelectorScreens, } from './AccountSelector.types'; -import styleSheet from './AccountSelector.styles'; import { useDispatch, useSelector } from 'react-redux'; import { setReloadAccounts } from '../../../actions/accounts'; import { RootState } from '../../../reducers'; @@ -67,24 +63,16 @@ import { trace, } from '../../../util/trace'; import { getTraceTags } from '../../../util/sentry/tags'; -import { ButtonProps } from '../../../component-library/components/Buttons/Button/Button.types'; import { useSyncSRPs } from '../../hooks/useSyncSRPs'; import { useAccountsOperationsLoadingStates } from '../../../util/accounts/useAccountsOperationsLoadingStates'; -import { Box } from '../../UI/Box/Box'; -import { - AlignItems, - FlexDirection, - JustifyContent, -} from '../../UI/Box/box.types'; -import { AnimationDuration } from '../../../component-library/constants/animation.constants'; import Routes from '../../../constants/navigation/Routes'; const AccountSelector = ({ route }: AccountSelectorProps) => { - const { styles } = useStyles(styleSheet, {}); + const tw = useTailwind(); const dispatch = useDispatch(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { width: screenWidth } = useWindowDimensions(); + const { y: frameY } = useSafeAreaFrame(); const { trackEvent, createEventBuilder } = useAnalytics(); const routeParams = useMemo(() => route?.params, [route?.params]); @@ -148,18 +136,14 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { } }, [navigation, shouldRedirectToAddWallet]); - // Tracing for the account list rendering: const isAccountSelector = useMemo( () => screen === AccountSelectorScreens.AccountSelector, [screen], ); - const translateX = useSharedValue(screenWidth); - - // Backdrop opacity animation - fades in as screen slides in from right - const backdropOpacity = useDerivedValue(() => - interpolate(translateX.value, [screenWidth, 0], [0, 0.5]), - ); + const handleClose = useCallback(() => { + navigation.goBack(); + }, [navigation]); useEffect(() => { if (reloadAccounts) { @@ -167,45 +151,38 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { } }, [dispatch, reloadAccounts]); + // Tracing for the account list: start at layout flush, end after paint (useEffect). useLayoutEffect(() => { - if (!isAccountSelector) return; - - const onAnimationComplete = () => { + if (!isAccountSelector) { + return undefined; + } + trace({ + name: TraceName.ShowAccountList, + op: TraceOperation.AccountUi, + tags: getTraceTags(store.getState()), + }); + return () => { endTrace({ name: TraceName.ShowAccountList, }); }; - - translateX.value = withSpring( - 0, - { - damping: 20, - stiffness: 500, - mass: 0.3, - }, - () => runOnJS(onAnimationComplete)(), - ); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAccountSelector]); - const closeModal = useCallback(() => { - const onCloseComplete = () => { - navigation.goBack(); - }; - - translateX.value = withTiming( - screenWidth, - { duration: AnimationDuration.Fast }, - () => runOnJS(onCloseComplete)(), - ); - }, [translateX, navigation, screenWidth]); + useEffect(() => { + if (!isAccountSelector) { + return; + } + endTrace({ + name: TraceName.ShowAccountList, + }); + }, [isAccountSelector]); const _onSelectMultichainAccount = useCallback( (accountGroup: AccountGroupObject) => { Engine.context.AccountTreeController.setSelectedAccountGroup( accountGroup.id, ); - closeModal(); + handleClose(); trackEvent( createEventBuilder(MetaMetricsEvents.SWITCHED_ACCOUNT) @@ -216,7 +193,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { .build(), ); }, - [accounts?.length, trackEvent, createEventBuilder, closeModal], + [accounts?.length, trackEvent, createEventBuilder, handleClose], ); const handleAddAccount = useCallback(() => { @@ -227,43 +204,6 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { setScreen(AccountSelectorScreens.AccountSelector); }, []); - // Tracing for the account list rendering: - useEffect(() => { - if (isAccountSelector) { - trace({ - name: TraceName.ShowAccountList, - op: TraceOperation.AccountUi, - tags: getTraceTags(store.getState()), - }); - // Trace ends in animation callback - } - }, [isAccountSelector]); - - const addAccountButtonProps: ButtonProps[] = useMemo( - () => [ - { - variant: ButtonVariants.Secondary, - isDisabled: isAccountSyncingInProgress, - label: ( - - {isAccountSyncingInProgress && } - {buttonLabel} - - ), - size: ButtonSize.Lg, - width: ButtonWidthTypes.Full, - onPress: handleAddAccount, - testID: AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ADD_BUTTON_ID, - }, - ], - [handleAddAccount, buttonLabel, isAccountSyncingInProgress], - ); - const renderAccountSelector = useCallback( () => ( @@ -277,24 +217,38 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { /> ) : null} {!disableAddAccountButton && ( - - {addAccountButtonProps.map((buttonProp, index) => ( - + )} ), @@ -302,11 +256,9 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { selectedAccountGroup, _onSelectMultichainAccount, disableAddAccountButton, - addAccountButtonProps, - styles.accountSelectorFooterContent, - styles.accountSelectorFooter, - styles.footerButton, - styles.footerButtonSubsequent, + handleAddAccount, + buttonLabel, + isAccountSyncingInProgress, ], ); @@ -320,14 +272,6 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { [handleBackToSelector], ); - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: translateX.value }], - })); - - const backdropStyle = useAnimatedStyle(() => ({ - opacity: backdropOpacity.value, - })); - const showAddWalletModal = screen === AccountSelectorScreens.AddAccountActions || screen === AccountSelectorScreens.MultichainAddWalletActions; @@ -338,31 +282,28 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { return ( <> - - {renderAccountSelector()} - + { onRequestClose={handleBackToSelector} > {showAddWalletModal ? ( - { {screen === AccountSelectorScreens.AddAccountActions ? renderAddAccountActions() : renderMultichainAddWalletActions()} - + ) : null} From b14edaefe0ac4d5acc9a2d1dcb10ef5cbf465715 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 6 May 2026 15:20:08 +0200 Subject: [PATCH 05/27] chore: add advanced charts traces cp-7.76.0 (#29497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds Sentry performance traces for token-overview advanced chart visibility (time from series change until skeleton clears), with separate trace types for dashboards to distinguish initial load / asset or currency changes from time-range-only updates. Also fixes a bug in metrics consent detection during early Sentry initialization. ## Changes ### Advanced Chart Performance Tracing **AdvancedChart component:** - Added optional `onSkeletonHidden` callback that fires once when loading/layout settles and the chart is ready - Guarded by refs to reset when series key or HTML content changes **PriceAdvanced component:** - Starts `trace()` when `ohlcvSeriesKey` changes - Ends trace via `endTrace()` when: - Skeleton hides (chart ready) - Chart error occurs - Trace is superseded by a newer series - Falls back to legacy chart - `getAdvancedChartVisibilityTraceRequest()` selects appropriate `TraceName` and `TraceOperation` based on what changed: - Same asset + currency with only range change → `TokenOverviewAdvancedChartTimeRangeVisible` trace - Otherwise → `TokenOverviewAdvancedChartInitialVisible` trace **trace.ts:** - Added new `TraceName` values: - `TokenOverviewAdvancedChartInitialVisible` - for initial load or asset/currency changes - `TokenOverviewAdvancedChartTimeRangeVisible` - for time range selector changes only - Added new `TraceOperation` values: - `token_overview.advanced_chart` - initial load or asset/currency change - `token_overview.advanced_chart_time_range` - time range change only ### Metrics Consent Fix **Problem:** The original implementation attempted to read from `analytics.isEnabled()` during Sentry initialization in `index.js:45`. However, this occurs before Engine and Redux are initialized, causing `analytics.isEnabled()` to incorrectly return `false` even for opted-in users (it checks in-memory state that doesn't exist yet). **Solution:** `hasMetricsConsent()` now reads directly from AnalyticsController's persisted state in FilesystemStorage (`persist:AnalyticsController`) before falling back to the legacy `METRICS_OPT_IN` storage key. This approach: - Works before Engine/Redux initialization (FilesystemStorage is always available) - Reads the actual persisted opt-in value from disk (`{"optedIn": true/false}`) - Follows the same pattern as `ControllerStorage.getAllPersistedState()` in `persistConfig/index.ts` - Avoids dependency on deprecated `METRICS_OPT_IN` as the primary source **MetaMetricsAndDataCollectionSection:** - Calls `updateCachedConsent()` when user toggles analytics participation - Ensures in-memory consent cache stays synchronized with AnalyticsController state ## **Changelog** CHANGELOG entry: Adds tracing for advanced charts ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3129 ## **Manual testing steps** ```gherkin Feature: Advanced Chart Performance Tracing Scenario: user opens token details and chart loads successfully Given the user is on the home screen When user taps on a token to open token details Then a single "Starting sampled root span" log appears for "Token Overview Advanced Chart Initial Visible" And a single "Finishing" log appears for the same span ID And no duplicate "Token Overview" traces appear Scenario: user navigates back before chart finishes loading Given the user is on token details with the chart still loading (skeleton visible) When user quickly navigates back to the home screen Then a "Finishing" log appears for the "Token Overview Advanced Chart Initial Visible" span And the finish log appears after the navigation back (after "[UserInteraction]" log) Scenario: user navigates back after chart has loaded Given the user is on token details and the chart has fully loaded When user navigates back to the home screen Then no "Token Overview" trace logs appear during navigation back Scenario: user changes time range on the chart Given the user is on token details with the chart loaded on 1D range When user taps on the 1W time range selector Then a "Finishing" log appears for the previous "Token Overview Advanced Chart Initial Visible" span And a new "Starting sampled root span" log appears for "Token Overview Advanced Chart Time Range Visible" And a "Finishing" log appears for the new span when the chart reloads ``` ``` How to verify locally: 1. Enable Sentry trace logging by filtering console output for "Tracing" 2. Navigate to a token's details screen 3. Confirm you see exactly ONE "Starting sampled root span" for "Token Overview Advanced Chart Initial Visible" followed by ONE "Finishing" log for the same span ID 4. Navigate back — confirm no additional trace start/finish for that span 5. Re-enter the token, change time range — confirm the old trace ends with superseded and a new "Time Range Visible" trace starts and finishes ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Adds new Sentry tracing lifecycle hooks around the token overview advanced chart and changes how metrics consent is determined during early app startup, which could affect whether performance data is captured or suppressed. > > **Overview** > Adds Sentry performance traces for token overview *advanced chart visibility*, starting a trace on `ohlcvSeriesKey` changes and ending it when the chart skeleton disappears, errors, is superseded by a newer request, falls back to the legacy chart, or unmounts (including `assetId` and truncated error details). > > Extends `AdvancedChart` with an `onSkeletonHidden` callback that fires once when the native skeleton overlay is removed (resetting on series/HTML reload), and adds new `TraceName`/`TraceOperation` constants to distinguish *initial/asset changes* vs *time-range-only* updates. > > Fixes early Sentry init consent detection by updating `hasMetricsConsent()` to read `persist:AnalyticsController` from `redux-persist-filesystem-storage` (fallback to legacy `METRICS_OPT_IN`), and keeps the in-memory consent cache in sync via `updateCachedConsent()` from settings; tests were updated/added for these behaviors. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7ec6bbd6905d6ece654e4d29fa3446ad330810b4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../Price/Price.advanced.test.tsx | 208 ++++++++++++++++++ .../UI/AssetOverview/Price/Price.advanced.tsx | 154 +++++++++++++ .../UI/Charts/AdvancedChart/AdvancedChart.tsx | 31 ++- .../AdvancedChart/AdvancedChart.types.ts | 5 + .../__tests__/AdvancedChart.test.tsx | 138 ++++++++++++ .../MetaMetricsAndDataCollectionSection.tsx | 5 + app/util/trace.test.ts | 7 +- app/util/trace.ts | 34 ++- 8 files changed, 579 insertions(+), 3 deletions(-) diff --git a/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx b/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx index 98067b7d408..b61e870b882 100644 --- a/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx +++ b/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx @@ -11,6 +11,15 @@ import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEvent jest.mock('../../../hooks/useAnalytics/useAnalytics'); +const mockTrace = jest.fn(); +const mockEndTrace = jest.fn(); + +jest.mock('../../../../util/trace', () => ({ + ...jest.requireActual('../../../../util/trace'), + trace: (...args: unknown[]) => mockTrace(...args), + endTrace: (...args: unknown[]) => mockEndTrace(...args), +})); + const mockSetIsChartBeingTouched = jest.fn(); jest.mock('../PriceChart/PriceChart.context', () => ({ usePriceChart: () => ({ @@ -830,4 +839,203 @@ describe('PriceAdvanced', () => { expect(mockSetIsChartBeingTouched).toHaveBeenCalledWith(false); }); }); + + describe('performance tracing', () => { + beforeEach(() => { + mockTrace.mockClear(); + mockEndTrace.mockClear(); + }); + + it('starts initial visibility trace when component mounts with advanced chart', () => { + render(); + + expect(mockTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringContaining('Advanced Chart Initial Visible'), + op: expect.stringContaining('token_overview.advanced_chart'), + }), + ); + }); + + it('ends trace when onSkeletonHidden is called with matching series key', () => { + const { getByTestId } = render(); + const advancedChart = getByTestId('mock-advanced-chart'); + + mockEndTrace.mockClear(); + + act(() => { + advancedChart.props.onSkeletonHidden?.(); + }); + + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringContaining('Advanced Chart Initial Visible'), + }), + ); + }); + + it('ends trace with error data when onError is called', () => { + const { getByTestId } = render(); + const advancedChart = getByTestId('mock-advanced-chart'); + + mockEndTrace.mockClear(); + + act(() => { + advancedChart.props.onError?.('WebView failed to load'); + }); + + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringContaining('Advanced Chart Initial Visible'), + data: expect.objectContaining({ + errorMessage: 'WebView failed to load', + }), + }), + ); + }); + + it('starts time range visibility trace when time range changes', () => { + const { getByTestId } = render(); + + mockTrace.mockClear(); + + act(() => { + fireEvent.press(getByTestId('select-1W')); + }); + + expect(mockTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringContaining('Time Range Visible'), + op: expect.stringContaining('time_range'), + }), + ); + }); + + it('supersedes previous trace when series key changes before skeleton hidden', () => { + const { getByTestId } = render(); + + mockEndTrace.mockClear(); + + act(() => { + fireEvent.press(getByTestId('select-1W')); + }); + + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + superseded: true, + }), + }), + ); + }); + + it('ends trace with fallbackToLegacy when switching to legacy chart', () => { + const { rerender } = render(); + + mockEndTrace.mockClear(); + + mockUseOHLCVChart.mockReturnValueOnce({ + ohlcvData: [], + isLoading: false, + error: undefined, + hasMore: false, + nextCursor: null, + hasEmptyData: true, + }); + + rerender(); + + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + fallbackToLegacy: true, + }), + }), + ); + }); + + it('includes assetId in trace data when available', () => { + mockTrace.mockClear(); + + render(); + + expect(mockTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + assetId: expect.any(String), + }), + }), + ); + }); + + it('does not start trace when falling back to legacy chart immediately', () => { + mockTrace.mockClear(); + + mockUseOHLCVChart.mockReturnValueOnce({ + ohlcvData: [], + isLoading: false, + error: undefined, + hasMore: false, + nextCursor: null, + hasEmptyData: true, + }); + + render(); + + expect(mockTrace).not.toHaveBeenCalled(); + }); + + it('ends trace with unmounted flag when component unmounts with open trace', () => { + const { unmount } = render(); + + mockEndTrace.mockClear(); + + unmount(); + + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringContaining('Advanced Chart Initial Visible'), + data: expect.objectContaining({ + unmounted: true, + }), + }), + ); + }); + + it('does not end trace on unmount when trace was already completed', () => { + const { getByTestId, unmount } = render(); + const advancedChart = getByTestId('mock-advanced-chart'); + + act(() => { + advancedChart.props.onSkeletonHidden?.(); + }); + + mockEndTrace.mockClear(); + + unmount(); + + expect(mockEndTrace).not.toHaveBeenCalled(); + }); + + it('truncates error message to 200 characters', () => { + const { getByTestId } = render(); + const advancedChart = getByTestId('mock-advanced-chart'); + + mockEndTrace.mockClear(); + + const longError = 'A'.repeat(300); + + act(() => { + advancedChart.props.onError?.(longError); + }); + + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + errorMessage: 'A'.repeat(200), + }), + }), + ); + }); + }); }); diff --git a/app/components/UI/AssetOverview/Price/Price.advanced.tsx b/app/components/UI/AssetOverview/Price/Price.advanced.tsx index 3a06ac9fdfa..0477302d54e 100644 --- a/app/components/UI/AssetOverview/Price/Price.advanced.tsx +++ b/app/components/UI/AssetOverview/Price/Price.advanced.tsx @@ -56,6 +56,12 @@ import type { TokenPrice, } from '../../../../components/hooks/useTokenHistoricalPrices'; import PriceLegacy from './Price.legacy'; +import { + endTrace, + trace, + TraceName, + TraceOperation, +} from '../../../../util/trace'; const EMPTY_INDICATORS: IndicatorType[] = []; @@ -67,6 +73,36 @@ const TIME_RANGE_LABELS: Record = { '1Y': 'asset_overview.chart_time_period.1y', }; +/** Maps {@link ohlcvSeriesKey} transitions to Sentry trace name/op (dashboards filter by name or op). */ +function getAdvancedChartVisibilityTraceRequest( + previousSeriesKey: string | null, + nextSeriesKey: string, +): { name: TraceName; op: TraceOperation } { + if (previousSeriesKey === null) { + return { + name: TraceName.TokenOverviewAdvancedChartInitialVisible, + op: TraceOperation.TokenOverviewAdvancedChart, + }; + } + const prev = previousSeriesKey.split('|'); + const next = nextSeriesKey.split('|'); + if (prev.length >= 4 && next.length >= 4) { + const sameAsset = prev[0] === next[0]; + const sameCurrency = prev[prev.length - 1] === next[next.length - 1]; + const rangeChanged = prev[1] !== next[1] || prev[2] !== next[2]; + if (sameAsset && sameCurrency && rangeChanged) { + return { + name: TraceName.TokenOverviewAdvancedChartTimeRangeVisible, + op: TraceOperation.TokenOverviewAdvancedChartTimeRange, + }; + } + } + return { + name: TraceName.TokenOverviewAdvancedChartInitialVisible, + op: TraceOperation.TokenOverviewAdvancedChart, + }; +} + export interface PriceAdvancedProps { asset: TokenI; currentPrice: number; @@ -206,6 +242,43 @@ const PriceAdvanced = ({ [assetId, config.timePeriod, config.interval, currentCurrency], ); + const assetIdRef = useRef(assetId); + assetIdRef.current = assetId; + + const visibilityTraceStartedRef = useRef(null); + /** Matches pending manual trace so {@link endTrace} uses the same `TraceName` as {@link trace}. */ + const activeVisibilityTraceRef = useRef<{ + seriesKey: string; + traceName: TraceName; + } | null>(null); + + const handleAdvancedChartSkeletonHidden = useCallback(() => { + const open = activeVisibilityTraceRef.current; + if (!open) { + return; + } + endTrace({ + name: open.traceName, + id: open.seriesKey, + }); + activeVisibilityTraceRef.current = null; + }, []); + + const handleAdvancedChartError = useCallback((error: string) => { + const open = activeVisibilityTraceRef.current; + if (!open) { + return; + } + endTrace({ + name: open.traceName, + id: open.seriesKey, + data: { + errorMessage: error.slice(0, 200), + }, + }); + activeVisibilityTraceRef.current = null; + }, []); + const { ohlcvData, isLoading: chartLoading, @@ -294,6 +367,85 @@ const PriceAdvanced = ({ !chartLoading && (ohlcvData.length < CHART_DATA_THRESHOLD || hasEmptyData || chartError); + const shouldFallbackToLegacyRef = useRef(shouldFallbackToLegacy); + shouldFallbackToLegacyRef.current = shouldFallbackToLegacy; + + useEffect(() => { + if (!shouldFallbackToLegacy) { + return; + } + const pendingId = visibilityTraceStartedRef.current; + if (pendingId === null) { + return; + } + const open = activeVisibilityTraceRef.current; + if (open?.seriesKey === pendingId) { + endTrace({ + name: open.traceName, + id: pendingId, + data: { fallbackToLegacy: true }, + }); + activeVisibilityTraceRef.current = null; + } + visibilityTraceStartedRef.current = null; + }, [shouldFallbackToLegacy]); + + useEffect(() => { + if (shouldFallbackToLegacyRef.current) { + return; + } + if (visibilityTraceStartedRef.current === ohlcvSeriesKey) { + return; + } + + const previousSeriesId = visibilityTraceStartedRef.current; + if (previousSeriesId !== null && previousSeriesId !== ohlcvSeriesKey) { + const supersededOpen = activeVisibilityTraceRef.current; + if (supersededOpen?.seriesKey === previousSeriesId) { + endTrace({ + name: supersededOpen.traceName, + id: previousSeriesId, + data: { superseded: true }, + }); + activeVisibilityTraceRef.current = null; + } + } + const { name: visibilityTraceName, op: visibilityTraceOp } = + getAdvancedChartVisibilityTraceRequest(previousSeriesId, ohlcvSeriesKey); + + visibilityTraceStartedRef.current = ohlcvSeriesKey; + activeVisibilityTraceRef.current = { + seriesKey: ohlcvSeriesKey, + traceName: visibilityTraceName, + }; + + const currentAssetId = assetIdRef.current; + trace({ + name: visibilityTraceName, + op: visibilityTraceOp, + id: ohlcvSeriesKey, + ...(currentAssetId.length > 0 + ? { data: { assetId: currentAssetId } } + : {}), + }); + }, [ohlcvSeriesKey]); + + useEffect( + () => () => { + const open = activeVisibilityTraceRef.current; + if (open) { + endTrace({ + name: open.traceName, + id: open.seriesKey, + data: { unmounted: true }, + }); + activeVisibilityTraceRef.current = null; + visibilityTraceStartedRef.current = null; + } + }, + [], + ); + if (shouldFallbackToLegacy) { return ( diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx b/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx index eb9ca1ee9f3..2865ae3a825 100644 --- a/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx @@ -85,6 +85,7 @@ const AdvancedChart = forwardRef( enableDrawingTools = false, disabledFeatures = DEFAULT_DISABLED_FEATURES, onChartReady, + onSkeletonHidden, onError, onCrosshairMove, onChartInteracted, @@ -123,6 +124,7 @@ const AdvancedChart = forwardRef( /** When non-null, `ohlcvData` is still the previous series' array; skip sync until the hook replaces it. */ const ohlcvSeriesStaleSnapshotRef = useRef(null); const tradingViewOpenInterceptRef = useRef(0); + const skeletonHiddenReportedRef = useRef(false); const htmlContent = useMemo( () => @@ -136,6 +138,7 @@ const AdvancedChart = forwardRef( // Reset all chart state when the WebView reloads due to htmlContent changes useEffect(() => { + skeletonHiddenReportedRef.current = false; setChartReadyCount(0); setWebViewLoaded(false); activeIndicatorsRef.current.clear(); @@ -180,6 +183,7 @@ const AdvancedChart = forwardRef( if (ohlcvSeriesKey === undefined) { return; } + skeletonHiddenReportedRef.current = false; setChartReadyCount(0); setWebViewLoaded(false); setLayoutSettling(false); @@ -565,6 +569,31 @@ const AdvancedChart = forwardRef( }); }, [lineChrome, chartReadyCount, postMessage]); + const showSkeleton = isLoading || !isChartReady || layoutSettling; + + useEffect(() => { + if (webViewError) { + return; + } + if (!onSkeletonHidden) { + return; + } + if (isLoading || !isChartReady || layoutSettling) { + return; + } + if (skeletonHiddenReportedRef.current) { + return; + } + skeletonHiddenReportedRef.current = true; + onSkeletonHidden(); + }, [ + isLoading, + isChartReady, + layoutSettling, + webViewError, + onSkeletonHidden, + ]); + // ---- Render ---- if (webViewError) { @@ -601,7 +630,7 @@ const AdvancedChart = forwardRef( androidLayerType="hardware" mixedContentMode="always" /> - {(isLoading || !isChartReady || layoutSettling) && ( + {showSkeleton && ( void; + /** + * Fires once when the native skeleton overlay is removed (chart ready, layout settled, + * and parent `isLoading` false). Resets when `ohlcvSeriesKey` or chart HTML reloads. + */ + onSkeletonHidden?: () => void; /** Callback when an error occurs */ onError?: (error: string) => void; /** Crosshair OHLC data callback (for overlay legend) */ diff --git a/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx b/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx index 4849719b07c..21ede7c67a8 100644 --- a/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx +++ b/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx @@ -373,6 +373,144 @@ describe('AdvancedChart', () => { expect(onChartReady).toHaveBeenCalledTimes(1); }); + it('calls onSkeletonHidden once when skeleton overlay is removed', () => { + const onSkeletonHidden = jest.fn(); + const { getByTestId, queryByTestId, rerender } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onLoadEnd(); + }); + + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + expect(getByTestId('advanced-chart-skeleton')).toBeOnTheScreen(); + expect(onSkeletonHidden).not.toHaveBeenCalled(); + + rerender( + , + ); + + expect(queryByTestId('advanced-chart-skeleton')).not.toBeOnTheScreen(); + expect(onSkeletonHidden).toHaveBeenCalledTimes(1); + + rerender( + , + ); + + expect(onSkeletonHidden).toHaveBeenCalledTimes(1); + }); + + it('calls onSkeletonHidden after CHART_LAYOUT_SETTLED when series key changes', () => { + const altBars: OHLCVBar[] = [ + { time: 2000000, open: 20, high: 22, low: 19, close: 21, volume: 400 }, + { time: 2000300, open: 21, high: 23, low: 20, close: 22, volume: 500 }, + ]; + const onSkeletonHidden = jest.fn(); + + const { getByTestId, queryByTestId, rerender } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onLoadEnd(); + }); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + expect(onSkeletonHidden).toHaveBeenCalledTimes(1); + expect(queryByTestId('advanced-chart-skeleton')).not.toBeOnTheScreen(); + + onSkeletonHidden.mockClear(); + rerender( + , + ); + + expect(getByTestId('advanced-chart-skeleton')).toBeOnTheScreen(); + + const webViewAfter = getByTestId('mock-webview'); + act(() => { + webViewAfter.props.onLoadEnd(); + }); + act(() => { + webViewAfter.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + act(() => { + webViewAfter.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_LAYOUT_SETTLED', payload: {} }), + }, + }); + }); + + expect(queryByTestId('advanced-chart-skeleton')).not.toBeOnTheScreen(); + expect(onSkeletonHidden).toHaveBeenCalledTimes(1); + }); + + it('does not call onSkeletonHidden when WebView error UI is shown', () => { + const onSkeletonHidden = jest.fn(); + const { getByTestId, queryByTestId } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ + type: 'ERROR', + payload: { message: 'chart init failed' }, + }), + }, + }); + }); + + expect(queryByTestId('advanced-chart-skeleton')).not.toBeOnTheScreen(); + expect(queryByTestId('mock-webview')).not.toBeOnTheScreen(); + expect(onSkeletonHidden).not.toHaveBeenCalled(); + }); + it('calls onError when chart reports an error', () => { const onError = jest.fn(); const { getByTestId } = render( diff --git a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx index a7b8770f415..25dbdae28f2 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx @@ -31,6 +31,7 @@ import { RootState } from '../../../../../../reducers'; import { useAutoSignIn } from '../../../../../../util/identity/hooks/useAuthentication'; import OAuthService from '../../../../../../core/OAuthService/OAuthService'; import Logger from '../../../../../../util/Logger'; +import { updateCachedConsent } from '../../../../../../util/trace'; import { selectSeedlessOnboardingLoginFlow } from '../../../../../../selectors/seedlessOnboardingController'; import { selectOnboardingAccountType } from '../../../../../../selectors/onboarding'; import { storePna25Acknowledged } from '../../../../../../actions/legalNotices'; @@ -75,6 +76,7 @@ const MetaMetricsAndDataCollectionSection: React.FC< // Error already logged in optOut }); setAnalyticsEnabled(false); + updateCachedConsent(false); dispatch(setDataCollectionForMarketing(false)); return; } @@ -93,6 +95,7 @@ const MetaMetricsAndDataCollectionSection: React.FC< fetchMarketingStatus(); } setAnalyticsEnabled(analytics.isEnabled()); + updateCachedConsent(analytics.isEnabled()); }, [ setAnalyticsEnabled, autoSignIn, @@ -110,6 +113,7 @@ const MetaMetricsAndDataCollectionSection: React.FC< await analytics.optIn(); setAnalyticsEnabled(true); + updateCachedConsent(true); analytics.identify(consolidatedTraits); analytics.trackEvent( @@ -158,6 +162,7 @@ const MetaMetricsAndDataCollectionSection: React.FC< await analytics.optOut(); setAnalyticsEnabled(false); + updateCachedConsent(false); if (isDataCollectionForMarketingEnabled) { dispatch(setDataCollectionForMarketing(false)); diff --git a/app/util/trace.test.ts b/app/util/trace.test.ts index 74ade05318b..30d5f55f879 100644 --- a/app/util/trace.test.ts +++ b/app/util/trace.test.ts @@ -31,6 +31,12 @@ jest.mock('../store/storage-wrapper', () => ({ getItem: jest.fn(), })); +jest.mock('redux-persist-filesystem-storage', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), +})); + jest.mock('../store', () => ({ store: { dispatch: jest.fn(), @@ -89,7 +95,6 @@ describe('Trace', () => { ); flushBufferedTraces(); - // Reset consent state to false by default updateCachedConsent(false); }); diff --git a/app/util/trace.ts b/app/util/trace.ts index 940e0e4132b..9dd821d4de3 100644 --- a/app/util/trace.ts +++ b/app/util/trace.ts @@ -13,6 +13,7 @@ import performance from 'react-native-performance'; import { createModuleLogger, createProjectLogger } from '@metamask/utils'; import { AGREED, METRICS_OPT_IN } from '../constants/storage'; import StorageWrapper from '../store/storage-wrapper'; +import FilesystemStorage from 'redux-persist-filesystem-storage'; // Cannot create this 'sentry' logger in Sentry util file because of circular dependency const projectLogger = createProjectLogger('sentry'); @@ -62,6 +63,10 @@ export enum TraceName { EvmDiscoverAccounts = 'EVM Discover Accounts', SnapDiscoverAccounts = 'Snap Discover Accounts', FetchHistoricalPrices = 'Fetch Historical Prices', + /** Token overview advanced chart: skeleton cleared after initial load / asset or currency change. */ + TokenOverviewAdvancedChartInitialVisible = 'Token Overview Advanced Chart Initial Visible', + /** Token overview advanced chart: skeleton cleared after time range selector change only. */ + TokenOverviewAdvancedChartTimeRangeVisible = 'Token Overview Advanced Chart Time Range Visible', TransactionConfirmed = 'Transaction Confirmed', LoadCollectibles = 'Load Collectibles', DetectNfts = 'Detect Nfts', @@ -268,6 +273,10 @@ export enum TraceOperation { MarketInsightsViewportTracking = 'market_insights.viewport_tracking', // Homepage Section Performance HomepageSectionPerformance = 'homepage.section.performance', + /** Token overview OHLCV WebView: initial load or asset/currency change */ + TokenOverviewAdvancedChart = 'token_overview.advanced_chart', + /** Token overview OHLCV WebView: time range change only */ + TokenOverviewAdvancedChartTimeRange = 'token_overview.advanced_chart_time_range', } const ID_DEFAULT = 'default'; @@ -543,9 +552,32 @@ export async function flushBufferedTraces() { let cachedConsent: boolean | null = null; /** - * Check if user has given consent for metrics + * Check if user has given consent for metrics (for Sentry init). + * Reads from AnalyticsController's persisted state in FilesystemStorage. + * + * This bypasses Engine/Redux because Sentry initializes in index.js before they're available. + * Follows the same pattern as ControllerStorage.getAllPersistedState() in persistConfig. */ export async function hasMetricsConsent(): Promise { + try { + // Read directly from AnalyticsController's persisted state (same as ControllerStorage does) + const persistedData = await FilesystemStorage.getItem( + 'persist:AnalyticsController', + ); + if (persistedData) { + const parsed = JSON.parse(persistedData); + // Remove redux-persist metadata and get controller state + const { _persist, ...controllerState } = parsed; + if (typeof controllerState?.optedIn === 'boolean') { + cachedConsent = controllerState.optedIn; + return controllerState.optedIn; + } + } + } catch { + // Fall through to legacy storage + } + + // Fallback: legacy METRICS_OPT_IN (migration 108, may be stale) const metricsOptIn = await StorageWrapper.getItem(METRICS_OPT_IN); const hasConsent = metricsOptIn === AGREED; cachedConsent = hasConsent; From 7f3f590371c2a6bf01672cccfc22e762422e18aa Mon Sep 17 00:00:00 2001 From: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> Date: Wed, 6 May 2026 09:25:21 -0400 Subject: [PATCH 06/27] refactor: migrate `RevealSRP` and `EditMultichainAccountName` to design system (#29697) ## **Description** Migrates the `RevealSRP` and `EditMultichainAccountName` multichain account sheets from component-library primitives to `@metamask/design-system-react-native`. ## **Changelog** CHANGELOG entry: null ## **Related issues** Refs: [MUL-1687](https://consensyssoftware.atlassian.net/browse/MUL-1687) ## **Manual testing steps** ```gherkin Feature: Multichain account sheets (design system) Scenario: Reveal SRP intro screen Given the user opens the Reveal SRP flow for a multichain account When they view the intro sheet Then the header, description, primary "Get started", and secondary "Learn more" actions render and match prior behavior When they tap the back control Then navigation returns to the previous screen Scenario: Edit multichain account group name Given the user opens Edit multichain account name for an account group When they change the name and tap Save Then the name is persisted and the screen closes When they tap the back control without saving Then navigation returns to the previous screen ``` ## **Screenshots/Recordings** https://github.com/user-attachments/assets/a4faecee-8e04-4e5a-a3b8-194933d74fa2 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example ## **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. [MUL-1687]: https://consensyssoftware.atlassian.net/browse/MUL-1687?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Medium Risk** > Primarily a UI refactor, but it touches the SRP reveal entry flow and navigation controls, so regressions could impact a security-sensitive user journey (layout/safe-area/back/save behaviors across iOS/Android). > > **Overview** > Migrates the `RevealSRP` and `EditMultichainAccountName` sheets from component-library primitives and custom `StyleSheet` files to `@metamask/design-system-react-native` components with Tailwind-based styling, including Android-specific status bar inset handling. > > Adds a dedicated back-button test id (`EditAccountNameIds.BACK_BUTTON`) and updates/expands tests to assert navigation via that control, cover a missing-`accountGroup` fallback header, and exercise Android rendering paths (status bar height present/absent). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e74bb6cf0573350da5a9387b427f1696bc298f2c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Cursor --- .../sheets/EditAccountName.testIds.ts | 1 + .../EditMultichainAccountName.styles.ts | 49 ------- .../EditMultichainAccountName.test.tsx | 45 ++++++- .../EditMultichainAccountName.tsx | 75 ++++++----- .../sheets/RevealSRP/RevealSRP.styles.ts | 52 -------- .../sheets/RevealSRP/RevealSRP.test.tsx | 47 +++++++ .../sheets/RevealSRP/RevealSRP.tsx | 122 +++++++++++------- 7 files changed, 212 insertions(+), 179 deletions(-) delete mode 100644 app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.styles.ts delete mode 100644 app/components/Views/MultichainAccounts/sheets/RevealSRP/RevealSRP.styles.ts diff --git a/app/components/Views/MultichainAccounts/sheets/EditAccountName.testIds.ts b/app/components/Views/MultichainAccounts/sheets/EditAccountName.testIds.ts index d1cd888882b..f8a8123a08a 100644 --- a/app/components/Views/MultichainAccounts/sheets/EditAccountName.testIds.ts +++ b/app/components/Views/MultichainAccounts/sheets/EditAccountName.testIds.ts @@ -2,4 +2,5 @@ export const EditAccountNameIds = { EDIT_ACCOUNT_NAME_CONTAINER: 'edit-account-name-container', ACCOUNT_NAME_INPUT: 'edit-account-name-input', SAVE_BUTTON: 'edit-account-name-save-button', + BACK_BUTTON: 'edit-multichain-account-name-back-button', }; diff --git a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.styles.ts b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.styles.ts deleted file mode 100644 index c8f575dccd1..00000000000 --- a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.styles.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Theme } from '../../../../../util/theme/models'; -import { Platform, StatusBar, StyleSheet } from 'react-native'; - -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - const { colors } = theme; - - return StyleSheet.create({ - safeArea: { - paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0, - flex: 1, - backgroundColor: colors.background.default, - }, - keyboardAvoidingView: { - flex: 1, - justifyContent: 'space-between', - }, - contentContainer: { - marginTop: 16, - paddingLeft: 24, - paddingRight: 24, - gap: 16, - }, - input: { - borderRadius: 8, - borderWidth: 2, - width: '100%', - borderColor: colors.border.default, - padding: 10, - height: 40, - color: colors.text.default, - }, - saveButtonContainer: { - paddingHorizontal: 24, - marginTop: 16, - paddingVertical: 10, - width: '100%', - }, - header: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - margin: 16, - }, - }); -}; - -export default styleSheet; diff --git a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.test.tsx b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.test.tsx index 1363eb99450..3b0e0bdc3d7 100644 --- a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.test.tsx +++ b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { fireEvent } from '@testing-library/react-native'; +import { Platform, StatusBar } from 'react-native'; import { EditMultichainAccountName } from './EditMultichainAccountName'; import { strings } from '../../../../../../locales/i18n'; import { EditAccountNameIds } from '../EditAccountName.testIds'; @@ -37,12 +38,29 @@ jest.mock('../../../../../core/Engine', () => ({ })); describe('EditMultichainAccountName', () => { + const originalPlatformOs = Platform.OS; + const originalStatusBarCurrentHeight = StatusBar.currentHeight; const render = () => renderWithProvider(); beforeEach(() => { jest.clearAllMocks(); mockSetAccountGroupName.mockReset(); mockUseRoute.mockReturnValue(mockRoute); + Platform.OS = 'ios'; + Object.defineProperty(StatusBar, 'currentHeight', { + configurable: true, + writable: true, + value: originalStatusBarCurrentHeight, + }); + }); + + afterAll(() => { + Platform.OS = originalPlatformOs; + Object.defineProperty(StatusBar, 'currentHeight', { + configurable: true, + writable: true, + value: originalStatusBarCurrentHeight, + }); }); describe('rendering', () => { @@ -85,9 +103,9 @@ describe('EditMultichainAccountName', () => { }); it('navigates back when back button is pressed', () => { - const { getByRole } = render(); + const { getByTestId } = render(); - const backButton = getByRole('button'); + const backButton = getByTestId(EditAccountNameIds.BACK_BUTTON); fireEvent.press(backButton); expect(mockGoBack).toHaveBeenCalledTimes(1); @@ -235,5 +253,28 @@ describe('EditMultichainAccountName', () => { const nameInput = getByTestId(EditAccountNameIds.ACCOUNT_NAME_INPUT); expect(nameInput.props.value).toBe(''); }); + + it('renders fallback header when route omits account group', () => { + mockUseRoute.mockReturnValue({ + params: {}, + } as typeof mockRoute); + + const { getByText } = render(); + expect(getByText('Account Group')).toBeOnTheScreen(); + }); + }); + + describe('platform-specific layout', () => { + it('renders on Android with status bar inset and keyboard avoiding height behavior', () => { + Platform.OS = 'android'; + Object.defineProperty(StatusBar, 'currentHeight', { + configurable: true, + writable: true, + value: 24, + }); + + const { getByTestId } = render(); + expect(getByTestId(EditAccountNameIds.BACK_BUTTON)).toBeOnTheScreen(); + }); }); }); diff --git a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx index 92c4ed85f12..a178d619dd1 100644 --- a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx +++ b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx @@ -1,5 +1,11 @@ import React, { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; +import { + KeyboardAvoidingView, + Platform, + StatusBar, + TextInput, +} from 'react-native'; import { strings } from '../../../../../../locales/i18n'; import Engine from '../../../../../core/Engine'; import { @@ -8,31 +14,28 @@ import { useNavigation, useRoute, } from '@react-navigation/native'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import { Box } from '../../../../UI/Box/Box'; import { + Box, + BoxFlexDirection, Button, + ButtonIcon, + ButtonIconSize, + ButtonSize, ButtonVariant, - ButtonBaseSize, + HeaderBase, + IconName, + Text, + TextColor, + TextVariant, + FontWeight, } from '@metamask/design-system-react-native'; -import styleSheet from './EditMultichainAccountName.styles'; -import { useStyles } from '../../../../hooks/useStyles'; -import { useTheme } from '../../../../../util/theme'; -import { TextInput, KeyboardAvoidingView, Platform } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; import { EditAccountNameIds } from '../EditAccountName.testIds'; import { AccountGroupObject } from '@metamask/account-tree-controller'; import { RootState } from '../../../../../reducers'; import { selectAccountGroupById } from '../../../../../selectors/multichainAccounts/accountTreeController'; -import HeaderBase from '../../../../../component-library/components/HeaderBase/HeaderBase'; -import ButtonLink from '../../../../../component-library/components/Buttons/Button/variants/ButtonLink'; -import Icon, { - IconName, - IconSize, -} from '../../../../../component-library/components/Icons/Icon'; +import { useTheme } from '../../../../../util/theme'; interface RootNavigationParamList extends ParamListBase { EditMultichainAccountName: { @@ -46,7 +49,7 @@ type EditMultichainAccountNameRouteProp = RouteProp< >; export const EditMultichainAccountName = () => { - const { styles } = useStyles(styleSheet, {}); + const tw = useTailwind(); const { colors, themeAppearance } = useTheme(); const route = useRoute(); const { accountGroup: initialAccountGroup } = route.params; @@ -65,6 +68,18 @@ export const EditMultichainAccountName = () => { const [accountName, setAccountName] = useState(initialName); const [error, setError] = useState(null); + const safeAreaStyle = tw.style( + 'flex-1 bg-default', + Platform.OS === 'android' && StatusBar.currentHeight + ? { paddingTop: StatusBar.currentHeight } + : undefined, + ); + + const inputStyle = tw.style( + 'h-10 w-full rounded-lg border-2 border-default p-2.5', + { color: colors.text.default }, + ); + const handleAccountNameChange = useCallback(() => { // Validate that account name is not empty if (!accountName || accountName.trim() === '') { @@ -93,13 +108,14 @@ export const EditMultichainAccountName = () => { }, [accountName, accountGroup, navigation]); return ( - + } + navigation.goBack()} /> } @@ -107,19 +123,20 @@ export const EditMultichainAccountName = () => { {accountGroup?.metadata?.name || 'Account Group'} - + {strings('multichain_accounts.edit_account_name.account_name')} { setAccountName(newName); @@ -136,13 +153,13 @@ export const EditMultichainAccountName = () => { autoFocus editable /> - {error && {error}} + {error ? {error} : null} - + + ); From 38817dba5b14a43d33ee314d3005e3bf81c8bb84 Mon Sep 17 00:00:00 2001 From: Christopher Ferreira <104831203+christopherferreira9@users.noreply.github.com> Date: Wed, 6 May 2026 14:28:43 +0100 Subject: [PATCH 07/27] test: migrates the WalletConnect performance test (#29785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR migrates the WC performance test to the new framework. `skip-e2e` has been applied since all changes are related to Appium. ### Framework changes: - Allows for lazy fetch on `getElementByNameiOS` iOS Run: https://app-automate.browserstack.com/dashboard/v2/builds/8c27168975bc74376449c6f9fa82158c549c8f41/sessions/195066d41131f7143a0b1989f5e0b710d39999f4 (Quality-Gates failed) Android Run: https://app-automate.browserstack.com/dashboard/v2/builds/91d934900ddd178f4d255db708e8a14ac3153aed/sessions/05e33619e97368cfd712ec14a41c19033b01ce45 ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MMQA-1725 ## **Manual testing steps** N/A ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Moderate risk because it rewires a cross-context (native/web) performance test and adds new iOS/Android selectors and retry logic, which can introduce flaky behavior if locators or timing differ across devices. > > **Overview** > Migrates the Uniswap WalletConnect performance interaction test from the legacy JS/WebdriverIO setup to the new Playwright-based fixture (`uniswap-interaction.spec.js` replaced with `uniswap-interaction.spec.ts`), updating flows to use `loginToAppPlaywright`, `native-browser.flow`, and `PlaywrightContextHelpers`. > > Adds a new `UniswapDapp` page object to encapsulate Uniswap connect + WalletConnect + MetaMask deep link interactions and “Uniswap displayed” checks with platform-specific selectors and retry/wait helpers. > > Extends test/framework selectors by adding an optional `lazy` mode to `PlaywrightMatchers.getElementByNameiOS`, and updates `DappConnectionModal` selectors/behavior for iOS (edit accounts) plus a small tap/refactor in network selection. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit cdc88422a8366bc0f78585cd71a9d24a46710690. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- tests/framework/PlaywrightMatchers.ts | 9 +- .../MMConnect/DappConnectionModal.ts | 19 +- tests/page-objects/MMConnect/UniswapDapp.ts | 248 ++++++++++++++++++ .../login/uniswap-interaction.spec.js | 106 -------- .../login/uniswap-interaction.spec.ts | 94 +++++++ 5 files changed, 363 insertions(+), 113 deletions(-) create mode 100644 tests/page-objects/MMConnect/UniswapDapp.ts delete mode 100644 tests/performance/login/uniswap-interaction.spec.js create mode 100644 tests/performance/login/uniswap-interaction.spec.ts diff --git a/tests/framework/PlaywrightMatchers.ts b/tests/framework/PlaywrightMatchers.ts index 2347b26f1fb..7037bc93d20 100644 --- a/tests/framework/PlaywrightMatchers.ts +++ b/tests/framework/PlaywrightMatchers.ts @@ -229,12 +229,19 @@ export default class PlaywrightMatchers { /** * Get element by name on iOS * @param name - The name to search for + * @param lazy - Whether to get a lazy element. Lazy elements are not required to be present in the DOM. This is useful for negative assertions where the element may never have been rendered (e.g. waitForDisplayed({ reverse: true })). * @returns The wrapped element */ - static async getElementByNameiOS(name: string): Promise { + static async getElementByNameiOS( + name: string, + lazy = false, + ): Promise { const isIOS = await PlatformDetector.isIOS(); if (!isIOS) throw new Error('This function is only valid for iOS'); const xpath = `//*[contains(@name,'${name}')]`; + if (lazy) { + return await this.getLazyElementByXPath(xpath); + } return await this.getElementByXPath(xpath); } diff --git a/tests/page-objects/MMConnect/DappConnectionModal.ts b/tests/page-objects/MMConnect/DappConnectionModal.ts index 1a12895191f..1252e45e3de 100644 --- a/tests/page-objects/MMConnect/DappConnectionModal.ts +++ b/tests/page-objects/MMConnect/DappConnectionModal.ts @@ -8,6 +8,7 @@ import PlaywrightMatchers from '../../framework/PlaywrightMatchers'; import UnifiedGestures from '../../framework/UnifiedGestures'; import { getDriver } from '../../framework/PlaywrightUtilities'; import { ConnectAccountBottomSheetSelectorsIDs } from '../../../app/components/Views/AccountConnect/ConnectAccountBottomSheet.testIds'; +import { ConnectedAccountsSelectorsIDs } from '../../../app/components/Views/AccountConnect/ConnectedAccountModal.testIds'; import { AccountCellIds } from '../../../app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.testIds'; import { CellComponentSelectorsIDs } from '../../../app/component-library/components/Cells/Cell/CellComponent.testIds'; import { sleep } from '../../framework'; @@ -33,10 +34,16 @@ class DappConnectionModal { get editAccountsButton(): EncapsulatedElementType { return encapsulated({ - appium: () => - PlaywrightMatchers.getElementByXPath( - '//android.view.ViewGroup[@content-desc="Edit accounts"]', - ), + appium: { + android: () => + PlaywrightMatchers.getElementByXPath( + '//android.view.ViewGroup[@content-desc="Edit accounts"]', + ), + ios: () => + PlaywrightMatchers.getElementById( + ConnectedAccountsSelectorsIDs.ACCOUNT_LIST_BOTTOM_SHEET, + ), + }, }); } @@ -130,10 +137,10 @@ class DappConnectionModal { direction: 'down', percent: 1.0, }); - const element = await asPlaywrightElement( + const networkButton = await asPlaywrightElement( this.getNetworkButton(networkName), ); - await element.click(); + await networkButton.click(); }, }); } diff --git a/tests/page-objects/MMConnect/UniswapDapp.ts b/tests/page-objects/MMConnect/UniswapDapp.ts new file mode 100644 index 00000000000..c585d87c295 --- /dev/null +++ b/tests/page-objects/MMConnect/UniswapDapp.ts @@ -0,0 +1,248 @@ +import { + asPlaywrightElement, + encapsulated, + encapsulatedAction, + EncapsulatedElementType, + PlatformDetector, + PlaywrightAssertions, + PlaywrightGestures, + PlaywrightMatchers, + sleep, + UnifiedGestures, +} from '../../framework'; + +class UniswapDapp { + private getByXPath(xpath: string): EncapsulatedElementType { + return encapsulated({ + appium: () => PlaywrightMatchers.getLazyElementByXPath(xpath), + }); + } + + get connectButton(): EncapsulatedElementType { + return encapsulated({ + appium: { + android: () => + PlaywrightMatchers.getLazyElementByXPath( + '//*[@data-testid="navbar-connect-wallet"]', + ), + ios: () => + PlaywrightMatchers.getElementById('Connect', { exact: true }), + }, + }); + } + + get walletConnect(): EncapsulatedElementType { + return encapsulated({ + appium: { + android: () => + PlaywrightMatchers.getElementByXPath( + '//*[contains(normalize-space(.), "WalletConnect")]', + ), + ios: () => + PlaywrightMatchers.getElementByXPath( + '//XCUIElementTypeStaticText[@name="WalletConnect"]', + ), + }, + }); + } + + get metaMaskWalletOption(): EncapsulatedElementType { + return encapsulated({ + appium: { + android: () => + PlaywrightMatchers.getLazyElementByXPath( + '//android.widget.Button[@text="MetaMask MetaMask"]', + ), + ios: () => + PlaywrightMatchers.getElementById('MetaMask MetaMask', { + exact: true, + }), + }, + }); + } + + get metaMaskDeeplinkButton(): EncapsulatedElementType { + return encapsulated({ + appium: { + android: () => + PlaywrightMatchers.getLazyElementByXPath( + '//android.widget.TextView[@text="MetaMask"]', + ), + ios: () => + PlaywrightMatchers.getLazyElementByXPath( + '//XCUIElementTypeOther[@name="textfield"]', + ), + }, + }); + } + + get uniswapDialog(): EncapsulatedElementType { + return this.getByXPath('//android.app.AlertDialog'); + } + + get uniswapIcon(): EncapsulatedElementType { + return encapsulated({ + appium: () => PlaywrightMatchers.getElementById('account-icon'), + }); + } + + get solanaPopup(): EncapsulatedElementType { + return encapsulated({ + appium: () => + PlaywrightMatchers.getElementByText('Use Solana on Uniswap'), + }); + } + + get SolanaPopup(): EncapsulatedElementType { + return this.solanaPopup; + } + + async waitForConnectButtonVisible(timeoutMs = 20000): Promise { + await this.waitForElementVisible( + this.connectButton, + timeoutMs, + 'UniswapDapp: connect button not visible', + ); + } + + async waitForWalletConnectVisible(timeoutMs = 15000): Promise { + await this.waitForElementVisible( + this.walletConnect, + timeoutMs, + 'UniswapDapp: WalletConnect option not visible', + ); + } + + async tapConnect(): Promise { + await PlaywrightGestures.waitAndTap( + await asPlaywrightElement(this.connectButton), + { + delay: 3000, // 3 seconds - DOM might not be ready yet + }, + ); + } + + async tapOnWalletConnect(): Promise { + await PlaywrightGestures.waitAndTap( + await asPlaywrightElement(this.walletConnect), + { + delay: 3000, // 3 seconds - DOM might not be ready yet + }, + ); + } + + async connectWithMetaMask(): Promise { + await this.waitForConnectButtonVisible(); + await this.tapConnect(); + await this.waitForWalletConnectVisible(); + await this.tapOnWalletConnect(); + } + + async connectIOS(timeoutMs = 20000): Promise { + await this.waitForConnectButtonVisible(timeoutMs); + await this.tapConnect(); + } + + async selectWalletConnectOption(): Promise { + await this.tapOnWalletConnect(); + } + + async tapOnMetaMaskWalletOption(): Promise { + await UnifiedGestures.waitAndTap(this.metaMaskWalletOption, { + description: 'tap MetaMask wallet option', + }); + } + + async tapOnMetaMaskDeeplinkButton(): Promise { + await encapsulatedAction({ + appium: async () => { + await sleep(2000); + await PlaywrightGestures.waitAndTap( + await asPlaywrightElement(this.metaMaskDeeplinkButton), + ); + }, + }); + } + + async tapOnMetaMaskWalletOptionAndOpenDeeplink(): Promise { + await this.tapOnMetaMaskWalletOption(); + if (PlatformDetector.isAndroid()) { + await this.tapOnMetaMaskDeeplinkButton(); + } + } + + async isUniswapDisplayed(timeoutMs = 30000): Promise { + await encapsulatedAction({ + appium: async () => { + if (PlatformDetector.isAndroid()) { + const dialogVisible = await this.isElementVisible( + this.uniswapDialog, + timeoutMs, + ); + + if (dialogVisible) { + return; + } + + const iconVisible = await this.isElementVisible( + this.uniswapIcon, + timeoutMs, + ); + + if (!iconVisible) { + throw new Error( + 'Neither Uniswap dialog nor account icon is visible in Android context', + ); + } + + return; + } + + await this.waitForElementVisible( + this.solanaPopup, + timeoutMs, + 'UniswapDapp: Solana popup not visible', + ); + }, + }); + } + + private async waitForElementVisible( + targetElement: EncapsulatedElementType, + timeoutMs: number, + timeoutMsg: string, + ): Promise { + await encapsulatedAction({ + appium: async () => { + await PlaywrightAssertions.expectConditionWithRetry( + async () => { + const resolvedElement = await asPlaywrightElement(targetElement); + await resolvedElement.waitForDisplayed({ + timeout: timeoutMs, + timeoutMsg, + }); + }, + { + maxRetries: 5, + description: timeoutMsg, + }, + ); + }, + }); + } + + private async isElementVisible( + targetElement: EncapsulatedElementType, + timeoutMs: number, + ): Promise { + try { + const resolvedElement = await asPlaywrightElement(targetElement); + await resolvedElement.waitForDisplayed({ timeout: timeoutMs }); + return true; + } catch { + return false; + } + } +} + +export default new UniswapDapp(); diff --git a/tests/performance/login/uniswap-interaction.spec.js b/tests/performance/login/uniswap-interaction.spec.js deleted file mode 100644 index 03e5d22bb15..00000000000 --- a/tests/performance/login/uniswap-interaction.spec.js +++ /dev/null @@ -1,106 +0,0 @@ -import { test } from '../../framework/fixtures/performance/index.ts'; -import TimerHelper from '../../framework/TimerHelper.ts'; - -import { login } from '../../framework/utils/Flows.js'; -import { - switchToMobileBrowser, - navigateToDapp, - launchMobileBrowser, -} from '../../framework/utils/MobileBrowser.js'; -import WalletMainScreen from '../../../wdio/screen-objects/WalletMainScreen.js'; -import UniswapDapp from '../../../wdio/screen-objects/UniswapDapp.js'; -import AndroidScreenHelpers from '../../../wdio/screen-objects/Native/Android.js'; -import DappConnectionModal from '../../../wdio/screen-objects/Modals/DappConnectionModal.js'; -import AccountListComponent from '../../../wdio/screen-objects/AccountListComponent.js'; -import AppwrightHelpers from '../../framework/AppwrightHelpers.ts'; -import { unlockIfLockScreenVisible } from '../mm-connect/utils.js'; -import { PerformanceLogin } from '../../tags.performance.js'; -import AppwrightSelectors from '../../framework/AppwrightSelectors.ts'; - -const UNISWAP_URL = 'https://app.uniswap.org'; -const UNISWAP_DAPP_NAME = 'Uniswap'; - -// TODO(MMQA-1616): Re-enable after migrating this spec to tests/framework/fixture. - -test.describe(`${PerformanceLogin}`, () => { - test.setTimeout(240000); - - test.skip( - 'Connect to Uniswap dapp, edit accounts, choose another account, and skip Solana popup', - { tag: '@metamask-mobile-platform' }, - async ({ device, performanceTracker }, testInfo) => { - WalletMainScreen.device = device; - UniswapDapp.device = device; - AndroidScreenHelpers.device = device; - DappConnectionModal.device = device; - AccountListComponent.device = device; - - const metamaskTimer = new TimerHelper( - 'Time since the user selects Metamask until Metamask app is opened', - { ios: 15000, android: 20000 }, - device, - ); - - const connectTimer = new TimerHelper( - 'Time since the user taps Connect in MetaMask until Uniswap is displayed', - { ios: 15000, android: 20000 }, - device, - ); - await login(device); - // 1. Login and navigate to Uniswap in the mobile browser - await AppwrightHelpers.withNativeAction(device, async () => { - await launchMobileBrowser(device); - await navigateToDapp(device, UNISWAP_URL, UNISWAP_DAPP_NAME); - }); - - // Wait for Uniswap to fully load before interacting - await new Promise((resolve) => setTimeout(resolve, 5000)); - - // 2. Tap Connect on Uniswap and select MetaMask from the wallet picker - if (AppwrightSelectors.isAndroid(device)) { - await AppwrightHelpers.withWebAction( - device, - async () => { - await UniswapDapp.connectWithMetaMask(); - }, - UNISWAP_URL, - ); - } else { - await AppwrightHelpers.withNativeAction(device, async () => { - await UniswapDapp.connectIOS(); - await new Promise((resolve) => setTimeout(resolve, 3000)); - await UniswapDapp.selectWalletConnectOption(); - }); - } - - // 3. Click MetaMask in native wallet picker. - await AppwrightHelpers.withNativeAction(device, async () => { - await UniswapDapp.tapOnMetaMaskWalletOptionAndOpenDeeplink(); - }); - metamaskTimer.start(); - // 4. Handle MetaMask connection modal in native context: - // - unlock if lock screen is shown - // - edit account selection to pick a different account - // - tap Connect (timer starts here) - await AppwrightHelpers.withNativeAction(device, async () => { - await unlockIfLockScreenVisible(device); - metamaskTimer.stop(); - await DappConnectionModal.tapEditAccountsButton(); - await DappConnectionModal.tapUpdateAccountsButton(); - - await DappConnectionModal.tapConnectButton(); - }); - connectTimer.start(); - await switchToMobileBrowser(device); - await AppwrightHelpers.withNativeAction(device, async () => { - if (AppwrightSelectors.isAndroid(device)) { - // with the current framework we are limited with autoaccept alerts and on ios it clicks it before we can make the assertion - await UniswapDapp.isUniswapDisplayed(); - } - }); - connectTimer.stop(); - - performanceTracker.addTimers(metamaskTimer, connectTimer); - }, - ); -}); diff --git a/tests/performance/login/uniswap-interaction.spec.ts b/tests/performance/login/uniswap-interaction.spec.ts new file mode 100644 index 00000000000..abbdf3f6563 --- /dev/null +++ b/tests/performance/login/uniswap-interaction.spec.ts @@ -0,0 +1,94 @@ +import { test as perfTest } from '../../framework/fixture'; +import TimerHelper from '../../framework/TimerHelper'; +import UniswapDapp from '../../page-objects/MMConnect/UniswapDapp'; +import DappConnectionModal from '../../page-objects/MMConnect/DappConnectionModal'; +import { unlockIfLockScreenVisible } from '../mm-connect/utils'; +import { PerformanceLogin } from '../../tags.performance.js'; +import { loginToAppPlaywright } from '../../flows/wallet.flow'; +import PlaywrightContextHelpers from '../../framework/PlaywrightContextHelpers'; +import { + launchMobileBrowser, + navigateToDapp, + switchToMobileBrowser, +} from '../../flows/native-browser.flow'; + +const UNISWAP_URL = 'https://app.uniswap.org'; + +perfTest.describe(`${PerformanceLogin}`, () => { + perfTest.setTimeout(10 * 60 * 1000); + + perfTest( + 'Connect to Uniswap dapp, edit accounts, choose another account, and skip Solana popup', + { tag: '@metamask-mobile-platform' }, + async ({ currentDeviceDetails, driver: _driver, performanceTracker }) => { + const { platform } = currentDeviceDetails; + + const metamaskTimer = new TimerHelper( + 'Time since the user selects Metamask until Metamask app is opened', + { ios: 15000, android: 20000 }, + platform, + ); + + const connectTimer = new TimerHelper( + 'Time since the user taps Connect in MetaMask until Uniswap is displayed', + { ios: 15000, android: 20000 }, + platform, + ); + await loginToAppPlaywright(); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await launchMobileBrowser(); + await navigateToDapp(UNISWAP_URL); + }); + + // Wait for Uniswap to fully load before interacting + await new Promise((resolve) => setTimeout(resolve, 5000)); + + if (platform === 'android') { + await PlaywrightContextHelpers.withWebAction(async () => { + await UniswapDapp.connectWithMetaMask(); + }, UNISWAP_URL); + } else { + await PlaywrightContextHelpers.withNativeAction(async () => { + await UniswapDapp.connectIOS(); + await UniswapDapp.selectWalletConnectOption(); + }); + } + + // Android comes from a webAction so needs to be in native context + if (platform === 'android') { + await PlaywrightContextHelpers.withNativeAction(async () => { + await UniswapDapp.tapOnMetaMaskWalletOptionAndOpenDeeplink(); + }); + } else { + // iOS comes from a nativeAction so no need to change context + await UniswapDapp.tapOnMetaMaskWalletOptionAndOpenDeeplink(); + } + + metamaskTimer.start(); + + // Still on Native Context + await unlockIfLockScreenVisible(); + metamaskTimer.stop(); + await DappConnectionModal.tapEditAccountsButton(); + await DappConnectionModal.tapUpdateAccountsButton(); + + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + + connectTimer.start(); + + await switchToMobileBrowser(); + + if (platform === 'android') { + await UniswapDapp.isUniswapDisplayed(); + } + + connectTimer.stop(); + + performanceTracker.addTimers(metamaskTimer, connectTimer); + }, + ); +}); From 5dd99a15825fd658a71306322382c1ada830d8c3 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Wed, 6 May 2026 21:41:55 +0800 Subject: [PATCH 08/27] feat: add new hardware wallet signing qr code (#29737) ## **Description** Wires the QR scan errors from PR 1 into pairing and signing flows. QR scan failures now surface through the hardware wallet bottom sheet with retry support, signing confirmations can reopen the scanner from the error CTA, and replacement transaction gas params are normalized through the shared helper. This is PR 2 of 3 and is stacked on #29388. ## **Changelog** CHANGELOG entry: Improved retry behavior when QR hardware wallet signing scans fail ## **Related issues** Refs: MUL-1665 ## **Manual testing steps** ```gherkin Feature: QR hardware signing retry Scenario: user retries a failed QR signing scan Given the user is signing with a QR-based hardware wallet When the user scans an invalid QR code Then the hardware wallet bottom sheet displays the QR scan error When the user taps Try again Then the QR scanner reopens for another signing scan ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. Made with [Cursor](https://cursor.com) --- > [!NOTE] > **Medium Risk** > Moderate risk because it changes the hardware-wallet bottom-sheet recovery flow and retry behavior for QR signing, which could affect signing UX/state transitions. Adds new analytics properties for QR scan failures; incorrect classification could skew metrics but not funds. > > **Overview** > **Improves QR hardware wallet scan-failure recovery during pairing/signing.** QR scan errors now route through the hardware wallet bottom sheet with a dedicated retry path that can reopen the QR scanner for signing retries, plus a provider-level `setQrScanRetryHandler`/`onRetryQrScan` mechanism to coordinate retries outside provider-managed flows. > > **Adds QR scan error-specific UI + analytics.** `ErrorContent` treats QR scan errors specially (custom title, *Try again* + *Learn more*, no generic icon) and supports `OPEN_SETTINGS` recovery; `useHardwareWalletAnalytics` now attaches QR scan metadata (`error_category`, `is_ur_format`, optional `received_ur_type`) to recovery viewed/CTA/success events. > > Includes supporting refactors and tests (QR adapter no longer emits `AppOpened` on `ensureDeviceReady`, new `isQRHardwareScanError`, `useQrScanErrorForwarding`, `useIsConfirmationFromQrAccount`, and `useQrConfirm`). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 31443d4a9e95d03b2f0d2695db58a6ab2f7389ae. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../HardwareWallet/HardwareWalletProvider.tsx | 28 ++ .../adapters/QRWalletAdapter.test.ts | 7 +- .../adapters/QRWalletAdapter.ts | 10 - .../HardwareWallet/analytics/helpers.test.ts | 104 +++++++ app/core/HardwareWallet/analytics/helpers.ts | 29 ++ app/core/HardwareWallet/analytics/index.ts | 1 + .../useHardwareWalletAnalytics.test.ts | 63 ++++ .../analytics/useHardwareWalletAnalytics.ts | 11 + .../HardwareWalletBottomSheet.test.tsx | 284 +++++++++++++++--- .../HardwareWalletBottomSheet.tsx | 33 +- .../AwaitingConfirmationContent.test.tsx | 54 ++++ .../contents/AwaitingConfirmationContent.tsx | 30 +- .../contents/ErrorContent.test.tsx | 203 +++++++++++-- .../contents/ErrorContent.tsx | 103 +++++-- .../contexts/HardwareWalletContext.tsx | 2 + app/core/HardwareWallet/errors/index.ts | 1 + .../errors/qrHardwareScanError.ts | 6 + app/core/HardwareWallet/errors/qrScan.ts | 5 +- app/core/HardwareWallet/hooks/index.ts | 1 + .../useIsConfirmationFromQrAccount.test.ts | 119 ++++++++ .../hooks/useIsConfirmationFromQrAccount.ts | 19 ++ .../HardwareWallet/hooks/useQrConfirm.test.ts | 246 +++++++++++++++ app/core/HardwareWallet/hooks/useQrConfirm.ts | 119 ++++++++ .../hooks/useQrScanErrorForwarding.test.ts | 70 +++++ .../hooks/useQrScanErrorForwarding.ts | 38 +++ 25 files changed, 1487 insertions(+), 99 deletions(-) create mode 100644 app/core/HardwareWallet/hooks/useIsConfirmationFromQrAccount.test.ts create mode 100644 app/core/HardwareWallet/hooks/useIsConfirmationFromQrAccount.ts create mode 100644 app/core/HardwareWallet/hooks/useQrConfirm.test.ts create mode 100644 app/core/HardwareWallet/hooks/useQrConfirm.ts create mode 100644 app/core/HardwareWallet/hooks/useQrScanErrorForwarding.test.ts create mode 100644 app/core/HardwareWallet/hooks/useQrScanErrorForwarding.ts diff --git a/app/core/HardwareWallet/HardwareWalletProvider.tsx b/app/core/HardwareWallet/HardwareWalletProvider.tsx index 7af5f7add2f..d5ebe72d2c7 100644 --- a/app/core/HardwareWallet/HardwareWalletProvider.tsx +++ b/app/core/HardwareWallet/HardwareWalletProvider.tsx @@ -88,6 +88,7 @@ export const HardwareWalletProvider: React.FC = ({ const awaitingConfirmationRejectRef = useRef<(() => void) | null>(null); const operationTypeRef = useRef<'transaction' | 'message' | null>(null); + const qrScanRetryHandlerRef = useRef<(() => void) | null>(null); const [analyticsFlow, setAnalyticsFlow] = useState( HardwareWalletAnalyticsFlow.Connection, @@ -136,6 +137,10 @@ export const HardwareWalletProvider: React.FC = ({ [handleError], ); + const setQrScanRetryHandler = useCallback((handler: (() => void) | null) => { + qrScanRetryHandlerRef.current = handler; + }, []); + const showAwaitingConfirmation = useCallback( (operationType: 'transaction' | 'message', onReject?: () => void) => { DevLogger.log( @@ -187,6 +192,26 @@ export const HardwareWalletProvider: React.FC = ({ } await retryEnsureDeviceReady(); }, [handleCloseFlow, retryEnsureDeviceReady]); + + const handleRetryQrScan = useCallback(() => { + if (operationTypeRef.current !== null) { + updateConnectionState({ + status: ConnectionStatus.AwaitingConfirmation, + deviceId: deviceId ?? 'unknown', + operationType: operationTypeRef.current, + }); + return; + } + + const retryQrScan = qrScanRetryHandlerRef.current; + updateConnectionState({ status: ConnectionStatus.Disconnected }); + if (!retryQrScan) { + return; + } + + retryQrScan(); + }, [deviceId, updateConnectionState]); + const handleAwaitingConfirmationCancel = useCallback(() => { DevLogger.log('[HardwareWallet] handleAwaitingConfirmationCancel'); const onReject = awaitingConfirmationRejectRef.current; @@ -248,6 +273,7 @@ export const HardwareWalletProvider: React.FC = ({ setTargetWalletType: setters.setTargetWalletType, setPendingOperationAddress, showHardwareWalletError, + setQrScanRetryHandler, showAwaitingConfirmation, hideAwaitingConfirmation, qr: qrSigningValue, @@ -261,6 +287,7 @@ export const HardwareWalletProvider: React.FC = ({ setters.setTargetWalletType, setPendingOperationAddress, showHardwareWalletError, + setQrScanRetryHandler, showAwaitingConfirmation, hideAwaitingConfirmation, qrSigningValue, @@ -282,6 +309,7 @@ export const HardwareWalletProvider: React.FC = ({ onAwaitingConfirmationCancel={handleAwaitingConfirmationCancel} onConnectionSuccess={handleBottomSheetConnectionSuccess} onCTAClicked={trackCTAClicked} + onRetryQrScan={handleRetryQrScan} /> ); diff --git a/app/core/HardwareWallet/adapters/QRWalletAdapter.test.ts b/app/core/HardwareWallet/adapters/QRWalletAdapter.test.ts index 39a73c4f63f..b02fe029338 100644 --- a/app/core/HardwareWallet/adapters/QRWalletAdapter.test.ts +++ b/app/core/HardwareWallet/adapters/QRWalletAdapter.test.ts @@ -128,13 +128,11 @@ describe('QRWalletAdapter', () => { ).rejects.toThrow('Adapter has been destroyed'); }); - it('returns true and emits AppOpened (QR wallets are always ready)', async () => { + it('returns true when camera permission is granted (QR wallets are always ready)', async () => { const result = await adapter.ensureDeviceReady('qr-account-address'); expect(result).toBe(true); - expect(onDeviceEvent).toHaveBeenCalledWith({ - event: DeviceEvent.AppOpened, - }); + expect(onDeviceEvent).not.toHaveBeenCalled(); }); it('stores device ID', async () => { @@ -150,6 +148,7 @@ describe('QRWalletAdapter', () => { const result = await adapter.ensureDeviceReady('qr-account-address'); expect(result).toBe(false); + expect(mockRequestCameraPermission).not.toHaveBeenCalled(); expect(adapter.getConnectedDeviceId()).toBeNull(); expect(adapter.isConnected()).toBe(false); expect(onDeviceEvent).toHaveBeenCalledWith({ diff --git a/app/core/HardwareWallet/adapters/QRWalletAdapter.ts b/app/core/HardwareWallet/adapters/QRWalletAdapter.ts index 17ea1f0f864..e76f18e6b81 100644 --- a/app/core/HardwareWallet/adapters/QRWalletAdapter.ts +++ b/app/core/HardwareWallet/adapters/QRWalletAdapter.ts @@ -114,12 +114,6 @@ export class QRWalletAdapter implements HardwareWalletAdapter { DevLogger.log('[QRWalletAdapter] Device is ready'); - // For QR wallets, we consider the "app" to always be open - // since there's no app concept like on Ledger - this.#emitEvent({ - event: DeviceEvent.AppOpened, - }); - return true; } @@ -271,12 +265,8 @@ export class QRWalletAdapter implements HardwareWalletAdapter { if (newStatus === 'granted') { return true; } - - this.#emitCameraPermissionDenied(); - return false; } - // status === 'denied' - emit error event this.#emitCameraPermissionDenied(); return false; } catch (error) { diff --git a/app/core/HardwareWallet/analytics/helpers.test.ts b/app/core/HardwareWallet/analytics/helpers.test.ts index 6f17db5f33c..2f83faf78f5 100644 --- a/app/core/HardwareWallet/analytics/helpers.test.ts +++ b/app/core/HardwareWallet/analytics/helpers.test.ts @@ -16,7 +16,10 @@ import { getAnalyticsDeviceType, getErrorDetails, getAnalyticsFlowFromApproval, + getQrHardwareScanErrorAnalyticsProperties, } from './helpers'; +import { createQRHardwareScanError, QRHardwareScanErrorType } from '../errors'; +import { QrScanRequestType } from '@metamask/eth-qr-keyring'; describe('analytics helpers', () => { describe('getAnalyticsErrorType', () => { @@ -362,4 +365,105 @@ describe('analytics helpers', () => { ); }); }); + + describe('getQrHardwareScanErrorAnalyticsProperties', () => { + it('returns empty object for non-ErrorState', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.Disconnected, + }; + + expect(getQrHardwareScanErrorAnalyticsProperties(state)).toEqual({}); + }); + + it('returns empty object for ErrorState with non-QR error', () => { + const error = new HardwareWalletError('Test', { + code: ErrorCode.Unknown, + severity: Severity.Err, + category: Category.Connection, + userMessage: 'Test', + }); + + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error, + }; + + expect(getQrHardwareScanErrorAnalyticsProperties(state)).toEqual({}); + }); + + it('returns QR scan properties for non-UR QR scanned error', () => { + const qrError = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.NonURQrScanned, + purpose: QrScanRequestType.SIGN, + isUrFormat: false, + }); + + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: qrError, + }; + + const result = getQrHardwareScanErrorAnalyticsProperties(state); + expect(result).toEqual({ + error_category: 'non_ur_qr_scanned', + is_ur_format: false, + }); + }); + + it('returns received_ur_type for wrong UR type error', () => { + const qrError = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.WrongURType, + purpose: QrScanRequestType.SIGN, + receivedUrType: 'crypto-account', + isUrFormat: true, + }); + + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: qrError, + }; + + const result = getQrHardwareScanErrorAnalyticsProperties(state); + expect(result).toEqual({ + error_category: 'wrong_ur_type', + is_ur_format: true, + received_ur_type: 'crypto-account', + }); + }); + + it('returns empty string for received_ur_type when not provided', () => { + const qrError = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.WrongURType, + purpose: QrScanRequestType.PAIR, + isUrFormat: true, + }); + + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: qrError, + }; + + const result = getQrHardwareScanErrorAnalyticsProperties(state); + expect(result.received_ur_type).toBe(''); + }); + + it('returns QR scan properties for UR decode error', () => { + const qrError = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.URDecodeError, + purpose: QrScanRequestType.SIGN, + isUrFormat: true, + }); + + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: qrError, + }; + + const result = getQrHardwareScanErrorAnalyticsProperties(state); + expect(result).toEqual({ + error_category: 'ur_decode_error', + is_ur_format: true, + }); + }); + }); }); diff --git a/app/core/HardwareWallet/analytics/helpers.ts b/app/core/HardwareWallet/analytics/helpers.ts index 45cec023278..7d3bb9fe884 100644 --- a/app/core/HardwareWallet/analytics/helpers.ts +++ b/app/core/HardwareWallet/analytics/helpers.ts @@ -4,6 +4,7 @@ import { HardwareWalletConnectionState, ConnectionStatus, } from '@metamask/hw-wallet-sdk'; +import { isQRHardwareScanError, QRHardwareScanErrorType } from '../errors'; import { ApprovalType } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; @@ -186,3 +187,31 @@ export function getErrorDetails( } return { error_code: '', error_message: '' }; } + +/** + * Segment/MetaMetrics properties for QR hardware camera scan failures + * (`Hardware Wallet Connection Failed` / recovery UI), when the connection + * {@link ConnectionStatus.ErrorState} error is a {@link isQRHardwareScanError}. + */ +export function getQrHardwareScanErrorAnalyticsProperties( + connectionState: HardwareWalletConnectionState, +): Record { + if (connectionState.status !== ConnectionStatus.ErrorState) { + return {}; + } + const { error } = connectionState; + if (!isQRHardwareScanError(error)) { + return {}; + } + const metadata = error.metadata; + const payload: Record = { + error_category: metadata.qrHardwareScanErrorType, + is_ur_format: metadata.isUrFormat, + }; + if ( + metadata.qrHardwareScanErrorType === QRHardwareScanErrorType.WrongURType + ) { + payload.received_ur_type = metadata.receivedUrType ?? ''; + } + return payload; +} diff --git a/app/core/HardwareWallet/analytics/index.ts b/app/core/HardwareWallet/analytics/index.ts index 3c51279adcc..e394183956a 100644 --- a/app/core/HardwareWallet/analytics/index.ts +++ b/app/core/HardwareWallet/analytics/index.ts @@ -6,6 +6,7 @@ export { getErrorTypeFromConnectionState, getAnalyticsDeviceType, getErrorDetails, + getQrHardwareScanErrorAnalyticsProperties, } from './helpers'; export { useHardwareWalletAnalytics } from './useHardwareWalletAnalytics'; diff --git a/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.test.ts b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.test.ts index 4e4bd3ff5c8..0dd62771d96 100644 --- a/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.test.ts +++ b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.test.ts @@ -14,6 +14,8 @@ import { HardwareWalletAnalyticsFlow, } from './helpers'; import { MetaMetricsEvents } from '../../Analytics'; +import { createQRHardwareScanError, QRHardwareScanErrorType } from '../errors'; +import { QrScanRequestType } from '@metamask/eth-qr-keyring'; const mockTrackEvent = jest.fn(); const mockBuild = jest.fn().mockReturnValue({ name: 'built-event' }); @@ -86,6 +88,67 @@ describe('useHardwareWalletAnalytics', () => { expect(mockTrackEvent).toHaveBeenCalled(); }); + it('includes QR scan analytics when error is a QR hardware scan failure', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + const qrError = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.NonURQrScanned, + purpose: QrScanRequestType.PAIR, + isUrFormat: false, + }); + + rerender({ + ...defaultOptions, + walletType: HardwareWalletType.Qr, + connectionState: { + status: ConnectionStatus.ErrorState, + error: qrError, + }, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + error_type: HardwareWalletAnalyticsErrorType.GenericError, + error_category: 'non_ur_qr_scanned', + is_ur_format: false, + }), + ); + }); + + it('includes received_ur_type for wrong UR type QR scan errors', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + const qrError = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.WrongURType, + purpose: QrScanRequestType.SIGN, + receivedUrType: 'eth-signature', + isUrFormat: true, + }); + + rerender({ + ...defaultOptions, + walletType: HardwareWalletType.Qr, + connectionState: { + status: ConnectionStatus.ErrorState, + error: qrError, + }, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + error_category: 'wrong_ur_type', + is_ur_format: true, + received_ur_type: 'eth-signature', + }), + ); + }); + it('fires when transitioning to AwaitingApp', () => { const { rerender } = renderHook( (props) => useHardwareWalletAnalytics(props), diff --git a/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.ts b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.ts index 803658f0998..b0a6ff0ddf1 100644 --- a/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.ts +++ b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.ts @@ -12,6 +12,7 @@ import { getErrorTypeFromConnectionState, getAnalyticsDeviceType, getErrorDetails, + getQrHardwareScanErrorAnalyticsProperties, type ErrorDetails, } from './helpers'; @@ -59,6 +60,7 @@ export function useHardwareWalletAnalytics({ error_code: '', error_message: '', }); + const lastQrScanAnalyticsRef = useRef>({}); const prevStatusRef = useRef(ConnectionStatus.Disconnected); const resetAnalyticsState = useCallback(() => { @@ -66,6 +68,7 @@ export function useHardwareWalletAnalytics({ lastErrorTypeRef.current = null; lastErrorTypeViewCountRef.current = 0; lastErrorDetailsRef.current = { error_code: '', error_message: '' }; + lastQrScanAnalyticsRef.current = {}; }, []); useEffect(() => { @@ -90,6 +93,9 @@ export function useHardwareWalletAnalytics({ viewCountsRef.current.set(errorType, newCount); const errorDetails = getErrorDetails(connectionState); + const qrScanAnalytics = + getQrHardwareScanErrorAnalyticsProperties(connectionState); + lastQrScanAnalyticsRef.current = qrScanAnalytics; lastErrorTypeRef.current = errorType; lastErrorTypeViewCountRef.current = newCount; @@ -107,6 +113,7 @@ export function useHardwareWalletAnalytics({ error_type_view_count: newCount, error_code: errorDetails.error_code, error_message: errorDetails.error_message, + ...qrScanAnalytics, }) .build(), ); @@ -128,6 +135,7 @@ export function useHardwareWalletAnalytics({ error_type_view_count: lastErrorTypeViewCountRef.current, error_code: lastErrorDetailsRef.current.error_code, error_message: lastErrorDetailsRef.current.error_message, + ...lastQrScanAnalyticsRef.current, }), }) .build(), @@ -150,6 +158,8 @@ export function useHardwareWalletAnalytics({ if (!errorType) return; const errorDetails = getErrorDetails(connectionState); + const qrScanAnalytics = + getQrHardwareScanErrorAnalyticsProperties(connectionState); trackEvent( createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_CTA_CLICKED) @@ -161,6 +171,7 @@ export function useHardwareWalletAnalytics({ error_type_view_count: viewCountsRef.current.get(errorType) ?? 1, error_code: errorDetails.error_code, error_message: errorDetails.error_message, + ...qrScanAnalytics, }) .build(), ); diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.test.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.test.tsx index 43acce80ba5..21b22431fe9 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.test.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.test.tsx @@ -17,6 +17,7 @@ const mockDeviceSelection = { }; const mockActions = { retryEnsureDeviceReady: jest.fn(), + onRetryQrScan: jest.fn(), selectDevice: jest.fn(), rescan: jest.fn(), connect: jest.fn(), @@ -55,34 +56,44 @@ jest.mock('./contents', () => ({ }, })); -// Track BottomSheet onClose callback -let lastBottomSheetOnClose: (() => void) | undefined; +// Track BottomSheet onClose callback (`mock*` prefix: required for use inside jest.mock factory) +const mockLastBottomSheetOnCloseRef = { + current: undefined as (() => void) | undefined, +}; // Mock bottom sheet jest.mock( '../../../../component-library/components/BottomSheets/BottomSheet', () => { + const React = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return { __esModule: true, - default: ({ - children, - testID, - onClose, - }: { - children: React.ReactNode; - testID?: string; - onClose?: () => void; - }) => { - lastBottomSheetOnClose = onClose; - return {children}; - }, + default: React.forwardRef( + ( + { + children, + testID, + onClose, + }: { + children: React.ReactNode; + testID?: string; + onClose?: () => void; + }, + _ref: React.Ref, + ) => { + // Test double: capture BottomSheet onClose for assertions (not production UI). + // eslint-disable-next-line react-compiler/react-compiler -- intentional mock side effect + mockLastBottomSheetOnCloseRef.current = onClose; + return {children}; + }, + ), }; }, ); import React from 'react'; -import { render } from '@testing-library/react-native'; +import { act, render } from '@testing-library/react-native'; import { HardwareWalletError, ErrorCode, @@ -91,12 +102,17 @@ import { ConnectionStatus, HardwareWalletType, } from '@metamask/hw-wallet-sdk'; +import { QrScanRequestType } from '@metamask/eth-qr-keyring'; import { HardwareWalletBottomSheet, HardwareWalletBottomSheetProps, HARDWARE_WALLET_BOTTOM_SHEET_TEST_ID, } from './HardwareWalletBottomSheet'; +import { + createQRHardwareScanError, + QRHardwareScanErrorType, +} from '../../errors'; /** * Build default props using the mutable mock objects. @@ -303,10 +319,10 @@ describe('HardwareWalletBottomSheet', () => { it('renders when scanning with selected device', () => { mockConnectionState.status = ConnectionStatus.Scanning; - const device = { id: 'device-1', name: 'Nano X' }; + const mockDevice = { id: 'device-1', name: 'Nano X' }; Object.assign(mockDeviceSelection, { - devices: [device], - selectedDevice: device, + devices: [mockDevice], + selectedDevice: mockDevice, isScanning: false, }); const { getByTestId } = render( @@ -321,7 +337,7 @@ describe('HardwareWalletBottomSheet', () => { describe('handleClose behavior', () => { beforeEach(() => { - lastBottomSheetOnClose = undefined; + mockLastBottomSheetOnCloseRef.current = undefined; }); it('calls onClose when sheet closes during scanning', () => { @@ -331,8 +347,8 @@ describe('HardwareWalletBottomSheet', () => { , ); - expect(lastBottomSheetOnClose).toBeDefined(); - lastBottomSheetOnClose?.(); + expect(mockLastBottomSheetOnCloseRef.current).toBeDefined(); + mockLastBottomSheetOnCloseRef.current?.(); expect(onClose).toHaveBeenCalled(); }); @@ -344,8 +360,8 @@ describe('HardwareWalletBottomSheet', () => { , ); - expect(lastBottomSheetOnClose).toBeDefined(); - lastBottomSheetOnClose?.(); + expect(mockLastBottomSheetOnCloseRef.current).toBeDefined(); + mockLastBottomSheetOnCloseRef.current?.(); expect(onClose).toHaveBeenCalled(); }); @@ -360,8 +376,8 @@ describe('HardwareWalletBottomSheet', () => { , ); - expect(lastBottomSheetOnClose).toBeDefined(); - lastBottomSheetOnClose?.(); + expect(mockLastBottomSheetOnCloseRef.current).toBeDefined(); + mockLastBottomSheetOnCloseRef.current?.(); expect(onClose).toHaveBeenCalled(); }); @@ -382,8 +398,8 @@ describe('HardwareWalletBottomSheet', () => { , ); - expect(lastBottomSheetOnClose).toBeDefined(); - lastBottomSheetOnClose?.(); + expect(mockLastBottomSheetOnCloseRef.current).toBeDefined(); + mockLastBottomSheetOnCloseRef.current?.(); expect(onClose).toHaveBeenCalled(); }); @@ -398,8 +414,8 @@ describe('HardwareWalletBottomSheet', () => { , ); - expect(lastBottomSheetOnClose).toBeDefined(); - lastBottomSheetOnClose?.(); + expect(mockLastBottomSheetOnCloseRef.current).toBeDefined(); + mockLastBottomSheetOnCloseRef.current?.(); expect(onClose).toHaveBeenCalled(); }); @@ -417,8 +433,8 @@ describe('HardwareWalletBottomSheet', () => { />, ); - expect(lastBottomSheetOnClose).toBeDefined(); - lastBottomSheetOnClose?.(); + expect(mockLastBottomSheetOnCloseRef.current).toBeDefined(); + mockLastBottomSheetOnCloseRef.current?.(); expect(onAwaitingConfirmationCancel).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled(); @@ -436,9 +452,9 @@ describe('HardwareWalletBottomSheet', () => { it('calls selectDevice when device is selected', () => { mockConnectionState.status = ConnectionStatus.Scanning; - const device = { id: 'device-1', name: 'Nano X' }; + const mockDevice = { id: 'device-1', name: 'Nano X' }; Object.assign(mockDeviceSelection, { - devices: [device], + devices: [mockDevice], selectedDevice: null, isScanning: false, }); @@ -448,9 +464,9 @@ describe('HardwareWalletBottomSheet', () => { d: unknown, ) => void; expect(onSelectDevice).toBeDefined(); - onSelectDevice(device); + onSelectDevice(mockDevice); - expect(mockActions.selectDevice).toHaveBeenCalledWith(device); + expect(mockActions.selectDevice).toHaveBeenCalledWith(mockDevice); }); it('calls rescan when rescan is triggered', () => { @@ -480,10 +496,10 @@ describe('HardwareWalletBottomSheet', () => { it('calls connect when device selection is confirmed', async () => { mockConnectionState.status = ConnectionStatus.Scanning; - const device = { id: 'device-1', name: 'Nano X' }; + const mockDevice = { id: 'device-1', name: 'Nano X' }; Object.assign(mockDeviceSelection, { - devices: [device], - selectedDevice: device, + devices: [mockDevice], + selectedDevice: mockDevice, isScanning: false, }); mockActions.connect.mockResolvedValue(undefined); @@ -596,4 +612,196 @@ describe('HardwareWalletBottomSheet', () => { expect(mockActions.retryEnsureDeviceReady).toHaveBeenCalled(); }); }); + + describe('QR scan error recovery', () => { + it('calls onRetryQrScan instead of retryEnsureDeviceReady for QR scan errors', async () => { + Object.assign(mockConnectionState, { + status: ConnectionStatus.ErrorState, + error: createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.NonURQrScanned, + purpose: QrScanRequestType.SIGN, + isUrFormat: false, + }), + }); + + render( + , + ); + + await act(async () => { + await ( + lastErrorContentProps.onContinue as (() => Promise) | undefined + )?.(); + }); + + expect(mockActions.onRetryQrScan).toHaveBeenCalled(); + expect(mockActions.retryEnsureDeviceReady).not.toHaveBeenCalled(); + }); + + it('does not auto-open the QR scanner after retrying a pairing QR scan error', async () => { + Object.assign(mockConnectionState, { + status: ConnectionStatus.ErrorState, + error: createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.NonURQrScanned, + purpose: QrScanRequestType.PAIR, + isUrFormat: false, + }), + }); + + const { rerender } = render( + , + ); + + await act(async () => { + await ( + lastErrorContentProps.onContinue as (() => Promise) | undefined + )?.(); + }); + + expect(mockActions.onRetryQrScan).toHaveBeenCalled(); + expect(mockActions.retryEnsureDeviceReady).not.toHaveBeenCalled(); + + Object.assign(mockConnectionState, { + status: ConnectionStatus.Disconnected, + }); + + rerender( + , + ); + + Object.assign(mockConnectionState, { + status: ConnectionStatus.AwaitingConfirmation, + deviceId: 'device-123', + operationType: 'transaction', + }); + + rerender( + , + ); + + expect(lastAwaitingConfirmationProps.openQrScannerOnMount).toBe(false); + }); + + it('clears QR scanner auto-open state before retrying non-QR scan errors', async () => { + Object.assign(mockConnectionState, { + status: ConnectionStatus.ErrorState, + error: createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.WrongURType, + purpose: QrScanRequestType.SIGN, + receivedUrType: 'crypto-account', + isUrFormat: true, + }), + }); + + const { rerender } = render( + , + ); + + await act(async () => { + await ( + lastErrorContentProps.onContinue as (() => Promise) | undefined + )?.(); + }); + + Object.assign(mockConnectionState, { + status: ConnectionStatus.ErrorState, + error: new HardwareWalletError('Test error', { + code: ErrorCode.Unknown, + severity: Severity.Err, + category: Category.Unknown, + userMessage: 'Test error', + }), + }); + + rerender( + , + ); + + await act(async () => { + await ( + lastErrorContentProps.onContinue as (() => Promise) | undefined + )?.(); + }); + + Object.assign(mockConnectionState, { + status: ConnectionStatus.AwaitingConfirmation, + deviceId: 'device-123', + operationType: 'transaction', + }); + + rerender( + , + ); + + expect(lastAwaitingConfirmationProps.openQrScannerOnMount).toBe(false); + }); + + it('auto-opens the QR scanner after retrying a QR scan error', async () => { + Object.assign(mockConnectionState, { + status: ConnectionStatus.ErrorState, + error: createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.WrongURType, + purpose: QrScanRequestType.SIGN, + receivedUrType: 'crypto-account', + isUrFormat: true, + }), + }); + + const { rerender } = render( + , + ); + + await act(async () => { + await ( + lastErrorContentProps.onContinue as (() => Promise) | undefined + )?.(); + }); + + Object.assign(mockConnectionState, { + status: ConnectionStatus.AwaitingConfirmation, + deviceId: 'device-123', + operationType: 'transaction', + }); + + rerender( + , + ); + + expect(lastAwaitingConfirmationProps.openQrScannerOnMount).toBe(true); + + act(() => { + ( + lastAwaitingConfirmationProps.onQrScannerOpened as + | (() => void) + | undefined + )?.(); + }); + + rerender( + , + ); + + expect(lastAwaitingConfirmationProps.openQrScannerOnMount).toBe(false); + }); + }); }); diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx index 7c3eac4651b..09cc9dda61c 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx @@ -12,6 +12,8 @@ import { HardwareWalletConnectionState, ConnectionStatus, } from '@metamask/hw-wallet-sdk'; +import { QrScanRequestType } from '@metamask/eth-qr-keyring'; +import { isQRHardwareScanError } from '../../errors'; import { ConnectingContent, @@ -54,6 +56,8 @@ export interface HardwareWalletBottomSheetProps { onAwaitingConfirmationCancel?: () => void; /** Callback fired when the user taps the CTA on an error/recovery screen. */ onCTAClicked?: () => void; + /** Callback when the user retries a QR scan error from the bottom sheet. */ + onRetryQrScan?: () => void; } /** @@ -82,11 +86,13 @@ export const HardwareWalletBottomSheet: React.FC< onConnectionSuccess, onAwaitingConfirmationCancel, onCTAClicked, + onRetryQrScan, }) => { const { colors } = useTheme(); const styles = useMemo(() => createStyles(colors), [colors]); const bottomSheetRef = useRef(null); + const [openQrScannerOnMount, setOpenQrScannerOnMount] = React.useState(false); const { devices, selectedDevice, isScanning } = deviceSelection; @@ -128,8 +134,27 @@ export const HardwareWalletBottomSheet: React.FC< const handleErrorContinue = useCallback(async () => { onCTAClicked?.(); + if ( + walletType === HardwareWalletType.Qr && + connectionState.status === ConnectionStatus.ErrorState && + isQRHardwareScanError(connectionState.error) + ) { + const qrErrorMetadata = connectionState.error.metadata; + setOpenQrScannerOnMount( + qrErrorMetadata.qrScanPurpose === QrScanRequestType.SIGN, + ); + onRetryQrScan?.(); + return; + } + setOpenQrScannerOnMount(false); await retryEnsureDeviceReady(); - }, [retryEnsureDeviceReady, onCTAClicked]); + }, [ + connectionState, + onCTAClicked, + onRetryQrScan, + retryEnsureDeviceReady, + walletType, + ]); const handleErrorDismiss = useCallback(() => { onCTAClicked?.(); @@ -165,6 +190,10 @@ export const HardwareWalletBottomSheet: React.FC< onClose(); }, [onClose]); + const handleQrScannerOpened = useCallback(() => { + setOpenQrScannerOnMount(false); + }, []); + const renderContent = () => { if (!walletType) return null; switch (connectionState.status) { @@ -210,6 +239,8 @@ export const HardwareWalletBottomSheet: React.FC< deviceType={walletType} operationType={connectionState.operationType} onCancel={handleAwaitingConfirmationCancel} + openQrScannerOnMount={openQrScannerOnMount} + onQrScannerOpened={handleQrScannerOpened} /> ); diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.test.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.test.tsx index 2a71c07fac7..814081b1666 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.test.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.test.tsx @@ -59,15 +59,29 @@ jest.mock('../../../../../components/UI/QRHardware/AnimatedQRScanner', () => ({ __esModule: true, default: ({ hideModal, + onQRHardwareScanError, + onModalHideComplete, onScanError, onScanSuccess, visible, }: { hideModal: () => void; + onModalHideComplete?: () => void; + onQRHardwareScanError?: (error: Error) => void; onScanError: (error: string) => void; onScanSuccess: (ur: { cbor: string; type: string }) => void; visible: boolean; }) => { + const ActualReact = jest.requireActual('react'); + const prevVisibleRef = ActualReact.useRef(visible); + + ActualReact.useEffect(() => { + if (prevVisibleRef.current && !visible) { + onModalHideComplete?.(); + } + prevVisibleRef.current = visible; + }, [visible, onModalHideComplete]); + if (!visible) return null; return ( @@ -81,6 +95,13 @@ jest.mock('../../../../../components/UI/QRHardware/AnimatedQRScanner', () => ({ title="onScanError" onPress={() => onScanError('scan failed')} /> + + onQRHardwareScanError?.(new Error('qr hardware scan failed')) + } + /> { const renderComponent = ( props = {}, qrSigningOverrides?: Partial, + contextOverrides?: Partial, ) => renderWithProvider( { expect(getByTestId('animated-qr-scanner-mock')).toBeOnTheScreen(); }); + it('opens scanner on mount after QR scan error retry', () => { + const onQrScannerOpened = jest.fn(); + const { getByTestId } = renderComponent( + { + deviceType: HardwareWalletType.Qr, + openQrScannerOnMount: true, + onQrScannerOpened, + }, + qrSigningOverrides, + ); + + expect(getByTestId('animated-qr-scanner-mock')).toBeOnTheScreen(); + expect(onQrScannerOpened).toHaveBeenCalledTimes(1); + }); + it('renders spinner in QR flow when not signing QR object', () => { const { getByTestId, queryByTestId } = renderComponent( { deviceType: HardwareWalletType.Qr }, @@ -405,6 +443,22 @@ describe('AwaitingConfirmationContent', () => { expect(getByText('scan failed')).toBeOnTheScreen(); }); + it('routes QR hardware scan errors to hardware wallet error state', () => { + const showHardwareWalletError = jest.fn(); + const { getByTestId } = renderComponent( + { deviceType: HardwareWalletType.Qr }, + qrSigningOverrides, + { showHardwareWalletError }, + ); + + openScanner(getByTestId); + fireEvent.press(getByTestId('scanner-qr-hardware-error-btn')); + + expect(showHardwareWalletError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'qr hardware scan failed' }), + ); + }); + it('dismisses error message when alert is pressed', () => { const { stringify } = jest.requireMock('uuid'); stringify.mockReturnValueOnce('different-request-id'); diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.tsx index 5e57f6d0148..8cb44cea0dc 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.tsx @@ -27,6 +27,7 @@ import { HardwareWalletType } from '@metamask/hw-wallet-sdk'; import { getHardwareWalletTypeName } from '../../../helpers'; import { ContentLayout } from './ContentLayout'; import { useHardwareWallet } from '../../../contexts'; +import { useQrScanErrorForwarding } from '../../../hooks/useQrScanErrorForwarding'; import Engine from '../../../../Engine'; import AnimatedQRCode from '../../../../../components/UI/QRHardware/AnimatedQRCode'; import AnimatedQRScannerModal from '../../../../../components/UI/QRHardware/AnimatedQRScanner'; @@ -73,11 +74,21 @@ export interface AwaitingConfirmationContentProps { operationType?: string; /** Optional callback when user wants to cancel/reject */ onCancel?: () => void; + /** Open the QR scanner as soon as this content mounts after QR error retry. */ + openQrScannerOnMount?: boolean; + /** Callback fired after the mount-triggered QR scanner has opened. */ + onQrScannerOpened?: () => void; } export const AwaitingConfirmationContent: React.FC< AwaitingConfirmationContentProps -> = ({ deviceType, operationType, onCancel }) => { +> = ({ + deviceType, + operationType, + onCancel, + openQrScannerOnMount, + onQrScannerOpened, +}) => { const { colors } = useTheme(); const { createEventBuilder, trackEvent } = useAnalytics(); const { qr } = useHardwareWallet(); @@ -94,6 +105,15 @@ export const AwaitingConfirmationContent: React.FC< const [shouldPause, setShouldPause] = useState(false); const [errorMessage, setErrorMessage] = useState(); + useEffect(() => { + if (!openQrScannerOnMount || !isQrFlow || !isSigningQRObject) { + return; + } + + setScannerVisible(true); + onQrScannerOpened?.(); + }, [isQrFlow, isSigningQRObject, onQrScannerOpened, openQrScannerOnMount]); + useEffect(() => { if (!isSigningQRObject) { setScannerVisible(false); @@ -149,6 +169,12 @@ export const AwaitingConfirmationContent: React.FC< setErrorMessage(error); }, []); + const hideScanner = useCallback(() => { + setScannerVisible(false); + }, []); + const { onQRHardwareScanError, handleScannerModalHide } = + useQrScanErrorForwarding({ hideScanner }); + const onQrCancel = useCallback(async () => { setScannerVisible(false); try { @@ -286,6 +312,8 @@ export const AwaitingConfirmationContent: React.FC< purpose={QrScanRequestType.SIGN} onScanSuccess={onScanSuccess} onScanError={onScanError} + onQRHardwareScanError={onQRHardwareScanError} + onModalHideComplete={handleScannerModalHide} hideModal={() => setScannerVisible(false)} /> diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.test.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.test.tsx index 5aea536575c..b08c6d76317 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.test.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import { Linking } from 'react-native'; import { HardwareWalletError, HardwareWalletType, @@ -15,7 +16,14 @@ import { ERROR_CONTENT_TITLE_TEST_ID, ERROR_CONTENT_MESSAGE_TEST_ID, ERROR_CONTENT_CONTINUE_BUTTON_TEST_ID, + ERROR_CONTENT_LEARN_MORE_BUTTON_TEST_ID, } from './ErrorContent'; +import { + createQRHardwareScanError, + QRHardwareScanErrorType, + isQRHardwareScanError as actualIsQRHardwareScanError, +} from '../../../errors'; +import { QrScanRequestType } from '@metamask/eth-qr-keyring'; // Mock dependencies jest.mock('../../../../../util/theme', () => ({ @@ -33,16 +41,26 @@ jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => key, })); -jest.mock('../../../errors', () => ({ - getIconForErrorCode: jest.fn().mockReturnValue('Danger'), - getIconColorForErrorCode: jest.fn().mockReturnValue('Error'), - getTitleForErrorCode: jest.fn().mockReturnValue('Error Title'), - getRecoveryActionForErrorCode: jest.fn().mockReturnValue('retry'), - RecoveryAction: { - RETRY: 'retry', - ACKNOWLEDGE: 'acknowledge', - }, -})); +jest.mock('../../../errors', () => { + const actual = jest.requireActual('../../../errors/qrScan'); + const actualQr = jest.requireActual('../../../errors/qrHardwareScanError'); + return { + ...actual, + ...actualQr, + getIconForErrorCode: jest.fn().mockReturnValue('Danger'), + getIconColorForErrorCode: jest.fn().mockReturnValue('Error'), + getTitleForErrorCode: jest.fn().mockReturnValue('Error Title'), + getRecoveryActionForErrorCode: jest.fn().mockReturnValue('retry'), + getQRHardwareScanErrorTitle: jest + .fn() + .mockReturnValue('QR Scan Error Title'), + RecoveryAction: { + RETRY: 'retry', + ACKNOWLEDGE: 'acknowledge', + OPEN_SETTINGS: 'open_settings', + }, + }; +}); // Mock component library jest.mock('../../../../../component-library/components/Texts/Text', () => { @@ -61,17 +79,17 @@ jest.mock('../../../../../component-library/components/Texts/Text', () => { }; }); -jest.mock('../../../../../component-library/components/Buttons/Button', () => { +jest.mock('@metamask/design-system-react-native', () => { const { TouchableOpacity, Text, View } = jest.requireActual('react-native'); return { __esModule: true, - default: ({ - label, + Button: ({ + children, onPress, testID, isDisabled, }: { - label: React.ReactNode; + children: React.ReactNode; onPress?: () => void; testID?: string; isDisabled?: boolean; @@ -79,18 +97,17 @@ jest.mock('../../../../../component-library/components/Buttons/Button', () => { - {typeof label === 'string' ? ( - {label} + {typeof children === 'string' ? ( + {children} ) : ( - {label} + {children} )} ), - ButtonVariants: { Primary: 'Primary', Secondary: 'Secondary' }, - ButtonSize: { Lg: 'Lg' }, - ButtonWidthTypes: { Full: 'Full' }, + ButtonVariant: { Primary: 'primary', Secondary: 'secondary' }, + ButtonSize: { Lg: 'lg' }, }; }); @@ -127,6 +144,10 @@ describe('ErrorContent', () => { userMessage: userMessage ?? message, }); + beforeEach(() => { + jest.clearAllMocks(); + }); + it('renders with test ID', () => { const error = createError('Test error'); const { getByTestId } = render( @@ -271,4 +292,144 @@ describe('ErrorContent', () => { expect(onContinue).toHaveBeenCalledTimes(1); }); }); + + it('renders QR scan error title without the generic icon', () => { + const error = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.NonURQrScanned, + purpose: QrScanRequestType.SIGN, + isUrFormat: false, + }); + + const { queryByTestId, getByTestId } = render( + , + ); + + expect(queryByTestId(ERROR_CONTENT_ICON_TEST_ID)).toBeNull(); + expect(getByTestId(ERROR_CONTENT_TITLE_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders learn more button for QR scan errors', () => { + const error = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.WrongURType, + purpose: QrScanRequestType.SIGN, + receivedUrType: 'crypto-account', + isUrFormat: true, + }); + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(ERROR_CONTENT_LEARN_MORE_BUTTON_TEST_ID), + ).toBeOnTheScreen(); + expect( + getByTestId(ERROR_CONTENT_CONTINUE_BUTTON_TEST_ID), + ).toBeOnTheScreen(); + }); + + it('opens support article when learn more is pressed for QR scan errors', async () => { + const openUrlSpy = jest.spyOn(Linking, 'openURL'); + openUrlSpy.mockResolvedValueOnce(undefined); + const error = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.URDecodeError, + purpose: QrScanRequestType.SIGN, + isUrFormat: true, + }); + + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(ERROR_CONTENT_LEARN_MORE_BUTTON_TEST_ID)); + + await waitFor(() => { + expect(openUrlSpy).toHaveBeenCalledWith( + 'https://support.metamask.io/more-web3/wallets/hardware-wallet-hub/#qr-codean-gapped-wallets', + ); + }); + + openUrlSpy.mockRestore(); + }); + + it('calls onContinue when try again is pressed for QR scan errors', async () => { + const onContinue = jest.fn().mockResolvedValue(undefined); + const error = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.NonURQrScanned, + purpose: QrScanRequestType.SIGN, + isUrFormat: false, + }); + + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(ERROR_CONTENT_CONTINUE_BUTTON_TEST_ID)); + + await waitFor(() => { + expect(onContinue).toHaveBeenCalled(); + }); + }); + + it('calls onDismiss when continue pressed for ACKNOWLEDGE recovery action', async () => { + const onDismiss = jest.fn(); + const { getRecoveryActionForErrorCode } = + jest.requireMock('../../../errors'); + getRecoveryActionForErrorCode.mockReturnValue('acknowledge'); + const error = createError('Test error'); + + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(ERROR_CONTENT_CONTINUE_BUTTON_TEST_ID)); + + await waitFor(() => { + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + }); + + it('opens device settings when continue pressed for OPEN_SETTINGS recovery action', async () => { + const openSettingsSpy = jest.spyOn(Linking, 'openSettings'); + openSettingsSpy.mockResolvedValueOnce(undefined); + const { getRecoveryActionForErrorCode } = + jest.requireMock('../../../errors'); + getRecoveryActionForErrorCode.mockReturnValue('open_settings'); + const error = createError('Test error'); + + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(ERROR_CONTENT_CONTINUE_BUTTON_TEST_ID)); + + await waitFor(() => { + expect(openSettingsSpy).toHaveBeenCalledTimes(1); + }); + + openSettingsSpy.mockRestore(); + }); + + it('renders view settings label for OPEN_SETTINGS recovery action', () => { + const { getRecoveryActionForErrorCode } = + jest.requireMock('../../../errors'); + getRecoveryActionForErrorCode.mockReturnValue('open_settings'); + const error = createError('Test error'); + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(ERROR_CONTENT_CONTINUE_BUTTON_TEST_ID), + ).toBeOnTheScreen(); + }); }); diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.tsx index c43cbf0106f..b980ed39273 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.tsx @@ -1,15 +1,15 @@ import React, { useMemo, useCallback, useState } from 'react'; -import { StyleSheet } from 'react-native'; +import { Linking, StyleSheet } from 'react-native'; import { HardwareWalletError, HardwareWalletType, } from '@metamask/hw-wallet-sdk'; -import Button, { - ButtonVariants, +import { + Button, + ButtonVariant, ButtonSize, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; +} from '@metamask/design-system-react-native'; import Icon, { IconSize, } from '../../../../../component-library/components/Icons/Icon'; @@ -26,6 +26,8 @@ import { getIconColorForErrorCode, getTitleForErrorCode, getRecoveryActionForErrorCode, + getQRHardwareScanErrorTitle, + isQRHardwareScanError, RecoveryAction, } from '../../../errors'; import { ContentLayout } from './ContentLayout'; @@ -36,6 +38,11 @@ export const ERROR_CONTENT_TITLE_TEST_ID = 'error-content-title'; export const ERROR_CONTENT_MESSAGE_TEST_ID = 'error-content-message'; export const ERROR_CONTENT_CONTINUE_BUTTON_TEST_ID = 'error-content-continue-button'; +export const ERROR_CONTENT_LEARN_MORE_BUTTON_TEST_ID = + 'error-content-learn-more-button'; + +const QR_HARDWARE_LEARN_MORE_URL = + 'https://support.metamask.io/more-web3/wallets/hardware-wallet-hub/#qr-codean-gapped-wallets'; const styles = StyleSheet.create({ message: { @@ -76,22 +83,52 @@ export const ErrorContent: React.FC = ({ return getRecoveryActionForErrorCode(error.code); }, [error]); + const isQrScanError = useMemo( + () => Boolean(error && isQRHardwareScanError(error)), + [error], + ); + const showLoading = - recoveryAction === RecoveryAction.RETRY && (isLoading || isRetrying); + !isQrScanError && + recoveryAction === RecoveryAction.RETRY && + (isLoading || isRetrying); const errorTitle = useMemo(() => { if (!error) return strings('hardware_wallet.error.something_went_wrong'); + if (isQRHardwareScanError(error)) { + return getQRHardwareScanErrorTitle(error); + } return getTitleForErrorCode(error.code, deviceType); }, [error, deviceType]); const errorMessage = useMemo(() => error?.userMessage ?? null, [error]); + const buttonLabel = useMemo(() => { + if (isQrScanError) { + return strings('hardware_wallet.common.try_again'); + } + if (recoveryAction === RecoveryAction.OPEN_SETTINGS) { + return strings('hardware_wallet.error.view_settings'); + } + return strings('hardware_wallet.common.continue'); + }, [isQrScanError, recoveryAction]); + const handleContinue = useCallback(async () => { + if (isQrScanError) { + await onContinue?.(); + return; + } + if (recoveryAction === RecoveryAction.ACKNOWLEDGE) { onDismiss?.(); return; } + if (recoveryAction === RecoveryAction.OPEN_SETTINGS) { + await Linking.openSettings(); + return; + } + if (showLoading) return; setIsRetrying(true); @@ -100,7 +137,11 @@ export const ErrorContent: React.FC = ({ } finally { setIsRetrying(false); } - }, [onContinue, onDismiss, recoveryAction, showLoading]); + }, [isQrScanError, onContinue, onDismiss, recoveryAction, showLoading]); + + const handleLearnMore = useCallback(async () => { + await Linking.openURL(QR_HARDWARE_LEARN_MORE_URL); + }, []); if (!error) { return null; @@ -111,12 +152,14 @@ export const ErrorContent: React.FC = ({ testID={ERROR_CONTENT_TEST_ID} titleTestID={ERROR_CONTENT_TITLE_TEST_ID} icon={ - + isQrScanError ? undefined : ( + + ) } title={errorTitle} body={ @@ -132,16 +175,30 @@ export const ErrorContent: React.FC = ({ ) : undefined } footer={ - + ) : null} + + } /> ); diff --git a/app/core/HardwareWallet/contexts/HardwareWalletContext.tsx b/app/core/HardwareWallet/contexts/HardwareWalletContext.tsx index d9cc439eb03..cb87acd6e5d 100644 --- a/app/core/HardwareWallet/contexts/HardwareWalletContext.tsx +++ b/app/core/HardwareWallet/contexts/HardwareWalletContext.tsx @@ -41,6 +41,8 @@ export interface HardwareWalletContextValue { setPendingOperationAddress: (address: string | null) => void; /** Show a hardware wallet error in the bottom sheet. Use after ensureDeviceReady succeeds. */ showHardwareWalletError: (error: unknown) => void; + /** Register a retry handler for QR scan errors outside the provider-managed flows. */ + setQrScanRetryHandler?: (handler: (() => void) | null) => void; /** Show "awaiting confirmation" bottom sheet. */ showAwaitingConfirmation: ( operationType: 'transaction' | 'message', diff --git a/app/core/HardwareWallet/errors/index.ts b/app/core/HardwareWallet/errors/index.ts index f4fb1368562..18c7f613db2 100644 --- a/app/core/HardwareWallet/errors/index.ts +++ b/app/core/HardwareWallet/errors/index.ts @@ -15,6 +15,7 @@ export { parseErrorByType } from './parser'; export { createQRHardwareScanError, getQRHardwareScanErrorTitle, + isQRHardwareScanError, QRHardwareScanError, QRHardwareScanErrorType, } from './qrScan'; diff --git a/app/core/HardwareWallet/errors/qrHardwareScanError.ts b/app/core/HardwareWallet/errors/qrHardwareScanError.ts index 46f131ce991..f75bd126989 100644 --- a/app/core/HardwareWallet/errors/qrHardwareScanError.ts +++ b/app/core/HardwareWallet/errors/qrHardwareScanError.ts @@ -20,3 +20,9 @@ export class QRHardwareScanError extends HardwareWalletError { this.metadata = options.metadata; } } + +export function isQRHardwareScanError( + error: unknown, +): error is QRHardwareScanError { + return error instanceof QRHardwareScanError; +} diff --git a/app/core/HardwareWallet/errors/qrScan.ts b/app/core/HardwareWallet/errors/qrScan.ts index ac6708ad530..cf3aad64786 100644 --- a/app/core/HardwareWallet/errors/qrScan.ts +++ b/app/core/HardwareWallet/errors/qrScan.ts @@ -21,7 +21,10 @@ export { type QRHardwareScanErrorOptions, }; -export { QRHardwareScanError } from './qrHardwareScanError'; +export { + QRHardwareScanError, + isQRHardwareScanError, +} from './qrHardwareScanError'; interface CreateQRHardwareScanErrorParams { errorType: QRHardwareScanErrorType; diff --git a/app/core/HardwareWallet/hooks/index.ts b/app/core/HardwareWallet/hooks/index.ts index 87e74356a88..523c9c029b1 100644 --- a/app/core/HardwareWallet/hooks/index.ts +++ b/app/core/HardwareWallet/hooks/index.ts @@ -9,3 +9,4 @@ export { useTransportMonitoring } from './useTransportMonitoring'; export { useDeviceDiscovery } from './useDeviceDiscovery'; export { useDeviceConnectionFlow } from './useDeviceConnectionFlow'; export { useQRSigningState } from './useQRSigningState'; +export { useQrScanErrorForwarding } from './useQrScanErrorForwarding'; diff --git a/app/core/HardwareWallet/hooks/useIsConfirmationFromQrAccount.test.ts b/app/core/HardwareWallet/hooks/useIsConfirmationFromQrAccount.test.ts new file mode 100644 index 00000000000..dd7486ad95d --- /dev/null +++ b/app/core/HardwareWallet/hooks/useIsConfirmationFromQrAccount.test.ts @@ -0,0 +1,119 @@ +import { renderHookWithProvider } from '../../../util/test/renderWithProvider'; +import { + personalSignatureConfirmationState, + stakingDepositConfirmationState, +} from '../../../util/test/confirm-data-helpers'; +import { useIsConfirmationFromQrAccount } from './useIsConfirmationFromQrAccount'; + +jest.mock('../../../core/Engine', () => ({ + context: { + KeyringController: { + state: { + keyrings: [ + { + type: 'HD Key Tree', + accounts: ['0x935e73edb9ff52e23bac7f7e043a1ecd06d05477'], + }, + ], + }, + }, + }, +})); + +const resetMockKeyrings = () => { + jest.requireMock( + '../../../core/Engine', + ).context.KeyringController.state.keyrings = [ + { + type: 'HD Key Tree', + accounts: ['0x935e73edb9ff52e23bac7f7e043a1ecd06d05477'], + }, + ]; +}; + +describe('useIsConfirmationFromQrAccount', () => { + beforeEach(() => { + resetMockKeyrings(); + }); + + it('returns false when from address belongs to a non-QR keyring', () => { + const { result } = renderHookWithProvider( + () => useIsConfirmationFromQrAccount(), + { state: personalSignatureConfirmationState }, + ); + + expect(result.current).toBe(false); + }); + + it('returns true when from address belongs to a QR keyring', () => { + jest.requireMock( + '../../../core/Engine', + ).context.KeyringController.state.keyrings = [ + { + type: 'QR Hardware Wallet Device', + accounts: ['0x935e73edb9ff52e23bac7f7e043a1ecd06d05477'], + }, + ]; + + const { result } = renderHookWithProvider( + () => useIsConfirmationFromQrAccount(), + { state: personalSignatureConfirmationState }, + ); + + expect(result.current).toBe(true); + }); + + it('returns false when there is no from address', () => { + const stateWithNoFrom = { + ...stakingDepositConfirmationState, + engine: { + backgroundState: { + ...stakingDepositConfirmationState.engine.backgroundState, + ApprovalController: { + pendingApprovals: { + 'test-id': { + id: 'test-id', + origin: 'metamask', + type: 'transaction', + time: 1, + requestData: {}, + requestState: null, + expectsResult: false, + }, + }, + pendingApprovalCount: 1, + approvalFlows: [], + }, + TransactionController: { + transactions: [], + }, + }, + }, + }; + + const { result } = renderHookWithProvider( + () => useIsConfirmationFromQrAccount(), + { state: stateWithNoFrom }, + ); + + expect(result.current).toBe(false); + }); + + it('returns false when from address belongs to a Ledger keyring', () => { + jest.requireMock( + '../../../core/Engine', + ).context.KeyringController.state.keyrings = [ + { + type: 'Ledger Hardware', + accounts: ['0x935e73edb9ff52e23bac7f7e043a1ecd06d05477'], + }, + ]; + + const { result } = renderHookWithProvider( + () => useIsConfirmationFromQrAccount(), + { state: personalSignatureConfirmationState }, + ); + + expect(result.current).toBe(false); + }); +}); diff --git a/app/core/HardwareWallet/hooks/useIsConfirmationFromQrAccount.ts b/app/core/HardwareWallet/hooks/useIsConfirmationFromQrAccount.ts new file mode 100644 index 00000000000..58144cb9dfa --- /dev/null +++ b/app/core/HardwareWallet/hooks/useIsConfirmationFromQrAccount.ts @@ -0,0 +1,19 @@ +import { useMemo } from 'react'; + +import { isHardwareAccount } from '../../../util/address'; +import ExtendedKeyringTypes from '../../../constants/keyringTypes'; +import useApprovalRequest from '../../../components/Views/confirmations/hooks/useApprovalRequest'; +import { useTransactionMetadataRequest } from '../../../components/Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; + +export function useIsConfirmationFromQrAccount(): boolean { + const { approvalRequest } = useApprovalRequest(); + const transactionMetadata = useTransactionMetadataRequest(); + + return useMemo(() => { + const fromAddress = + (approvalRequest?.requestData?.from as string) || + (transactionMetadata?.txParams?.from as string); + if (!fromAddress) return false; + return !!isHardwareAccount(fromAddress, [ExtendedKeyringTypes.qr]); + }, [approvalRequest?.requestData?.from, transactionMetadata?.txParams?.from]); +} diff --git a/app/core/HardwareWallet/hooks/useQrConfirm.test.ts b/app/core/HardwareWallet/hooks/useQrConfirm.test.ts new file mode 100644 index 00000000000..28363dcb5d8 --- /dev/null +++ b/app/core/HardwareWallet/hooks/useQrConfirm.test.ts @@ -0,0 +1,246 @@ +import { renderHook, act } from '@testing-library/react-native'; + +const mockEnsureDeviceReady = jest.fn(); +const mockSetTargetWalletType = jest.fn(); +const mockShowAwaitingConfirmation = jest.fn(); +const mockHideAwaitingConfirmation = jest.fn(); +const mockShowHardwareWalletError = jest.fn(); +const mockIsUserCancellation = jest.fn().mockReturnValue(false); +const mockSetScannerVisible = jest.fn(); +const mockExecuteHardwareWalletOperation = jest.fn(); + +jest.mock('..', () => ({ + useHardwareWallet: () => ({ + ensureDeviceReady: mockEnsureDeviceReady, + setTargetWalletType: mockSetTargetWalletType, + showAwaitingConfirmation: mockShowAwaitingConfirmation, + hideAwaitingConfirmation: mockHideAwaitingConfirmation, + showHardwareWalletError: mockShowHardwareWalletError, + }), + isUserCancellation: (...args: unknown[]) => mockIsUserCancellation(...args), + executeHardwareWalletOperation: (...args: unknown[]) => + mockExecuteHardwareWalletOperation(...args), +})); + +const mockApprovalRequest = { requestData: { from: '0xTestAddress' } }; +const mockTransactionMetadata = { txParams: { from: '0xTestAddress' } }; + +jest.mock( + '../../../components/Views/confirmations/hooks/useApprovalRequest', + () => ({ + __esModule: true, + default: () => ({ approvalRequest: mockApprovalRequest }), + }), +); + +jest.mock( + '../../../components/Views/confirmations/hooks/transactions/useTransactionMetadataRequest', + () => ({ + useTransactionMetadataRequest: () => mockTransactionMetadata, + }), +); + +const mockIsSigningQRObject = { current: false }; + +jest.mock( + '../../../components/Views/confirmations/context/qr-hardware-context', + () => ({ + useQRHardwareContext: () => ({ + isSigningQRObject: mockIsSigningQRObject.current, + setScannerVisible: mockSetScannerVisible, + }), + }), +); + +import { useQrConfirm } from './useQrConfirm'; + +describe('useQrConfirm', () => { + const onReject = jest.fn(); + const onTransactionConfirm = jest.fn().mockResolvedValue(undefined); + const executeApproval = jest.fn().mockResolvedValue(undefined); + + const defaultOptions = { + onReject, + onTransactionConfirm, + executeApproval, + isTransactionReq: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockIsSigningQRObject.current = false; + mockExecuteHardwareWalletOperation.mockResolvedValue(true); + }); + + it('opens scanner when QR signing is already in progress', async () => { + mockIsSigningQRObject.current = true; + + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(mockSetScannerVisible).toHaveBeenCalledWith(true); + expect(mockExecuteHardwareWalletOperation).not.toHaveBeenCalled(); + }); + + it('calls rejectOnce when no fromAddress is available', async () => { + const originalRequestData = mockApprovalRequest.requestData; + const originalTxParams = mockTransactionMetadata.txParams; + mockApprovalRequest.requestData = + {} as typeof mockApprovalRequest.requestData; + mockTransactionMetadata.txParams = + {} as typeof mockTransactionMetadata.txParams; + + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(onReject).toHaveBeenCalledTimes(1); + expect(mockExecuteHardwareWalletOperation).not.toHaveBeenCalled(); + + mockApprovalRequest.requestData = originalRequestData; + mockTransactionMetadata.txParams = originalTxParams; + }); + + it('calls executeHardwareWalletOperation for message signing', async () => { + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(mockExecuteHardwareWalletOperation).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0xTestAddress', + operationType: 'message', + }), + ); + }); + + it('calls executeHardwareWalletOperation for transaction with transaction type', async () => { + const { result } = renderHook(() => + useQrConfirm({ ...defaultOptions, isTransactionReq: true }), + ); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(mockExecuteHardwareWalletOperation).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0xTestAddress', + operationType: 'transaction', + }), + ); + }); + + it('calls onTransactionConfirm inside execute for transaction requests', async () => { + mockExecuteHardwareWalletOperation.mockImplementation( + async ({ execute }) => { + await execute(); + }, + ); + + const { result } = renderHook(() => + useQrConfirm({ ...defaultOptions, isTransactionReq: true }), + ); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(onTransactionConfirm).toHaveBeenCalledWith({ + onError: expect.any(Function), + }); + expect(executeApproval).not.toHaveBeenCalled(); + }); + + it('calls executeApproval inside execute for message requests', async () => { + mockExecuteHardwareWalletOperation.mockImplementation( + async ({ execute }) => { + await execute(); + }, + ); + + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(executeApproval).toHaveBeenCalledTimes(1); + expect(onTransactionConfirm).not.toHaveBeenCalled(); + }); + + it('shows error and rejects on non-user-cancellation error', async () => { + const signingError = new Error('signing failed'); + mockExecuteHardwareWalletOperation.mockRejectedValueOnce(signingError); + + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(mockShowHardwareWalletError).toHaveBeenCalledWith(signingError); + expect(onReject).toHaveBeenCalledTimes(1); + }); + + it('does not show error on user cancellation', async () => { + const userCancelError = new Error('User rejected'); + mockExecuteHardwareWalletOperation.mockRejectedValueOnce(userCancelError); + mockIsUserCancellation.mockReturnValueOnce(true); + + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(mockShowHardwareWalletError).not.toHaveBeenCalled(); + expect(onReject).toHaveBeenCalledTimes(1); + }); + + it('does not show error when already rejected', async () => { + const error = new Error('fail'); + mockExecuteHardwareWalletOperation.mockImplementation( + async ({ onRejected }) => { + await onRejected(); + throw error; + }, + ); + + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(onReject).toHaveBeenCalledTimes(1); + }); + + it('uses from address from transaction metadata when approval request has no from', async () => { + const originalRequestData = mockApprovalRequest.requestData; + mockApprovalRequest.requestData = + {} as typeof mockApprovalRequest.requestData; + mockTransactionMetadata.txParams = { from: '0xTxMetaAddress' }; + + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(mockExecuteHardwareWalletOperation).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0xTxMetaAddress', + }), + ); + + mockApprovalRequest.requestData = originalRequestData; + }); +}); diff --git a/app/core/HardwareWallet/hooks/useQrConfirm.ts b/app/core/HardwareWallet/hooks/useQrConfirm.ts new file mode 100644 index 00000000000..2be471a1ba6 --- /dev/null +++ b/app/core/HardwareWallet/hooks/useQrConfirm.ts @@ -0,0 +1,119 @@ +import { useCallback, useRef } from 'react'; + +import { + useHardwareWallet, + executeHardwareWalletOperation, + isUserCancellation, +} from '..'; +import { useQRHardwareContext } from '../../../components/Views/confirmations/context/qr-hardware-context'; +import useApprovalRequest from '../../../components/Views/confirmations/hooks/useApprovalRequest'; +import { useTransactionMetadataRequest } from '../../../components/Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; + +interface UseQrConfirmOptions { + onReject: () => void; + onTransactionConfirm: (opts?: { + onError?: (err: unknown) => void; + }) => Promise; + executeApproval: () => Promise; + isTransactionReq: boolean; +} + +/** + * Coordinates QR hardware wallet confirmation for transactions and message approvals. + * + * Ensures the QR account is ready, shows the awaiting-confirmation UI, opens the + * scanner when a QR signing payload is already active, and forwards terminal + * errors through the hardware wallet error flow. + * + * @returns An `onConfirm` callback for the confirmation submit action. + */ +export function useQrConfirm({ + onReject, + onTransactionConfirm, + executeApproval, + isTransactionReq, +}: UseQrConfirmOptions) { + const { + ensureDeviceReady, + showAwaitingConfirmation, + hideAwaitingConfirmation, + showHardwareWalletError, + } = useHardwareWallet(); + + const { isSigningQRObject, setScannerVisible } = useQRHardwareContext(); + + const { approvalRequest } = useApprovalRequest(); + const transactionMetadata = useTransactionMetadataRequest(); + + const hasRejectedRef = useRef(false); + + const executeQrConfirmation = useCallback(async () => { + if (isTransactionReq) { + await onTransactionConfirm({ + onError: (err) => { + throw err; + }, + }); + return; + } + + await executeApproval(); + }, [executeApproval, isTransactionReq, onTransactionConfirm]); + + const onConfirm = useCallback(async () => { + hasRejectedRef.current = false; + + const rejectOnce = () => { + if (hasRejectedRef.current) return; + hasRejectedRef.current = true; + onReject(); + }; + + const fromAddress = + (approvalRequest?.requestData?.from as string) || + (transactionMetadata?.txParams?.from as string); + + if (!fromAddress) { + rejectOnce(); + return; + } + + // If QR signing is already in progress, open the camera scanner + if (isSigningQRObject) { + setScannerVisible(true); + return; + } + + try { + await executeHardwareWalletOperation({ + address: fromAddress, + operationType: isTransactionReq ? 'transaction' : 'message', + ensureDeviceReady, + showAwaitingConfirmation, + hideAwaitingConfirmation, + showHardwareWalletError, + execute: executeQrConfirmation, + onRejected: rejectOnce, + }); + } catch (err) { + if (!hasRejectedRef.current && !isUserCancellation(err)) { + showHardwareWalletError(err); + } + rejectOnce(); + } + }, [ + approvalRequest?.requestData?.from, + transactionMetadata?.txParams?.from, + isSigningQRObject, + isTransactionReq, + executeQrConfirmation, + onReject, + ensureDeviceReady, + showAwaitingConfirmation, + hideAwaitingConfirmation, + showHardwareWalletError, + setScannerVisible, + ]); + + return { onConfirm }; +} diff --git a/app/core/HardwareWallet/hooks/useQrScanErrorForwarding.test.ts b/app/core/HardwareWallet/hooks/useQrScanErrorForwarding.test.ts new file mode 100644 index 00000000000..00ac89451ef --- /dev/null +++ b/app/core/HardwareWallet/hooks/useQrScanErrorForwarding.test.ts @@ -0,0 +1,70 @@ +import { act, renderHook } from '@testing-library/react-native'; +import type { HardwareWalletError } from '@metamask/hw-wallet-sdk'; + +const mockShowHardwareWalletError = jest.fn(); + +jest.mock('../contexts', () => ({ + useHardwareWallet: () => ({ + showHardwareWalletError: mockShowHardwareWalletError, + }), +})); + +import { useQrScanErrorForwarding } from './useQrScanErrorForwarding'; + +describe('useQrScanErrorForwarding', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('closes the scanner and forwards QR scan errors after the modal hides', () => { + const hideScanner = jest.fn(); + const scanError = new Error( + 'Scanned QR code is not in UR format', + ) as HardwareWalletError; + const { result } = renderHook(() => + useQrScanErrorForwarding({ hideScanner }), + ); + + act(() => { + result.current.onQRHardwareScanError(scanError); + }); + + expect(hideScanner).toHaveBeenCalledTimes(1); + expect(mockShowHardwareWalletError).not.toHaveBeenCalled(); + + act(() => { + result.current.handleScannerModalHide(); + }); + + expect(mockShowHardwareWalletError).toHaveBeenCalledWith(scanError); + }); + + it('does not forward an error when the modal hides without a pending QR scan error', () => { + const hideScanner = jest.fn(); + const { result } = renderHook(() => + useQrScanErrorForwarding({ hideScanner }), + ); + + act(() => { + result.current.handleScannerModalHide(); + }); + + expect(mockShowHardwareWalletError).not.toHaveBeenCalled(); + }); + + it('clears the pending QR scan error after forwarding it', () => { + const hideScanner = jest.fn(); + const scanError = new Error('QR scan failed') as HardwareWalletError; + const { result } = renderHook(() => + useQrScanErrorForwarding({ hideScanner }), + ); + + act(() => { + result.current.onQRHardwareScanError(scanError); + result.current.handleScannerModalHide(); + result.current.handleScannerModalHide(); + }); + + expect(mockShowHardwareWalletError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/core/HardwareWallet/hooks/useQrScanErrorForwarding.ts b/app/core/HardwareWallet/hooks/useQrScanErrorForwarding.ts new file mode 100644 index 00000000000..223f41955c4 --- /dev/null +++ b/app/core/HardwareWallet/hooks/useQrScanErrorForwarding.ts @@ -0,0 +1,38 @@ +import { useCallback, useRef } from 'react'; +import type { HardwareWalletError } from '@metamask/hw-wallet-sdk'; + +import { useHardwareWallet } from '../contexts'; + +interface UseQrScanErrorForwardingOptions { + hideScanner: () => void; +} + +export function useQrScanErrorForwarding({ + hideScanner, +}: UseQrScanErrorForwardingOptions) { + const { showHardwareWalletError } = useHardwareWallet(); + const pendingQrScanErrorRef = useRef(null); + + const onQRHardwareScanError = useCallback( + (error: HardwareWalletError) => { + pendingQrScanErrorRef.current = error; + hideScanner(); + }, + [hideScanner], + ); + + const handleScannerModalHide = useCallback(() => { + const pendingError = pendingQrScanErrorRef.current; + if (!pendingError) { + return; + } + + pendingQrScanErrorRef.current = null; + showHardwareWalletError(pendingError); + }, [showHardwareWalletError]); + + return { + onQRHardwareScanError, + handleScannerModalHide, + }; +} From 41f6b0f96f10ba7b20ce1e7af6eb3dc4275e1dcf Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 6 May 2026 15:53:34 +0200 Subject: [PATCH 09/27] fix: validate type of security data in token details and fetch when necessary cp-7.76.0 (#29787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ### Bug When navigating from the Swap/Bridge "Select token" screen to Token Details via the (i) icon, security info (badge, SecurityTrustEntryCard, warning banners) is missing. ### Root Cause The Bridge `/getTokens/popular` API returns security data in a different shape `({ type: "Verified" })` than what Token Details expects `({ resultType: "Verified", features: [...], ... })`. When navigating, the entire token object — including this wrong-shaped securityData — is spread into route params. `useTokenSecurityData` sees it as `truthy` prefetched data, skips its own API call, and the UI reads resultType / features → undefined → nothing renders. ### Fix Added a runtime type guard in `useTokenSecurityData` that validates `prefetchedData` has the required `resultType` (string) and features (array) before trusting it. If the shape is invalid, the hook falls through to `fetchTokenAssets()` and gets the full, correctly-shaped data. ## **Changelog** CHANGELOG entry: Fixed security badges and trust info now display correctly on Token Details when navigating from the Swap token 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** https://github.com/user-attachments/assets/1dbc3bbd-293d-4b90-a473-8ce505b8c718 ### **After** https://github.com/user-attachments/assets/61466686-09a6-45db-8961-7fd5d4690ff8 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk: adds a small runtime type guard and a focused test to ensure `useTokenSecurityData` falls back to fetching when prefetched security data is malformed. > > **Overview** > Fixes Token Details security UI missing when navigating from Swap/Bridge by **validating `prefetchedData` at runtime** in `useTokenSecurityData` (requires `resultType` string and `features` array) and treating invalid shapes as absent so the hook fetches via `fetchTokenAssets`. > > Adds a regression test covering the Bridge-style wrong-shaped data to ensure the hook ignores it and fetches correct security data instead. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2e9c4c841bccbb38677f7c089e4aaac5f1a238d4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../hooks/useTokenSecurityData.test.ts | 36 +++++++++++++++++++ .../hooks/useTokenSecurityData.ts | 12 ++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/app/components/UI/TokenDetails/hooks/useTokenSecurityData.test.ts b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.test.ts index 34dab03dff2..c1cab8323ac 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenSecurityData.test.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.test.ts @@ -109,6 +109,42 @@ describe('useTokenSecurityData', () => { expect(result.current.securityData).toBeNull(); }); + it('ignores prefetchedData with wrong shape and fetches instead', async () => { + const assetId = 'eip155:1/erc20:0x1234' as CaipAssetType; + mockFetchTokenAssets.mockResolvedValue([ + { + assetId, + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + securityData: mockSecurityData, + }, + ]); + + // Bridge SecurityData shape: { type: "Verified" } — missing resultType + const wrongShapedData = { + type: 'Verified', + } as unknown as TokenSecurityData; + + const { result } = renderHook(() => + useTokenSecurityData({ + assetId, + prefetchedData: wrongShapedData, + }), + ); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockFetchTokenAssets).toHaveBeenCalledWith([assetId], { + includeTokenSecurityData: true, + }); + expect(result.current.securityData).toBe(mockSecurityData); + }); + it('does not fetch when assetId is null', () => { const { result } = renderHook(() => useTokenSecurityData({ assetId: null }), diff --git a/app/components/UI/TokenDetails/hooks/useTokenSecurityData.ts b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.ts index 177878838f6..9f2398b8311 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenSecurityData.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.ts @@ -18,10 +18,20 @@ interface UseTokenSecurityDataResult { error: Error | null; } +const isValidTokenSecurityData = (data: unknown): data is TokenSecurityData => + data != null && + typeof data === 'object' && + typeof (data as TokenSecurityData).resultType === 'string' && + Array.isArray((data as TokenSecurityData).features); + export const useTokenSecurityData = ({ assetId, - prefetchedData, + prefetchedData: rawPrefetchedData, }: UseTokenSecurityDataOpts): UseTokenSecurityDataResult => { + const prefetchedData = isValidTokenSecurityData(rawPrefetchedData) + ? rawPrefetchedData + : undefined; + const [securityData, setSecurityData] = useState( prefetchedData ?? null, ); From 85015f7d41c6d7d50228f81d720928394df2673c Mon Sep 17 00:00:00 2001 From: jvbriones <1674192+jvbriones@users.noreply.github.com> Date: Wed, 6 May 2026 15:54:15 +0200 Subject: [PATCH 10/27] docs: e2e docu updated to include exceptions for release branches (#29745) ## **Description** e2e docu updated to include exceptions for release branches ## **Changelog** CHANGELOG entry: ## **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** - [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). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Documentation-only change that clarifies CI behavior for release-branch PRs; no runtime or build logic is modified. > > **Overview** > Updates `E2E_DECISION_TREE.md` to rename **AI test selection** to **Smart AI E2E test selection** and document *release-branch* exceptions. > > Specifies that release-branch PRs do not get the `pr-not-ready-for-e2e` label and skip smart selection in favor of running all required E2E suites when changes are non-ignorable. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 063f4a1249df8c6f9ad507c6fdd93d54f42bf439. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .github/guidelines/E2E_DECISION_TREE.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/guidelines/E2E_DECISION_TREE.md b/.github/guidelines/E2E_DECISION_TREE.md index 53a9aebb4d7..a9f6ce17e8d 100644 --- a/.github/guidelines/E2E_DECISION_TREE.md +++ b/.github/guidelines/E2E_DECISION_TREE.md @@ -33,7 +33,7 @@ To save infra resources while waiting for static analysis findings and potential - E2E tests are skipped and merge is blocked while the label is present, **unless** all changes are ignorable-only. - If E2E tests are needed, they should pass to be able to merge. -## AI test selection +## Smart AI E2E test selection Runs only when all of the following are true: @@ -53,3 +53,10 @@ Flakiness detection is applied to modified E2E test files in PRs: - Modified E2E test files run twice - It applies to existing test files as well as new test files added in the PR - It can be disabled by adding the label `skip-e2e-flakiness-detection`. Useful when making large refactors or when changes don't pose flakiness risk. + +## Release branches + +PRs to release branches (cherry-picked from main) are exempt from the following: + +- Label `pr-not-ready-for-e2e` is not applied +- Smart AI E2E selection is skipped - all E2E suites are run (if changes are not ignorable-only, e.g. only docs) From 7675a5711abc104099c2c54f3fd52a5b22bfc339 Mon Sep 17 00:00:00 2001 From: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> Date: Wed, 6 May 2026 09:56:47 -0400 Subject: [PATCH 11/27] refactor: migrate backup/sync UI to design-system (#29444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Migrate "Backup & sync" UI components to `@metamask/design-system-react-native`, specifically `Text`, `Icon`, and `BottomSheet`. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUL-1684 ## **Manual testing steps** - Onboarding - Go through the onboarding flow - Before finishing, disable "Backup & Sync" - Check that there aren't any UI regressions - Settings - Disable "Backup & Sync" and enable it back - Check that there aren't any UI regressions ## **Screenshots/Recordings** ### Onboarding Screenshot 2026-05-04 at 2 08 04 PM Screenshot 2026-05-04 at 2 08 13 PM ### Settings Screenshot 2026-05-04 at 2 21 33 PM Screenshot 2026-05-04 at 2 21 40 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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Primarily a UI refactor, but it changes shared component imports/enums and adds a global Jest mock for the design-system `BottomSheet`, which could affect rendering/tests across the suite. > > **Overview** > Migrates the Backup & Sync settings/onboarding UI to `@metamask/design-system-react-native` primitives (`Text`, `Icon`, `BottomSheet`), updating variant/color enum values (e.g., `HeadingSm`, `TextAlternative`, `SuccessDefault`). > > Updates the shared notification modal (`Notification/Modal`) to use design-system `Text`/`Icon` and adjusts typography variants accordingly. > > Improves test stability by adding a global Jest mock for the design-system `BottomSheet` in `testSetup.js` (synchronous open/close callbacks) and stubbing `QuickBuyBottomSheet` in `TraderPositionView` tests to avoid unintended mounts/selector dependencies. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ad2f26b5008a1d9578f8cb569d4a31ea4864d7aa. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Cursor --- .../BackupAndSyncFeaturesToggles.tsx | 14 ++-- .../BackupAndSyncToggle.tsx | 11 +-- .../ConfirmTurnOnBackupAndSyncModal.tsx | 12 ++-- .../UI/Notification/Modal/index.tsx | 18 +++-- .../TraderPositionView.test.tsx | 11 +++ app/util/test/testSetup.js | 69 +++++++++++++++++++ 6 files changed, 106 insertions(+), 29 deletions(-) diff --git a/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.tsx b/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.tsx index 623f96eb47b..b328f249d3c 100644 --- a/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.tsx +++ b/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.tsx @@ -1,10 +1,13 @@ import React, { useCallback, useMemo } from 'react'; import { View, Switch, InteractionManager } from 'react-native'; -import Text, { +import { + Text, TextColor, TextVariant, -} from '../../../../component-library/components/Texts/Text'; + Icon, + IconName, +} from '@metamask/design-system-react-native'; import { useTheme } from '../../../../util/theme'; import styles from './BackupAndSyncFeaturesToggles.styles'; import { useBackupAndSync } from '../../../../util/identity/hooks/useBackupAndSync'; @@ -16,9 +19,6 @@ import { selectIsBackupAndSyncUpdateLoading, } from '../../../../selectors/identity'; import { BACKUPANDSYNC_FEATURES } from '@metamask/profile-sync-controller/user-storage'; -import Icon, { - IconName, -} from '../../../../component-library/components/Icons/Icon'; import { strings } from '../../../../../locales/i18n'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; @@ -126,10 +126,10 @@ const BackupAndSyncFeaturesToggles = () => { return ( - + {strings('backupAndSync.manageWhatYouSync.title')} - + {strings('backupAndSync.manageWhatYouSync.description')} diff --git a/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.tsx b/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.tsx index 5a6f1155975..ad287b91399 100644 --- a/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.tsx +++ b/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.tsx @@ -3,10 +3,11 @@ import React, { useCallback, useEffect } from 'react'; import { View, Switch, Linking, InteractionManager } from 'react-native'; // import { useNavigation } from '@react-navigation/native'; -import Text, { +import { + Text, TextVariant, TextColor, -} from '../../../../component-library/components/Texts/Text'; +} from '@metamask/design-system-react-native'; import { useTheme } from '../../../../util/theme'; // import { strings } from '../../../../../locales/i18n'; import styles from './BackupAndSyncToggle.styles'; @@ -150,7 +151,7 @@ const BackupAndSyncToggle = ({ return ( - + {strings('backupAndSync.title')} - + {strings('backupAndSync.enable.description')} - + {strings('backupAndSync.privacyLink')} diff --git a/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/ConfirmTurnOnBackupAndSyncModal.tsx b/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/ConfirmTurnOnBackupAndSyncModal.tsx index 79a22efcaaa..a917aa0a4c3 100644 --- a/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/ConfirmTurnOnBackupAndSyncModal.tsx +++ b/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/ConfirmTurnOnBackupAndSyncModal.tsx @@ -1,15 +1,13 @@ import React, { useRef } from 'react'; -import BottomSheet, { - BottomSheetRef, -} from '../../../../component-library/components/BottomSheets/BottomSheet'; -import { strings } from '../../../../../locales/i18n'; - import { + BottomSheet, + type BottomSheetRef, IconColor, IconName, IconSize, -} from '../../../../component-library/components/Icons/Icon'; +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../locales/i18n'; import ModalContent from '../../Notification/Modal'; import { toggleBasicFunctionality } from '../../../../actions/settings'; import { useParams } from '../../../../util/navigation/navUtils'; @@ -45,7 +43,7 @@ const ConfirmTurnOnBackupAndSyncModal = () => { const turnContent = { icon: { name: IconName.Check, - color: IconColor.Success, + color: IconColor.SuccessDefault, }, bottomSheetTitle: strings('backupAndSync.enable.title'), bottomSheetMessage: strings('backupAndSync.enable.confirmation'), diff --git a/app/components/UI/Notification/Modal/index.tsx b/app/components/UI/Notification/Modal/index.tsx index 9a37c8f3cae..4f4077167e8 100644 --- a/app/components/UI/Notification/Modal/index.tsx +++ b/app/components/UI/Notification/Modal/index.tsx @@ -1,18 +1,16 @@ import React from 'react'; import { View } from 'react-native'; import Checkbox from '../../../../component-library/components/Checkbox/Checkbox'; -import Icon, { - IconColor, - IconName, - IconSize, -} from '../../../../component-library/components/Icons/Icon'; -import Text, { - TextVariant, -} from '../../../../component-library/components/Texts/Text'; import { Button, ButtonVariant, ButtonSize, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextVariant, } from '@metamask/design-system-react-native'; import createStyles from './styles'; import { useTheme } from '../../../../util/theme'; @@ -60,10 +58,10 @@ const ModalContent = ({ size={iconSize} style={styles.icon} /> - + {title} - + {message} diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx index 24483750049..8e02d0cdbaf 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx @@ -92,6 +92,17 @@ jest.mock('../../../../core/ClipboardManager', () => ({ setString: jest.fn().mockResolvedValue(undefined), })); +// Pressing buy mounts QuickBuyBottomSheet. Jest's global mock for design-system +// `BottomSheet` (see app/util/test/testSetup.js) invokes `onOpenBottomSheet`'s +// callback synchronously, so `QuickBuyBottomSheetContent` mounts in the same turn +// and runs `useQuickBuyBottomSheet` (bridge selectors, device version compare, +// NetworkController, …). This file intentionally uses a minimal Redux store, so +// we stub the sheet here. +jest.mock('./components/QuickBuyBottomSheet', () => ({ + __esModule: true, + default: () => null, +})); + jest.mock('../../../../util/haptics', () => { const actual = jest.requireActual( '../../../../util/haptics', diff --git a/app/util/test/testSetup.js b/app/util/test/testSetup.js index d600bd77442..86bb38c24ae 100644 --- a/app/util/test/testSetup.js +++ b/app/util/test/testSetup.js @@ -850,6 +850,75 @@ jest.mock('../../component-library/components/BottomSheets/BottomSheet', () => { }; }); +// Mock @metamask/design-system-react-native BottomSheet to render children immediately +// and run open/close callbacks synchronously (bypasses reanimated animations). +// Matches the component-library BottomSheet mock above; components that migrated +// to the design-system sheet otherwise trigger act() warnings from Animated updates. +jest.mock('@metamask/design-system-react-native', () => { + const React = require('react'); + const PropTypes = require('prop-types'); + const { View } = require('react-native'); + const actual = jest.requireActual('@metamask/design-system-react-native'); + + const BottomSheet = React.forwardRef( + ( + { + children, + onClose, + onOpen, + goBack, + style, + twClassName: _twClassName, + testID, + accessibilityLabel, + }, + ref, + ) => { + React.useImperativeHandle(ref, () => ({ + onOpenBottomSheet: (callback) => { + onOpen?.(); + callback?.(); + }, + onCloseBottomSheet: (callback) => { + const hasCallback = Boolean(callback); + onClose?.(hasCallback); + goBack?.(); + callback?.(); + }, + })); + return React.createElement( + View, + { + testID: testID || 'design-system-bottom-sheet-mock', + style, + accessibilityLabel, + }, + children, + ); + }, + ); + BottomSheet.displayName = 'BottomSheet'; + BottomSheet.propTypes = { + children: PropTypes.node, + onClose: PropTypes.func, + onOpen: PropTypes.func, + goBack: PropTypes.func, + style: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.array, + PropTypes.number, + ]), + twClassName: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + testID: PropTypes.string, + accessibilityLabel: PropTypes.string, + }; + + return { + ...actual, + BottomSheet, + }; +}); + // Mock react-native-modal to render children immediately (bypasses animation) jest.mock('react-native-modal', () => { const React = require('react'); From aac019d0d1f32e47ec18977a5cd69f9ef52322c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Wed, 6 May 2026 14:59:40 +0100 Subject: [PATCH 12/27] chore: adds "whats happening" to the Explore view (#29778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Renders the existing `WhatsHappeningSection` at the top of the Explore page's Now tab (V2 layout), gated behind the FF. Simulator Screenshot - iPhone 17 Pro - 2026-05-06
at 12 09 27 ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk: adds a feature-flagged UI section to the top of Explore’s Now tab plus a small ref-based refresh hook; no auth, persistence, or critical transaction logic changes. > > **Overview** > Renders `WhatsHappeningSection` at the top of `TrendingView/tabs/NowTab`, gated by `selectWhatsHappeningEnabled`, and forwards a `SectionRefreshHandle` ref so the section can be refreshed when the tab’s `refresh.trigger` changes. > > Adds a focused unit test (`NowTab.test.tsx`) that verifies **flag-on renders**, **flag-off does not mount**, and that a **ref is passed** to support pull-to-refresh without pulling in the section’s heavy dependencies. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d3b94323ca07b377d937b1eefa015b4ba33ab7ec. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Joao Santos Co-authored-by: João Santos --- .../Views/TrendingView/tabs/NowTab.test.tsx | 147 ++++++++++++++++++ .../Views/TrendingView/tabs/NowTab.tsx | 23 ++- 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 app/components/Views/TrendingView/tabs/NowTab.test.tsx diff --git a/app/components/Views/TrendingView/tabs/NowTab.test.tsx b/app/components/Views/TrendingView/tabs/NowTab.test.tsx new file mode 100644 index 00000000000..245db97ea27 --- /dev/null +++ b/app/components/Views/TrendingView/tabs/NowTab.test.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import { NavigationContainer } from '@react-navigation/native'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +// Feed hooks — return empty/not-loading so NowTab renders without network calls. +jest.mock('../feeds/tokens/useTokensFeed', () => ({ + useTokensFeed: jest.fn(() => ({ data: [], isLoading: false })), +})); + +jest.mock('../feeds/perps/usePerpsFeed', () => ({ + usePerpsFeed: jest.fn(() => ({ data: [], isLoading: false })), +})); + +jest.mock('../feeds/predictions/usePredictionsFeed', () => ({ + usePredictionsFeed: jest.fn(() => ({ data: [], isLoading: false })), +})); + +jest.mock('../feeds/stocks/useStocksFeed', () => ({ + useStocksFeed: jest.fn(() => ({ data: [], isLoading: false })), +})); + +// Mock PerpsSectionProvider as a transparent passthrough. +jest.mock('../feeds/perps/PerpsSectionProvider', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createElement } = require('react'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View } = require('react-native'); + return ({ children }: { children: unknown }) => + createElement(View, null, children); +}); + +// Mock WhatsHappeningSection to keep its transitive deps (Engine, analytics) +// out of this unit test. We control rendering via mockWhatsHappeningImpl. +const mockWhatsHappeningImpl = jest.fn( + () => null, +); + +jest.mock('../../Homepage/Sections/WhatsHappening', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { forwardRef } = require('react'); + return { + __esModule: true, + default: forwardRef((_props: unknown, ref: unknown) => + mockWhatsHappeningImpl(ref), + ), + }; +}); + +import { useSelector } from 'react-redux'; +import { selectPerpsEnabledFlag } from '../../../UI/Perps'; +import { selectPredictEnabledFlag } from '../../../UI/Predict'; +import { selectWhatsHappeningEnabled } from '../../../../selectors/featureFlagController/whatsHappening'; +import NowTab from './NowTab'; +import type { RefreshConfig } from '../hooks/useExploreRefresh'; + +const defaultRefresh: RefreshConfig = { trigger: 0, silentRefresh: true }; +const defaultTabProps = { + refresh: defaultRefresh, + refreshing: false, + onRefresh: jest.fn(), +}; + +const renderNowTab = (props = defaultTabProps) => + render( + + + , + ); + +describe('NowTab — WhatsHappeningSection integration', () => { + const mockUseSelector = useSelector as jest.MockedFunction< + typeof useSelector + >; + + const mockSelectorBase = (selector: unknown) => { + if (selector === selectPerpsEnabledFlag) return false; + if (selector === selectPredictEnabledFlag) return false; + return undefined; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockImplementation(mockSelectorBase); + // Default: section mock renders nothing; individual tests override as needed. + mockWhatsHappeningImpl.mockReturnValue(null); + }); + + it('mounts WhatsHappeningSection and renders it when the feature flag is enabled', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectWhatsHappeningEnabled) return true; + return mockSelectorBase(selector); + }); + (mockWhatsHappeningImpl as jest.Mock).mockReturnValue( + React.createElement('View', { + testID: 'homepage-whats-happening-carousel', + }), + ); + + renderNowTab(); + + expect( + screen.getByTestId('homepage-whats-happening-carousel'), + ).toBeOnTheScreen(); + }); + + it('does not mount WhatsHappeningSection when the feature flag is disabled', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectWhatsHappeningEnabled) return false; + return mockSelectorBase(selector); + }); + + renderNowTab(); + + // Section is not even mounted, so the mock should never have been called. + expect(mockWhatsHappeningImpl).not.toHaveBeenCalled(); + expect( + screen.queryByTestId('homepage-whats-happening-carousel'), + ).toBeNull(); + }); + + it('passes a ref to WhatsHappeningSection so pull-to-refresh can trigger it', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectWhatsHappeningEnabled) return true; + return mockSelectorBase(selector); + }); + + renderNowTab(); + + // The mock's first argument is the forwarded ref (we dropped props in the mock). + // It should be a React ref object so the useEffect bridge can call .refresh(). + expect(mockWhatsHappeningImpl).toHaveBeenCalled(); + const [forwardedRef] = mockWhatsHappeningImpl.mock.calls[0]; + expect(forwardedRef).not.toBeNull(); + }); +}); diff --git a/app/components/Views/TrendingView/tabs/NowTab.tsx b/app/components/Views/TrendingView/tabs/NowTab.tsx index c7eb9240f90..b02c4eccc46 100644 --- a/app/components/Views/TrendingView/tabs/NowTab.tsx +++ b/app/components/Views/TrendingView/tabs/NowTab.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { Box } from '@metamask/design-system-react-native'; @@ -33,6 +33,9 @@ import HorizontalCarousel from '../components/HorizontalCarousel'; import PillScrollList from '../components/PillScrollList'; import SectionHeader from '../components/SectionHeader'; import type { TabProps } from '../hooks/useExploreRefresh'; +import WhatsHappeningSection from '../../Homepage/Sections/WhatsHappening'; +import type { SectionRefreshHandle } from '../../Homepage/types'; +import { selectWhatsHappeningEnabled } from '../../../../selectors/featureFlagController/whatsHappening'; interface PerpsBlockProps { refresh: TabProps['refresh']; @@ -73,6 +76,14 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { useNavigation>(); const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); const isPredictEnabled = useSelector(selectPredictEnabledFlag); + const isWhatsHappeningEnabled = useSelector(selectWhatsHappeningEnabled); + + const whatsHappeningRef = useRef(null); + + useEffect(() => { + if (refresh.trigger === 0) return; + whatsHappeningRef.current?.refresh(); + }, [refresh.trigger]); const predictions = usePredictionsFeed({ refresh }); const cryptoMovers = useTokensFeed({ refresh }); @@ -111,6 +122,16 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { onRefresh={onRefresh} testID={TrendingViewSelectorsIDs.TRENDING_FEED_SCROLL_VIEW} > + {isWhatsHappeningEnabled && ( + + + + )} + {showPredictions && ( Date: Wed, 6 May 2026 17:15:33 +0200 Subject: [PATCH 13/27] feat(MUSD-739): hide Metal card outside US (#29735) ## **Description** Money Home's `MoneyMetaMaskCard` upsell currently shows two card rows (Virtual at 1% cashback, Metal at 3% cashback) to every user. The Metal card is only available to US users today, so this PR adds geolocation gating: the Metal card row is only rendered when the Ramps-detected geolocation positively resolves to `US`. Loading, unknown (`undefined`), and non-US country codes all fail closed and render only the Virtual card row. While here, both "Get now" buttons now route through the canonical `metamask://card-onboarding` deeplink (via `handleDeeplink`) instead of `navigation.navigate(Routes.CARD.ROOT)`, matching the upsell entry point used elsewhere (e.g. `EarnRewardsPreview`). `MoneyMetaMaskCard` accepts a new `showMetalCard` prop (default `false`) so the view layer keeps ownership of the geolocation read; the component stays a dumb presentational primitive. `MoneyHomeView` reads `getDetectedGeolocation` from `app/reducers/fiatOrders` and normalizes it the same way `useMusdConversionEligibility` does (`?.toUpperCase().split('-')[0] === 'US'`). ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: MUSD-739 ## **Manual testing steps** ```gherkin Feature: Metal card geolocation gating in Money Home Scenario: US user sees both Virtual and Metal card rows Given the user's Ramps geolocation has resolved to "US" When the user opens the Money home screen and scrolls to the MetaMask Card section in upsell mode Then the Virtual card row (1% cashback) is visible And the Metal card row (3% cashback) is visible Scenario: Non-US user sees only the Virtual card row Given the user's Ramps geolocation has resolved to "GB" (or any non-US code) When the user opens the Money home screen and scrolls to the MetaMask Card section in upsell mode Then the Virtual card row (1% cashback) is visible And the Metal card row is not rendered Scenario: Unknown / loading geolocation hides the Metal card row Given the Ramps geolocation has not yet resolved (undefined) When the user opens the Money home screen and scrolls to the MetaMask Card section in upsell mode Then only the Virtual card row is visible Scenario: Get now button opens the card-onboarding deeplink Given the MetaMask Card upsell section is visible When the user taps the "Get now" button on either the Virtual or Metal card row Then the metamask://card-onboarding deeplink is dispatched ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk UI change that conditionally hides the Metal card upsell row based on the existing ramps geolocation selector; main risk is incorrect geolocation normalization causing the Metal row to appear/disappear unexpectedly. > > **Overview** > Money Home now **hides the Metal card upsell row outside the US** by reading `getDetectedGeolocation` and passing a new `showMetalCard` flag into `MoneyMetaMaskCard` (US and US sub-regions like `US-CA` only; `undefined`/non-US fail closed). > > `MoneyMetaMaskCard` is updated to accept `showMetalCard` (default `false`) and only render the Metal row when enabled, with tests expanded to cover the new gating and MoneyHomeView integration. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 616aab68843865f0c37c335821bc87b8ea4e27f9. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../MoneyHomeView/MoneyHomeView.test.tsx | 86 +++++++++++++++++++ .../Views/MoneyHomeView/MoneyHomeView.tsx | 4 + .../MoneyMetaMaskCard.test.tsx | 51 +++++++++-- .../MoneyMetaMaskCard/MoneyMetaMaskCard.tsx | 23 +++-- 4 files changed, 149 insertions(+), 15 deletions(-) diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx index 17dd6468c1d..1b22739f026 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx @@ -23,6 +23,7 @@ import { strings } from '../../../../../../locales/i18n'; import MOCK_MONEY_TRANSACTIONS from '../../constants/mockActivityData'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import { selectIsCardholder } from '../../../../../selectors/cardController'; +import { getDetectedGeolocation } from '../../../../../reducers/fiatOrders'; import { moneyFormatFiat } from '../../utils/moneyFormatFiat'; const mockGoBack = jest.fn(); @@ -85,7 +86,13 @@ jest.mock('../../../../../selectors/cardController', () => ({ selectIsCardholder: jest.fn(), })); +jest.mock('../../../../../reducers/fiatOrders', () => ({ + ...jest.requireActual('../../../../../reducers/fiatOrders'), + getDetectedGeolocation: jest.fn(), +})); + const mockSelectIsCardholder = jest.mocked(selectIsCardholder); +const mockGetDetectedGeolocation = jest.mocked(getDetectedGeolocation); const mockUseMoneyAccountTransactions = jest.mocked( useMoneyAccountTransactions, @@ -134,6 +141,7 @@ describe('MoneyHomeView', () => { global.alert = jest.fn(); mockSelectIsCardholder.mockReturnValue(false); + mockGetDetectedGeolocation.mockReturnValue('US'); mockUseMoneyAccountBalance.mockReturnValue({ totalFiatFormatted: '$3.00', @@ -592,4 +600,82 @@ describe('MoneyHomeView', () => { }); }); }); + + describe('Metal card geolocation gating', () => { + it('renders the Metal card row when geolocation is US', () => { + mockGetDetectedGeolocation.mockReturnValue('US'); + + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).toBeOnTheScreen(); + expect( + getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW), + ).toBeOnTheScreen(); + }); + + it('renders the Metal card row when geolocation is a US sub-region (e.g. US-CA)', () => { + mockGetDetectedGeolocation.mockReturnValue('us-ca'); + + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).toBeOnTheScreen(); + }); + + it('hides the Metal card row when geolocation is GB', () => { + mockGetDetectedGeolocation.mockReturnValue('GB'); + + const { queryByTestId, getByTestId } = renderWithProvider( + , + ); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).not.toBeOnTheScreen(); + expect( + getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW), + ).toBeOnTheScreen(); + }); + + it('hides the Metal card row when geolocation is undefined (loading/unknown - fail closed)', () => { + mockGetDetectedGeolocation.mockReturnValue(undefined); + + const { queryByTestId, getByTestId } = renderWithProvider( + , + ); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).not.toBeOnTheScreen(); + expect( + getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW), + ).toBeOnTheScreen(); + }); + }); + + describe('Get now navigation', () => { + it('navigates to the card sign-up flow when the virtual card Get now button is pressed', () => { + mockGetDetectedGeolocation.mockReturnValue('GB'); + + const { getByText } = renderWithProvider(); + + fireEvent.press(getByText(strings('money.metamask_card.get_now'))); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT); + }); + + it('navigates to the card sign-up flow when the metal card Get now button is pressed', () => { + mockGetDetectedGeolocation.mockReturnValue('US'); + + const { getAllByText } = renderWithProvider(); + const buttons = getAllByText(strings('money.metamask_card.get_now')); + + fireEvent.press(buttons[1]); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT); + }); + }); }); diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx index a6875f3d269..ade2ce5762d 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx @@ -36,6 +36,7 @@ import { TokenDetailsSource } from '../../../TokenDetails/constants/constants'; import AppConstants from '../../../../../core/AppConstants'; import NavigationService from '../../../../../core/NavigationService'; import { selectIsCardholder } from '../../../../../selectors/cardController'; +import { getDetectedGeolocation } from '../../../../../reducers/fiatOrders'; import Logger from '../../../../../util/Logger'; import { AssetType } from '../../../../Views/confirmations/types/token'; import { Hex } from '@metamask/utils'; @@ -73,6 +74,8 @@ const MoneyHomeView = () => { const { allTransactions, moneyAddress } = useMoneyAccountTransactions(); const isCardholder = useSelector(selectIsCardholder); + const geolocation = useSelector(getDetectedGeolocation); + const isUS = geolocation?.toUpperCase().split('-')[0] === 'US'; const homeState = getMoneyHomeState(allTransactions.length); const isMilestone = homeState === 'milestone' || homeState === 'filled'; @@ -296,6 +299,7 @@ const MoneyHomeView = () => { onHeaderPress={handleHeaderPress} onLinkPress={handleLinkCardPress} apy={apyPercent} + showMetalCard={isUS} /> {isMilestone && ( diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx index 4193c2f72fe..485b94c2ad7 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx @@ -32,9 +32,9 @@ describe('MoneyMetaMaskCard', () => { ).toBeOnTheScreen(); }); - it('renders metal card row', () => { + it('renders metal card row when showMetalCard is true', () => { const { getByText, getByTestId } = render( - , + , ); expect( @@ -48,14 +48,36 @@ describe('MoneyMetaMaskCard', () => { ).toBeOnTheScreen(); }); + it('hides metal card row by default (showMetalCard not provided)', () => { + const { queryByTestId, queryByText } = render( + , + ); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).not.toBeOnTheScreen(); + expect( + queryByText(strings('money.metamask_card.metal_card')), + ).not.toBeOnTheScreen(); + }); + + it('hides metal card row when showMetalCard is false', () => { + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).not.toBeOnTheScreen(); + }); + it('calls onGetNowPress when virtual card Get now is pressed', () => { const mockGetNow = jest.fn(); - const { getAllByText } = render( + const { getByText } = render( , ); - const getNowButtons = getAllByText(strings('money.metamask_card.get_now')); - fireEvent.press(getNowButtons[0]); + fireEvent.press(getByText(strings('money.metamask_card.get_now'))); expect(mockGetNow).toHaveBeenCalledTimes(1); expect(mockGetNow.mock.calls[0]).toEqual([]); @@ -64,7 +86,7 @@ describe('MoneyMetaMaskCard', () => { it('calls onGetNowPress when metal card Get now is pressed', () => { const mockGetNow = jest.fn(); const { getAllByText } = render( - , + , ); const getNowButtons = getAllByText(strings('money.metamask_card.get_now')); @@ -174,9 +196,9 @@ describe('MoneyMetaMaskCard', () => { }); describe('upsell mode (default)', () => { - it('renders virtual and metal card rows', () => { + it('renders virtual and metal card rows when showMetalCard is true', () => { const { getByTestId } = render( - , + , ); expect( @@ -187,6 +209,19 @@ describe('MoneyMetaMaskCard', () => { ).toBeOnTheScreen(); }); + it('renders only the virtual card row when showMetalCard is false', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect( + getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW), + ).toBeOnTheScreen(); + expect( + queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).not.toBeOnTheScreen(); + }); + it('does not render link mode elements', () => { const { queryByTestId } = render( , diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx index 997526d7844..e5365d0df91 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx @@ -34,6 +34,12 @@ interface MoneyMetaMaskCardProps { onLinkPress?: () => void; /** Current APY value displayed in the link mode bullet. */ apy?: number; + /** + * Whether to render the Metal card row in upsell mode. Defaults to `false` + * because the Metal card is currently only available to US users; the parent + * is expected to pass the geolocation-derived flag. + */ + showMetalCard?: boolean; } const CardRow = ({ @@ -166,6 +172,7 @@ const MoneyMetaMaskCard = ({ onHeaderPress, onLinkPress, apy, + showMetalCard = false, }: MoneyMetaMaskCardProps) => { const handleLinkPress = useCallback(() => onLinkPress?.(), [onLinkPress]); @@ -200,13 +207,15 @@ const MoneyMetaMaskCard = ({ onPress={onGetNowPress} testID={MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW} /> - + {showMetalCard && ( + + )} )} From a6572a0692ae031f78dfbc467cd5aab4edee5979 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 6 May 2026 17:23:12 +0200 Subject: [PATCH 14/27] fix(accounts): dynamically enable Money account keyrings and service (#29502) ## **Description** Keep the `MoneyKeyring` builder registered in the `KeyringController` so that if the feature flag gets enabled dynamically, the controller and keyring will get created dynamically too! - The money keyring state is never removed/cleared - When the flag goes from ON -> OFF and there was a Money account, it gets cleared - When the flag goes from OFF -> ON and there was no Money account, it gets created automatically ## **Changelog** CHANGELOG entry: N/A ## **Related issues** Fixes: TODO ## **Manual testing steps** Make sure to enable this in your `.js.env`: ```env DEBUG=metamask:money-account-controller ``` Here's a patch to get some logs: ```diff diff --git a/app/core/Engine/controllers/money-account-controller-init.ts b/app/core/Engine/controllers/money-account-controller-init.ts index c493253bed..b5d5f4b761 100644 --- a/app/core/Engine/controllers/money-account-controller-init.ts +++ b/app/core/Engine/controllers/money-account-controller-init.ts @@ -39,12 +39,14 @@ export const moneyAccountControllerInit: MessengerClientInitFunction< const { isUnlocked } = initMessenger.call( 'KeyringController:getState', ); + console.log('testing: Initializing money account due to FF on'); // Check for the `KeyringController` to be unlocked, otherwise we won't be able // to create the Money keyring if it doesn't exist yet! if (isUnlocked) { // This call is idempotent, so it is safe to call even if the // controller is already initialized. await controller.init(); + console.log('testing: Clearing money account state due to FF off'); } } else if (!isEnabled && hasMoneyAccount) { // Clear state if we had a previous Money account and FF is off. diff --git a/app/core/Engine/controllers/remote-feature-flag-controller-init.ts b/app/core/Engine/controllers/remote-feature-flag-controller-init.ts index 2ab54cceae..2ac6fb26d9 100644 --- a/app/core/Engine/controllers/remote-feature-flag-controller-init.ts +++ b/app/core/Engine/controllers/remote-feature-flag-controller-init.ts @@ -45,9 +45,7 @@ export const remoteFeatureFlagControllerInit: MessengerClientInitFunction< distribution: getFeatureFlagAppDistribution(), }, }), - fetchInterval: __DEV__ - ? 1000 - : AppConstants.FEATURE_FLAGS_API.DEFAULT_FETCH_INTERVAL, + fetchInterval: 1000, }); if (disabled) { @@ -61,6 +59,15 @@ export const remoteFeatureFlagControllerInit: MessengerClientInitFunction< Logger.log('Feature flags updated'); }) .catch((error) => Logger.log('Feature flags update failed: ', error)); + setInterval(() => + controller + .updateRemoteFeatureFlags() + .then(() => { + Logger.log('Feature flags updated (interval)'); + }) + .catch((error) => Logger.log('Feature flags update failed: ', error)) + , 10 * 1000 + ); } return { diff --git a/app/lib/Money/feature-flags.ts b/app/lib/Money/feature-flags.ts index 736ed109e0..464497e8dc 100644 --- a/app/lib/Money/feature-flags.ts +++ b/app/lib/Money/feature-flags.ts @@ -16,6 +16,7 @@ export function isMoneyAccountEnabled( const localFlag = process.env.MM_MONEY_ENABLE_MONEY_ACCOUNT === 'true'; const remoteFlag = remoteFeatureFlags?.moneyEnableMoneyAccount as VersionGatedFeatureFlag; + console.log('testing: Remote Flag is:', remoteFlag, 'Local Flag is:', localFlag); return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; } ``` ```gherkin Feature: Money account feature flag handling (ON) Scenario: flag is ON Given the flag was OFF When the flag gets updated Then the Money account gets created automatically Feature: Money account feature flag handling (OFF) Scenario: flag is OFF Given the flag was ON When the flag gets updated Then the Money account gets cleared automatically ``` If you enabled those extra logs (with the patch above), toggling ON/OFF should give you something like this: ```log $ yarn watch |& grep -E "metamask:money-account-controller|testing:" (NOBRIDGE) LOG testing: Remote Flag is: {"enabled": true, "minimumVersion": "0.0.0"} Local Flag is: false (NOBRIDGE) LOG testing: Remote Flag is: {"enabled": true, "minimumVersion": "0.0.0"} Local Flag is: false (NOBRIDGE) LOG testing: Remote Flag is: {"enabled": true, "minimumVersion": "0.0.0"} Local Flag is: false (NOBRIDGE) DEBUG metamask:money-account-controller Money keyring (entropy:01KQFS1XQNH0399NJM7EZEFWP7 - primary) account is: 0xad9d9f06da37139dd54fd48fda02cae244a590cb (b3961609-ef94-42ea-90fc-93149e185d56) +0ms (NOBRIDGE) LOG testing: Remote Flag is: {"enabled": true, "minimumVersion": "0.0.0"} Local Flag is: false (NOBRIDGE) LOG testing: Remote Flag is: {"enabled": false, "minimumVersion": "0.0.0"} Local Flag is: false (NOBRIDGE) LOG testing: Clearing money account state due to FF off (NOBRIDGE) LOG testing: Remote Flag is: {"enabled": true, "minimumVersion": "0.0.0"} Local Flag is: false (NOBRIDGE) LOG testing: Initializing money account due to FF on (NOBRIDGE) DEBUG metamask:money-account-controller Money keyring (entropy:01KQFS1XQNH0399NJM7EZEFWP7) account created: 0xad9d9f06da37139dd54fd48fda02cae244a590cb (b3961609-ef94-42ea-90fc-93149e185d56) +26s (NOBRIDGE) DEBUG metamask:money-account-controller Money keyring (entropy:01KQFS1XQNH0399NJM7EZEFWP7 - primary) account is: 0xad9d9f06da37139dd54fd48fda02cae244a590cb (b3961609-ef94-42ea-90fc-93149e185d56) +0ms (NOBRIDGE) LOG testing: Remote Flag is: {"enabled": true, "minimumVersion": "0.0.0"} Local Flag is: false (NOBRIDGE) LOG testing: Remote Flag is: {"enabled": true, "minimumVersion": "0.0.0"} Local Flag is: false (NOBRIDGE) LOG testing: Remote Flag is: {"enabled": false, "minimumVersion": "0.0.0"} Local Flag is: false (NOBRIDGE) LOG testing: Clearing money account state due to FF off (NOBRIDGE) LOG testing: Remote Flag is: {"enabled": false, "minimumVersion": "0.0.0"} Local Flag is: false (NOBRIDGE) LOG testing: Remote Flag is: {"enabled": false, "minimumVersion": "0.0.0"} Local Flag is: false (NOBRIDGE) LOG testing: Remote Flag is: {"enabled": true, "minimumVersion": "0.0.0"} Local Flag is: false (NOBRIDGE) LOG testing: Initializing money account due to FF on (NOBRIDGE) DEBUG metamask:money-account-controller Money keyring (entropy:01KQFS1XQNH0399NJM7EZEFWP7) account created: 0xad9d9f06da37139dd54fd48fda02cae244a590cb (b3961609-ef94-42ea-90fc-93149e185d56) +1m (NOBRIDGE) DEBUG metamask:money-account-controller Money keyring (entropy:01KQFS1XQNH0399NJM7EZEFWP7 - primary) account is: 0xad9d9f06da37139dd54fd48fda02cae244a590cb (b3961609-ef94-42ea-90fc-93149e185d56) +0ms (NOBRIDGE) LOG testing: Remote Flag is: {"enabled": true, "minimumVersion": "0.0.0"} Local Flag is: false (NOBRIDGE) LOG testing: Remote Flag is: {"enabled": true, "minimumVersion": "0.0.0"} Local Flag is: false (NOBRIDGE) LOG testing: Remote Flag is: {"enabled": true, "minimumVersion": "0.0.0"} Local Flag is: false ``` - We only re-init if there was no Money account AND the flag is ON - We only clear if there was a Money account AND the flag is OFF - We only do those steps once (not clearing twice, not calling `init` twice) ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches account/keyring initialization and wallet reset flows, so mistakes could create or wipe Money account state unexpectedly when feature flags change. Changes are scoped and covered by targeted unit tests, but still impact core account plumbing. > > **Overview** > **Money accounts are now managed dynamically based on remote feature-flag updates.** `MoneyAccountController` subscribes to `RemoteFeatureFlagController:stateChange` during init and will `init()` when the flag turns on (only if the keyring is unlocked and no money accounts exist), or `clearState()` when the flag turns off (only if money accounts exist), with error logging on failures. > > **Keyring handling is made resilient to flag timing.** The `MoneyKeyring` builder is now always registered in `keyringControllerInit` so vault deserialization can recognize the type even if the Money feature flag is disabled at that moment. > > **State clearing responsibilities are centralized.** `Engine.resetState` now clears `MoneyAccountController` state, while `AccountTreeInitService.clearState` no longer clears money accounts. Tests were updated/added to assert these behaviors and the new init messenger wiring (`getMoneyAccountControllerInitMessenger`). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9d427ea8dec2093b603c9b36cd51724fc3de047c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/core/Engine/Engine.test.ts | 13 ++ app/core/Engine/Engine.ts | 4 + .../keyring-controller-init.test.ts | 25 +-- .../keyring-controller-init.ts | 60 +++--- .../money-account-controller-init.test.ts | 193 +++++++++++++++++- .../money-account-controller-init.ts | 41 +++- app/core/Engine/messengers/index.ts | 7 +- .../money-account-controller-messenger.ts | 51 +++++ .../AccountTreeInitService/index.test.ts | 5 - .../AccountTreeInitService/index.ts | 11 +- 10 files changed, 323 insertions(+), 87 deletions(-) diff --git a/app/core/Engine/Engine.test.ts b/app/core/Engine/Engine.test.ts index 5da3501da78..bbc39fb7e3c 100644 --- a/app/core/Engine/Engine.test.ts +++ b/app/core/Engine/Engine.test.ts @@ -1368,4 +1368,17 @@ describe('Engine', () => { expect(sortedControllersInState).toEqual(sortedExpectedControllers); }); }); + + describe('resetState', () => { + it('calls MoneyAccountController.clearState', async () => { + const engine = Engine.init(TEST_ANALYTICS_ID, backgroundState); + const clearStateSpy = jest + .spyOn(engine.context.MoneyAccountController, 'clearState') + .mockImplementation(() => undefined); + + await engine.resetState(); + + expect(clearStateSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 9162ca52471..0b9bcc0adfc 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -1159,6 +1159,7 @@ export class Engine { SnapController, ///: END:ONLY_INCLUDE_IF LoggingController, + MoneyAccountController, } = this.context; // Remove all permissions. @@ -1189,6 +1190,9 @@ export class Engine { })); LoggingController.clear(); + + // Accounts: + MoneyAccountController.clearState(); }; removeAllListeners() { diff --git a/app/core/Engine/controllers/keyring-controller/keyring-controller-init.test.ts b/app/core/Engine/controllers/keyring-controller/keyring-controller-init.test.ts index 63b5bf74af5..f2cdc3d0576 100644 --- a/app/core/Engine/controllers/keyring-controller/keyring-controller-init.test.ts +++ b/app/core/Engine/controllers/keyring-controller/keyring-controller-init.test.ts @@ -1,12 +1,4 @@ import { buildMessengerClientInitRequestMock } from '../../utils/test-utils'; - -jest.mock('../../../../lib/Money/feature-flags', () => ({ - isMoneyAccountEnabled: jest.fn(), -})); - -const mockIsMoneyAccountEnabled = jest.requireMock( - '../../../../lib/Money/feature-flags', -).isMoneyAccountEnabled as jest.Mock; import { ExtendedMessenger } from '../../../ExtendedMessenger'; import { getKeyringControllerMessenger } from '../../messengers/keyring-controller-messenger'; import { MessengerClientInitRequest } from '../../types'; @@ -72,7 +64,6 @@ function getInitRequestMock(): jest.Mocked< describe('keyringControllerInit', () => { beforeEach(() => { jest.clearAllMocks(); - mockIsMoneyAccountEnabled.mockReturnValue(true); }); it('initializes the controller', () => { @@ -108,26 +99,12 @@ describe('keyringControllerInit', () => { return builder; } - it('includes a MoneyKeyring builder when the flag is enabled', () => { - mockIsMoneyAccountEnabled.mockReturnValue(true); - + it('always includes a MoneyKeyring builder', () => { const builder = getMoneyKeyringBuilder(); expect(builder).toBeDefined(); }); - it('does not include a MoneyKeyring builder when the flag is disabled', () => { - mockIsMoneyAccountEnabled.mockReturnValue(false); - - keyringControllerInit(getInitRequestMock()); - - const { keyringBuilders } = jest.mocked(KeyringController).mock - .calls[0][0] as { keyringBuilders: KeyringBuilder[] }; - - const builder = keyringBuilders.find((b) => b.type === MoneyKeyring.type); - expect(builder).toBeUndefined(); - }); - it('creates a MoneyKeyring instance when invoked', () => { const builder = getMoneyKeyringBuilder(); diff --git a/app/core/Engine/controllers/keyring-controller/keyring-controller-init.ts b/app/core/Engine/controllers/keyring-controller/keyring-controller-init.ts index f134b2f8922..514fed8f278 100644 --- a/app/core/Engine/controllers/keyring-controller/keyring-controller-init.ts +++ b/app/core/Engine/controllers/keyring-controller/keyring-controller-init.ts @@ -1,5 +1,4 @@ import { MessengerClientInitFunction } from '../../types'; -import { isMoneyAccountEnabled } from '../../../../lib/Money/feature-flags'; import { CryptographicFunctions } from '@metamask/key-tree'; import { encodeMnemonic } from '@metamask/keyring-sdk'; import { @@ -44,10 +43,6 @@ export const keyringControllerInit: MessengerClientInitFunction< qrKeyringScanner, getMessengerClient, }) => { - const { remoteFeatureFlags } = getMessengerClient( - 'RemoteFeatureFlagController', - ).state; - // Required by the HD keyring and money keyring to use native crypto functions. const cryptographicFunctions: CryptographicFunctions = { pbkdf2Sha512: pbkdf2, @@ -81,35 +76,34 @@ export const keyringControllerInit: MessengerClientInitFunction< hdKeyringBuilder.type = HdKeyring.type; additionalKeyrings.push(hdKeyringBuilder); - // We only need this keyring if Money accounts are enabled. - if (isMoneyAccountEnabled(remoteFeatureFlags)) { - const moneyKeyringBuilder = () => - new MoneyKeyring({ - cryptographicFunctions, - getMnemonic: async (entropySource: string) => - // This builder needs the controller itself, so we re-use `getMessengerClient` to access - // the controller instance as it will be available when this method gets called. - // NOTE: This is required since we cannot self-use our own actions with the init messenger. - getMessengerClient('KeyringController').withKeyringUnsafe( - { - filter: (keyring, metadata): keyring is HdKeyring => - keyring.type === KeyringTypes.hd && - metadata.id === entropySource, - }, - async ({ keyring }) => { - if (!keyring?.mnemonic) { - throw new Error( - `Unable to get mnemonic to initialize MoneyKeyring`, - ); - } + // The builder is always registered so the KeyringController can recognise the + // MoneyKeyring type during vault deserialization (even if the feature flag is + // disabled at that time). + const moneyKeyringBuilder = () => + new MoneyKeyring({ + cryptographicFunctions, + getMnemonic: async (entropySource: string) => + // This builder needs the controller itself, so we re-use `getMessengerClient` to access + // the controller instance as it will be available when this method gets called. + // NOTE: This is required since we cannot self-use our own actions with the init messenger. + getMessengerClient('KeyringController').withKeyringUnsafe( + { + filter: (keyring, metadata): keyring is HdKeyring => + keyring.type === KeyringTypes.hd && metadata.id === entropySource, + }, + async ({ keyring }) => { + if (!keyring?.mnemonic) { + throw new Error( + `Unable to get mnemonic to initialize MoneyKeyring`, + ); + } - return encodeMnemonic(keyring.mnemonic); - }, - ), - }); - moneyKeyringBuilder.type = MoneyKeyring.type; - additionalKeyrings.push(moneyKeyringBuilder); - } + return encodeMnemonic(keyring.mnemonic); + }, + ), + }); + moneyKeyringBuilder.type = MoneyKeyring.type; + additionalKeyrings.push(moneyKeyringBuilder); ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) const snapKeyringBuilder = getMessengerClient('SnapKeyringBuilder'); diff --git a/app/core/Engine/controllers/money-account-controller-init.test.ts b/app/core/Engine/controllers/money-account-controller-init.test.ts index 68f948762dd..4f120d04acf 100644 --- a/app/core/Engine/controllers/money-account-controller-init.test.ts +++ b/app/core/Engine/controllers/money-account-controller-init.test.ts @@ -1,6 +1,10 @@ import { buildMessengerClientInitRequestMock } from '../utils/test-utils'; import { ExtendedMessenger } from '../../ExtendedMessenger'; -import { getMoneyAccountControllerMessenger } from '../messengers/money-account-controller-messenger'; +import { + getMoneyAccountControllerInitMessenger, + getMoneyAccountControllerMessenger, + MoneyAccountControllerInitMessenger, +} from '../messengers/money-account-controller-messenger'; import { MessengerClientInitRequest } from '../types'; import { moneyAccountControllerInit } from './money-account-controller-init'; import { @@ -8,31 +12,86 @@ import { MoneyAccountControllerMessenger, } from '@metamask/money-account-controller'; import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; +import { RemoteFeatureFlagControllerStateChangeEvent } from '@metamask/remote-feature-flag-controller'; +import { isMoneyAccountEnabled } from '../../../lib/Money/feature-flags'; +import Logger from '../../../util/Logger'; jest.mock('@metamask/money-account-controller'); +jest.mock('../../../lib/Money/feature-flags'); +jest.mock('../../../util/Logger'); + +const EMPTY_MONEY_ACCOUNTS = { moneyAccounts: {} }; +const NON_EMPTY_MONEY_ACCOUNTS = { moneyAccounts: { 'mock-account-id': {} } }; -function getInitRequestMock(): jest.Mocked< - MessengerClientInitRequest -> { - const baseMessenger = new ExtendedMessenger({ +function buildInitRequestMock< + Events extends RemoteFeatureFlagControllerStateChangeEvent = never, +>( + baseMessenger = new ExtendedMessenger({ namespace: MOCK_ANY_NAMESPACE, - }); + }), +): { + requestMock: jest.Mocked< + MessengerClientInitRequest< + MoneyAccountControllerMessenger, + MoneyAccountControllerInitMessenger + > + >; + baseMessenger: ExtendedMessenger; +} { + baseMessenger.registerActionHandler( + // @ts-expect-error: Action not allowed on root messenger. + 'RemoteFeatureFlagController:getState', + jest.fn().mockReturnValue({ remoteFeatureFlags: {} }), + ); - return { + baseMessenger.registerActionHandler( + // @ts-expect-error: Action not allowed on root messenger. + 'KeyringController:getState', + jest.fn().mockReturnValue({ isUnlocked: true }), + ); + + const requestMock = { ...buildMessengerClientInitRequestMock(baseMessenger), controllerMessenger: getMoneyAccountControllerMessenger(baseMessenger), - initMessenger: undefined, - }; + initMessenger: getMoneyAccountControllerInitMessenger(baseMessenger), + } as jest.Mocked< + MessengerClientInitRequest< + MoneyAccountControllerMessenger, + MoneyAccountControllerInitMessenger + > + >; + + return { requestMock, baseMessenger }; +} + +function publishStateChange( + baseMessenger: ExtendedMessenger< + MockAnyNamespace, + never, + RemoteFeatureFlagControllerStateChangeEvent + >, +) { + baseMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { remoteFeatureFlags: {}, cacheTimestamp: 0 }, + [], + ); } describe('moneyAccountControllerInit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('initializes the controller', () => { - const { controller } = moneyAccountControllerInit(getInitRequestMock()); + const { requestMock } = buildInitRequestMock(); + const { controller } = moneyAccountControllerInit(requestMock); expect(controller).toBeInstanceOf(MoneyAccountController); }); it('passes the proper arguments to the controller', () => { - moneyAccountControllerInit(getInitRequestMock()); + const { requestMock } = buildInitRequestMock(); + moneyAccountControllerInit(requestMock); const controllerMock = jest.mocked(MoneyAccountController); expect(controllerMock).toHaveBeenCalledWith({ @@ -40,4 +99,116 @@ describe('moneyAccountControllerInit', () => { state: undefined, }); }); + + describe('RemoteFeatureFlagController:stateChange subscription', () => { + function buildStateChangeSetup() { + const baseMessenger = new ExtendedMessenger< + MockAnyNamespace, + never, + RemoteFeatureFlagControllerStateChangeEvent + >({ namespace: MOCK_ANY_NAMESPACE }); + + const { requestMock } = buildInitRequestMock(baseMessenger); + return { requestMock, baseMessenger }; + } + + it('calls controller.init() when flag is enabled and keyring is unlocked', async () => { + jest.mocked(isMoneyAccountEnabled).mockReturnValue(true); + + const { requestMock, baseMessenger } = buildStateChangeSetup(); + const { controller } = moneyAccountControllerInit(requestMock); + (controller as unknown as { state: unknown }).state = + EMPTY_MONEY_ACCOUNTS; + + publishStateChange(baseMessenger); + await Promise.resolve(); + + expect(jest.mocked(controller.init)).toHaveBeenCalledTimes(1); + }); + + it('does not call controller.init() when flag is enabled but keyring is locked', async () => { + jest.mocked(isMoneyAccountEnabled).mockReturnValue(true); + + const { requestMock, baseMessenger } = buildStateChangeSetup(); + + baseMessenger.unregisterActionHandler( + // @ts-expect-error: Action not allowed on root messenger. + 'KeyringController:getState', + ); + baseMessenger.registerActionHandler( + // @ts-expect-error: Action not allowed on root messenger. + 'KeyringController:getState', + jest.fn().mockReturnValue({ isUnlocked: false }), + ); + + const { controller } = moneyAccountControllerInit(requestMock); + (controller as unknown as { state: unknown }).state = + EMPTY_MONEY_ACCOUNTS; + + publishStateChange(baseMessenger); + await Promise.resolve(); + + expect(jest.mocked(controller.init)).not.toHaveBeenCalled(); + }); + + it('does not call controller.init() when flag is enabled but money account already exists', async () => { + jest.mocked(isMoneyAccountEnabled).mockReturnValue(true); + + const { requestMock, baseMessenger } = buildStateChangeSetup(); + const { controller } = moneyAccountControllerInit(requestMock); + (controller as unknown as { state: unknown }).state = + NON_EMPTY_MONEY_ACCOUNTS; + + publishStateChange(baseMessenger); + await Promise.resolve(); + + expect(jest.mocked(controller.init)).not.toHaveBeenCalled(); + }); + + it('calls controller.clearState() when flag is disabled and money accounts exist', async () => { + jest.mocked(isMoneyAccountEnabled).mockReturnValue(false); + + const { requestMock, baseMessenger } = buildStateChangeSetup(); + const { controller } = moneyAccountControllerInit(requestMock); + (controller as unknown as { state: unknown }).state = + NON_EMPTY_MONEY_ACCOUNTS; + + publishStateChange(baseMessenger); + await Promise.resolve(); + + expect(jest.mocked(controller.clearState)).toHaveBeenCalledTimes(1); + }); + + it('does not call controller.clearState() when flag is disabled and no money accounts exist', async () => { + jest.mocked(isMoneyAccountEnabled).mockReturnValue(false); + + const { requestMock, baseMessenger } = buildStateChangeSetup(); + const { controller } = moneyAccountControllerInit(requestMock); + (controller as unknown as { state: unknown }).state = + EMPTY_MONEY_ACCOUNTS; + + publishStateChange(baseMessenger); + await Promise.resolve(); + + expect(jest.mocked(controller.clearState)).not.toHaveBeenCalled(); + }); + + it('logs an error when the stateChange callback throws', async () => { + const error = new Error('mock error'); + jest.mocked(isMoneyAccountEnabled).mockImplementation(() => { + throw error; + }); + + const { requestMock, baseMessenger } = buildStateChangeSetup(); + moneyAccountControllerInit(requestMock); + + publishStateChange(baseMessenger); + await Promise.resolve(); + + expect(jest.mocked(Logger.error)).toHaveBeenCalledWith( + error, + 'MoneyAccountController: error handling RemoteFeatureFlagController state change', + ); + }); + }); }); diff --git a/app/core/Engine/controllers/money-account-controller-init.ts b/app/core/Engine/controllers/money-account-controller-init.ts index 48e653df89b..c493253bed1 100644 --- a/app/core/Engine/controllers/money-account-controller-init.ts +++ b/app/core/Engine/controllers/money-account-controller-init.ts @@ -3,6 +3,9 @@ import { MoneyAccountController, MoneyAccountControllerMessenger, } from '@metamask/money-account-controller'; +import { MoneyAccountControllerInitMessenger } from '../messengers/money-account-controller-messenger'; +import { isMoneyAccountEnabled } from '../../../lib/Money/feature-flags'; +import Logger from '../../../util/Logger'; /** * Initialize the money account controller. @@ -15,12 +18,46 @@ import { */ export const moneyAccountControllerInit: MessengerClientInitFunction< MoneyAccountController, - MoneyAccountControllerMessenger -> = ({ controllerMessenger, persistedState }) => { + MoneyAccountControllerMessenger, + MoneyAccountControllerInitMessenger +> = ({ controllerMessenger, initMessenger, persistedState }) => { const controller = new MoneyAccountController({ messenger: controllerMessenger, state: persistedState.MoneyAccountController, }); + // Re-check the Money account feature flag whenever remote flags are updated. + initMessenger.subscribe( + 'RemoteFeatureFlagController:stateChange', + async ({ remoteFeatureFlags }) => { + try { + const isEnabled = isMoneyAccountEnabled(remoteFeatureFlags); + const hasMoneyAccount = + Object.keys(controller.state.moneyAccounts).length > 0; + + if (isEnabled && !hasMoneyAccount) { + const { isUnlocked } = initMessenger.call( + 'KeyringController:getState', + ); + // Check for the `KeyringController` to be unlocked, otherwise we won't be able + // to create the Money keyring if it doesn't exist yet! + if (isUnlocked) { + // This call is idempotent, so it is safe to call even if the + // controller is already initialized. + await controller.init(); + } + } else if (!isEnabled && hasMoneyAccount) { + // Clear state if we had a previous Money account and FF is off. + controller.clearState(); + } + } catch (error) { + Logger.error( + error as Error, + 'MoneyAccountController: error handling RemoteFeatureFlagController state change', + ); + } + }, + ); + return { controller }; }; diff --git a/app/core/Engine/messengers/index.ts b/app/core/Engine/messengers/index.ts index 1a3bc7ee755..ae40559346e 100644 --- a/app/core/Engine/messengers/index.ts +++ b/app/core/Engine/messengers/index.ts @@ -112,7 +112,10 @@ import { } from './identity/user-storage-controller-messenger'; import { getAuthenticationControllerMessenger } from './identity/authentication-controller-messenger'; import { getEarnControllerMessenger } from './earn-controller-messenger'; -import { getMoneyAccountControllerMessenger } from './money-account-controller-messenger'; +import { + getMoneyAccountControllerInitMessenger, + getMoneyAccountControllerMessenger, +} from './money-account-controller-messenger'; import { getMoneyAccountBalanceServiceMessenger } from './money-account-balance-service-messenger'; import { getGeolocationApiServiceMessenger } from './geolocation-api-service-messenger'; import { getGeolocationControllerMessenger } from './geolocation-controller-messenger'; @@ -340,7 +343,7 @@ export const MESSENGER_FACTORIES = { }, MoneyAccountController: { getMessenger: getMoneyAccountControllerMessenger, - getInitMessenger: noop, + getInitMessenger: getMoneyAccountControllerInitMessenger, }, MultichainTransactionsController: { getMessenger: getMultichainTransactionsControllerMessenger, diff --git a/app/core/Engine/messengers/money-account-controller-messenger.ts b/app/core/Engine/messengers/money-account-controller-messenger.ts index 114fc6d5352..9d662772697 100644 --- a/app/core/Engine/messengers/money-account-controller-messenger.ts +++ b/app/core/Engine/messengers/money-account-controller-messenger.ts @@ -4,6 +4,12 @@ import { MessengerEvents, } from '@metamask/messenger'; import { MoneyAccountControllerMessenger } from '@metamask/money-account-controller'; +import { + RemoteFeatureFlagControllerGetStateAction, + RemoteFeatureFlagControllerState, +} from '@metamask/remote-feature-flag-controller'; +import { KeyringControllerGetStateAction } from '@metamask/keyring-controller'; +import { ControllerStateChangeEvent } from '@metamask/base-controller'; import { RootMessenger } from '../types'; /** @@ -35,3 +41,48 @@ export function getMoneyAccountControllerMessenger( return messenger; } + +type AllowedInitializationActions = + | RemoteFeatureFlagControllerGetStateAction + | KeyringControllerGetStateAction; + +type AllowedInitializationEvents = ControllerStateChangeEvent< + 'RemoteFeatureFlagController', + RemoteFeatureFlagControllerState +>; + +export type MoneyAccountControllerInitMessenger = ReturnType< + typeof getMoneyAccountControllerInitMessenger +>; + +/** + * Get the messenger for the money account controller initialization. This is + * scoped to the actions and events needed during initialization. + * + * @param rootMessenger - The root messenger. + * @returns The MoneyAccountControllerInitMessenger. + */ +export function getMoneyAccountControllerInitMessenger( + rootMessenger: RootMessenger, +) { + const messenger = new Messenger< + 'MoneyAccountControllerInit', + AllowedInitializationActions, + AllowedInitializationEvents, + RootMessenger + >({ + namespace: 'MoneyAccountControllerInit', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: [ + 'RemoteFeatureFlagController:getState', + 'KeyringController:getState', + ], + events: ['RemoteFeatureFlagController:stateChange'], + messenger, + }); + + return messenger; +} diff --git a/app/multichain-accounts/AccountTreeInitService/index.test.ts b/app/multichain-accounts/AccountTreeInitService/index.test.ts index 4fc2d9cec02..102b3025853 100644 --- a/app/multichain-accounts/AccountTreeInitService/index.test.ts +++ b/app/multichain-accounts/AccountTreeInitService/index.test.ts @@ -117,10 +117,5 @@ describe('AccountTreeInitService', () => { await service.clearState(); expect(mockAccountTreeClearState).toHaveBeenCalled(); }); - - it('calls MoneyAccountController.clearState', async () => { - await service.clearState(); - expect(mockMoneyAccountClearState).toHaveBeenCalled(); - }); }); }); diff --git a/app/multichain-accounts/AccountTreeInitService/index.ts b/app/multichain-accounts/AccountTreeInitService/index.ts index ca342a33dd5..89ae6650294 100644 --- a/app/multichain-accounts/AccountTreeInitService/index.ts +++ b/app/multichain-accounts/AccountTreeInitService/index.ts @@ -29,18 +29,9 @@ export class AccountTreeInitService { }; clearState = async (): Promise => { - const { - AccountTreeController, - MoneyAccountController, - RemoteFeatureFlagController, - } = Engine.context; - const { remoteFeatureFlags } = RemoteFeatureFlagController.state; + const { AccountTreeController } = Engine.context; AccountTreeController.clearState(); - - if (isMoneyAccountEnabled(remoteFeatureFlags)) { - MoneyAccountController.clearState(); - } }; } From a81c1ea33480e8b6a2bce323b51ce122e85e270a Mon Sep 17 00:00:00 2001 From: Alexey Kureev Date: Wed, 6 May 2026 17:26:58 +0200 Subject: [PATCH 15/27] =?UTF-8?q?feat:=20MUSD-740,=20MUSD-746=20=E2=80=94?= =?UTF-8?q?=20MetaMask=20Card=20section=20polish=20(#29646)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Two small polish items in the MetaMask Card section on Money Home. - **MUSD-746** wraps the "1% cashback" / "3% cashback" labels on the Virtual and Metal card rows in the MMDS `Tag` (severity = success) so they read as pills. This matches the APY tag pattern in the Money Home header and gives the labels stronger emphasis. - **MUSD-740** rebalances the `link` variant of `MoneyMetaMaskCard`. The card image is enlarged from `104×66` to `152×96` (a new `linkCardImage` style), and the row gains `BoxAlignItems.Center` so the checklist sits vertically centred next to the larger image. The upsell variant rows are unchanged. No copy, analytics, or routing changes. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: - https://consensyssoftware.atlassian.net/browse/MUSD-740 - https://consensyssoftware.atlassian.net/browse/MUSD-746 ## **Manual testing steps** ```gherkin Feature: MetaMask Card section polish Scenario: user views the MetaMask Card section in upsell mode Given the Money Account feature flag is enabled and the user has no card When the user scrolls to the "MetaMask Card" section on Money Home Then "1% cashback" and "3% cashback" render as green pill tags And both pills sit immediately below the card name And the existing "Get now" buttons are unchanged Scenario: user views the MetaMask Card section in link mode Given the user is in the link-card flow When the "Link MetaMask Card" card is rendered Then the card image is visibly larger than before And the two checklist bullets ("Up to 3% cash back", "Up to N% APY") are vertically centred next to the image And the "Link card" button position is unchanged ``` ## **Screenshots/Recordings** image ## **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 - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk UI-only changes: swaps cashback labels to MMDS `Tag` pills and tweaks link-mode layout/image sizing without touching navigation, analytics, or data logic. > > **Overview** > Updates the Money Home `MoneyMetaMaskCard` UI polish. > > Cashback labels in the upsell rows are now rendered as MMDS `Tag` pills (success severity) instead of plain green text. Link mode layout is adjusted by enlarging the card image via a new `linkCardImage` style and vertically centering the image/bullet row. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8b2297f73f14775f1ff2283e64ff332495b826be. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../MoneyMetaMaskCard/MoneyMetaMaskCard.styles.ts | 4 ++++ .../MoneyMetaMaskCard/MoneyMetaMaskCard.tsx | 13 ++++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.styles.ts b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.styles.ts index 386a64c6428..8c132c100e7 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.styles.ts +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.styles.ts @@ -5,6 +5,10 @@ const styles = StyleSheet.create({ width: 104, height: 66, }, + linkCardImage: { + width: 152, + height: 96, + }, }); export default styles; diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx index e5365d0df91..aa195159598 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx @@ -13,6 +13,8 @@ import { IconColor, IconName, IconSize, + Tag, + TagSeverity, Text, TextColor, TextVariant, @@ -72,15 +74,11 @@ const CardRow = ({ {cardName} - + {strings('money.metamask_card.cashback', { percentage: cashbackPercentage, })} - + - ) : ( - <> - {visibleTokens.map((token) => ( - - ))} - - - - - - )} + ); }; diff --git a/locales/languages/en.json b/locales/languages/en.json index dd767d04837..5205ab4d836 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6543,9 +6543,9 @@ "card": "Card" }, "earnings": { - "title": "Earnings", - "lifetime": "Lifetime earnings", - "projected": "Projected earnings", + "title": "Estimated earnings", + "estimated_monthly": "Monthly", + "estimated_yearly": "Annual", "info_label": "Earnings info" }, "how_it_works": { @@ -6622,12 +6622,8 @@ "learn_more": "Learn more" }, "earnings_tooltip": { - "title": "Earnings", - "lifetime_heading": "Lifetime earnings", - "lifetime_body": "The total yield you've earned since opening your Money account.", - "projected_heading": "Projected earnings", - "projected_body": "A projection of what you'd earn over a year based on your current balance and rate.", - "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + "title": "Estimated earnings", + "body": "An estimate of how much you could earn over a period based on your current balance and today's APY. Estimates are not guaranteed returns and remain subject to change." }, "activity": { "title": "Activity", From 43a99eff4706269558a4b89ccd300317dfdc5028 Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Wed, 6 May 2026 11:59:35 -0400 Subject: [PATCH 18/27] fix: MUSD-772 fix earn deposits redirecting to activity view with no way to leave (#29763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes a regression introduced in https://github.com/MetaMask/metamask-mobile/pull/29454 where Earn redirects to the activity view for successful pooled-staking or lending deposits would leave users stranded without a way to exit. This comes as part of a larger change to replace activity with Money in the main navbar. ## **Changelog** CHANGELOG entry: Fixes a regression where Earn redirects to the activity view for successful pooled-staking or lending deposits would leave users stranded without a way to exit. ## **Related issues** Fixes: [MUSD-772: Fix deposit redirects for Earn flows leaving users stranded on Activity page](https://consensyssoftware.atlassian.net/browse/MUSD-772) ## **Manual testing steps** ```gherkin Feature: Activity screen navigation when Money Home is enabled Scenario: user accesses activity list from Money Home Given the Money Home feature flag is enabled And user is on the pooled-staking or lending deposit confirmation screen When confirms their transaction Then user is redirected to the activty with And a back button is displayed in the Activity screen header And user can navigate back to Wallet Home without being stranded ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/85bb5c72-978e-4e63-86b4-e35bb35e83da ### **After** https://github.com/user-attachments/assets/5752d2aa-7f8b-4b32-bec8-b58b5eb2351b ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes back-navigation behavior (including Android hardware back handling) for `ActivityView` when the Money Home feature flag is enabled, which could affect user navigation flows if routing assumptions are wrong. Scope is limited and covered by new unit tests around back button and hardware back handling. > > **Overview** > Fixes a regression where users redirected into `ActivityView` (e.g., after successful Earn deposits) could get stuck without a reliable way to exit when the Money Home experience is enabled. > > When `selectMoneyHomeScreenEnabledFlag` is on, `ActivityView` now always shows a back button and routes both header back and Android `hardwareBackPress` to `Routes.HOME_TABS` (instead of returning to the prior stack/confirmation screen). The wallet’s Activity entrypoint no longer passes `showBackButton` params, and `Routes` adds a new `HOME_TABS` route constant. > > Adds/updates `ActivityView` tests to cover Money-flag behavior for header back, hardware back interception, and header variant selection. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e5a0c602d9dd0453e06c14362c75748bdb5ac76d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/components/Views/ActivityView/index.js | 37 +++++- .../Views/ActivityView/index.test.tsx | 108 +++++++++++++++++- app/components/Views/Wallet/index.tsx | 5 +- app/constants/navigation/Routes.ts | 1 + 4 files changed, 141 insertions(+), 10 deletions(-) diff --git a/app/components/Views/ActivityView/index.js b/app/components/Views/ActivityView/index.js index 02f7a4abe07..67458ec491a 100644 --- a/app/components/Views/ActivityView/index.js +++ b/app/components/Views/ActivityView/index.js @@ -1,6 +1,6 @@ import { useFocusEffect, useNavigation } from '@react-navigation/native'; -import React, { useCallback, useMemo, useState } from 'react'; -import { StyleSheet, View } from 'react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { BackHandler, StyleSheet, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useSelector } from 'react-redux'; import { WalletViewSelectorsIDs } from '../Wallet/WalletView.testIds'; @@ -23,11 +23,13 @@ import { KnownCaipNamespace } from '@metamask/utils'; import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; import { selectChainId } from '../../../selectors/networkController'; import { selectNetworkName } from '../../../selectors/networkInfos'; +import Routes from '../../../constants/navigation/Routes'; import { useParams } from '../../../util/navigation/navUtils'; import { getNetworkImageSource } from '../../../util/networks'; import { useTheme } from '../../../util/theme'; import { TabsList } from '../../../component-library/components-temp/Tabs'; import { createNetworkManagerNavDetails } from '../../UI/NetworkManager'; +import { selectMoneyHomeScreenEnabledFlag } from '../../UI/Money/selectors/featureFlags'; import { selectPerpsEnabledFlag } from '../../UI/Perps'; import { selectPredictEnabledFlag } from '../../UI/Predict/selectors/featureFlags'; import PredictTransactionsView from '../../UI/Predict/views/PredictTransactionsView/PredictTransactionsView'; @@ -106,6 +108,10 @@ const ActivityView = () => { const currentNetworkName = getNetworkInfo(0)?.networkName; + const isMoneyHomeScreenEnabled = useSelector( + selectMoneyHomeScreenEnabledFlag, + ); + const params = useParams(); const perpsEnabledFlag = useSelector(selectPerpsEnabledFlag); const isPerpsEnabled = useMemo( @@ -123,13 +129,34 @@ const ActivityView = () => { navigation.navigate(...createNetworkManagerNavDetails({})); }; + // Prevent back button returning to confirmation screen in case that users are redirected after a successful transaction. + const handleNavigateHome = useCallback(() => { + navigation.navigate(Routes.HOME_TABS); + }, [navigation]); + const handleBackPress = useCallback(() => { - if (navigation.canGoBack()) { + if (isMoneyHomeScreenEnabled) { + handleNavigateHome(); + } else if (navigation.canGoBack()) { navigation.goBack(); } - }, [navigation]); + }, [isMoneyHomeScreenEnabled, navigation, handleNavigateHome]); + + useEffect(() => { + if (!isMoneyHomeScreenEnabled) return; + + const subscription = BackHandler.addEventListener( + 'hardwareBackPress', + () => { + handleNavigateHome(); + return true; + }, + ); + + return () => subscription.remove(); + }, [navigation, isMoneyHomeScreenEnabled, handleNavigateHome]); - const showBackButton = params.showBackButton || false; + const showBackButton = params.showBackButton || isMoneyHomeScreenEnabled; // Calculate dynamic tab indices based on which tabs are enabled // Tab order: Transactions (0), Orders (1), Perps (conditional), Predict (conditional) diff --git a/app/components/Views/ActivityView/index.test.tsx b/app/components/Views/ActivityView/index.test.tsx index c1dc00d2289..31e0ff0d4fe 100644 --- a/app/components/Views/ActivityView/index.test.tsx +++ b/app/components/Views/ActivityView/index.test.tsx @@ -1,14 +1,21 @@ import React from 'react'; import ActivityView from '.'; +import { BackHandler } from 'react-native'; import { backgroundState } from '../../../util/test/initial-root-state'; import renderWithProvider from '../../../util/test/renderWithProvider'; import { createStackNavigator } from '@react-navigation/stack'; -import { fireEvent } from '@testing-library/react-native'; +import { cleanup, fireEvent } from '@testing-library/react-native'; // eslint-disable-next-line import-x/no-namespace import * as networkManagerUtils from '../../UI/NetworkManager'; import { useCurrentNetworkInfo } from '../../hooks/useCurrentNetworkInfo'; import { ActivitiesViewSelectorsIDs } from './ActivitiesView.testIds'; import { WalletViewSelectorsIDs } from '../Wallet/WalletView.testIds'; +import Routes from '../../../constants/navigation/Routes'; + +let mockMoneyHomeScreenEnabled = false; +jest.mock('../../UI/Money/selectors/featureFlags', () => ({ + selectMoneyHomeScreenEnabledFlag: jest.fn(() => mockMoneyHomeScreenEnabled), +})); // Mock the Perps feature flag selector - will be controlled per test let mockPerpsEnabled = false; @@ -236,6 +243,8 @@ describe('ActivityView', () => { const mockUseCurrentNetworkInfo = useCurrentNetworkInfo as jest.MockedFunction; + let backHandlerSpy: jest.SpyInstance; + const defaultNetworkInfo = { enabledNetworks: [ { chainId: '0x1', enabled: true }, @@ -266,8 +275,14 @@ describe('ActivityView', () => { beforeEach(() => { jest.clearAllMocks(); + backHandlerSpy = jest + .spyOn(BackHandler, 'addEventListener') + .mockReturnValue({ remove: jest.fn() } as unknown as ReturnType< + typeof BackHandler.addEventListener + >); mockUseCurrentNetworkInfo.mockReturnValue(defaultNetworkInfo); mockIsEvmSelected = true; + mockMoneyHomeScreenEnabled = false; mockPerpsEnabled = false; mockPredictEnabled = false; mockAreAllEvmPopularNetworksEnabled = false; @@ -275,6 +290,11 @@ describe('ActivityView', () => { mockRoute.params = {}; }); + afterEach(() => { + cleanup(); + backHandlerSpy.mockRestore(); + }); + describe('Network Manager Integration', () => { beforeEach(() => { jest.clearAllMocks(); @@ -403,6 +423,80 @@ describe('ActivityView', () => { expect(mockNavigation.goBack).not.toHaveBeenCalled(); }); + + it('displays back button when Money home screen flag is enabled without showBackButton param', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = {}; + + const { getByTestId } = renderComponent(mockInitialState); + + expect(getByTestId('activity-view-back-button')).toBeOnTheScreen(); + }); + + it('calls navigation.navigate with HOME_TABS on back button press when Money flag is enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = {}; + const { getByTestId } = renderComponent(mockInitialState); + + fireEvent.press(getByTestId('activity-view-back-button')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.HOME_TABS); + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + }); + + it('calls navigation.navigate with HOME_TABS and not goBack when both flag and showBackButton param are true', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = { showBackButton: true }; + const { getByTestId } = renderComponent(mockInitialState); + + fireEvent.press(getByTestId('activity-view-back-button')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.HOME_TABS); + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + }); + + it('registers hardwareBackPress handler when Money flag is enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = {}; + + renderComponent(mockInitialState); + + expect(BackHandler.addEventListener).toHaveBeenCalledWith( + 'hardwareBackPress', + expect.any(Function), + ); + }); + + it('navigates to HOME_TABS when hardwareBackPress fires with Money flag enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = {}; + renderComponent(mockInitialState); + const [[, handler]] = (BackHandler.addEventListener as jest.Mock).mock + .calls; + + const result = handler(); + + expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.HOME_TABS); + expect(result).toBe(true); + }); + + it('does not navigate to HOME_TABS on hardwareBackPress when Money flag is disabled', () => { + mockMoneyHomeScreenEnabled = false; + mockRoute.params = {}; + + renderComponent(mockInitialState); + + const hardwareBackPressCalls = ( + BackHandler.addEventListener as jest.Mock + ).mock.calls.filter(([event]: [string]) => event === 'hardwareBackPress'); + hardwareBackPressCalls.forEach(([, handler]: [string, () => boolean]) => + handler(), + ); + + expect(mockNavigation.navigate).not.toHaveBeenCalledWith( + Routes.HOME_TABS, + ); + }); }); describe('header and SafeAreaView', () => { @@ -463,6 +557,18 @@ describe('ActivityView', () => { queryByTestId(ActivitiesViewSelectorsIDs.HEADER_COMPACT_STANDARD), ).toBeNull(); }); + + it('renders HeaderCompactStandard when Money home screen flag is enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = {}; + + const { getByTestId, queryByTestId } = renderComponent(mockInitialState); + + expect( + getByTestId(ActivitiesViewSelectorsIDs.HEADER_COMPACT_STANDARD), + ).toBeOnTheScreen(); + expect(queryByTestId(ActivitiesViewSelectorsIDs.HEADER_ROOT)).toBeNull(); + }); }); describe('Perps tab', () => { diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index bcb28723cef..d977b85e1bc 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -1061,10 +1061,7 @@ const Wallet = ({ MetaMetricsEvents.ACTIVITY_CLICKED, ).build(), ); - navigation.navigate(Routes.TRANSACTIONS_VIEW, { - screen: Routes.TRANSACTIONS_VIEW, - params: { showBackButton: true }, - }); + navigation.navigate(Routes.TRANSACTIONS_VIEW); }, [navigation, trackEvent]); const getTokenAddedAnalyticsParams = useCallback( diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index c3851369f74..0593e003de7 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -1,4 +1,5 @@ const Routes = { + HOME_TABS: 'Home', WALLET_VIEW: 'WalletView', BROWSER_TAB_HOME: 'BrowserTabHome', BROWSER_VIEW: 'BrowserView', From 9c50f9b198cd41502d4ea0ccfa0b4ef21d6d65f5 Mon Sep 17 00:00:00 2001 From: sophieqgu <37032128+sophieqgu@users.noreply.github.com> Date: Wed, 6 May 2026 12:11:48 -0400 Subject: [PATCH 19/27] fix(rewards): localize campaign date (#29808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Simulator Screenshot - E2E Test -
2026-05-06 at 11 48 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. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk: swaps a hardcoded month-name formatter for cached `Intl.DateTimeFormat` using the app locale, plus a small Jest mock adjustment; behavior changes are limited to date label rendering. > > **Overview** > Campaign tile date labels are now **localized** by formatting month/day via `getIntlDateTimeFormatter` using `I18n.locale`, replacing the prior hardcoded English month list. > > Tests were updated to mock the `i18n` module’s default export (`locale`) so the new locale-aware formatter can run under Jest. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 38c82e49ddff4df42a60502d40fe94679f2407b0. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../Campaigns/CampaignTile.utils.test.ts | 2 ++ .../Campaigns/CampaignTile.utils.ts | 33 ++++++------------- 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts index c218339c001..ce6d77de59c 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts @@ -23,6 +23,8 @@ jest.mock('@metamask/design-system-react-native', () => ({ })); jest.mock('../../../../../../locales/i18n', () => ({ + __esModule: true, + default: { locale: 'en-US' }, strings: jest.fn((key: string, params?: Record) => params ? `${key}:${JSON.stringify(params)}` : key, ), diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts index cc3c99218d0..9c665255eb3 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts @@ -4,7 +4,8 @@ import { type CampaignDto, type CampaignStatus, } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { strings } from '../../../../../../locales/i18n'; +import I18n, { strings } from '../../../../../../locales/i18n'; +import { getIntlDateTimeFormatter } from '../../../../../util/intl'; /** * Set of campaign types that have full UI support (details view, opt-in, etc.) @@ -53,32 +54,18 @@ export function getCampaignStatus(campaign: CampaignDto): CampaignStatus { return 'complete'; } -const MONTHS = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', -]; - /** - * Formats a date for display in campaign tiles. + * Formats a date for display in campaign tiles (localized month and day). * * @param date - The date to format - * @returns Formatted date string (e.g., "March 15") + * @param locale - BCP 47 locale; defaults to the app locale + * @returns Formatted date string (e.g., "March 15" in en-US) */ -function formatCampaignDate(date: Date): string { - const month = MONTHS[date.getMonth()]; - const day = date.getDate(); - - return `${month} ${day}`; +function formatCampaignDate(date: Date, locale: string = I18n.locale): string { + return getIntlDateTimeFormatter(locale, { + month: 'long', + day: 'numeric', + }).format(date); } /** From 34f3d4c06830b140b3be92b590e1118a97d488e3 Mon Sep 17 00:00:00 2001 From: Christopher Ferreira <104831203+christopherferreira9@users.noreply.github.com> Date: Wed, 6 May 2026 17:14:08 +0100 Subject: [PATCH 20/27] test: moves the fixture update spec file (#29801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR moves the default fixture change detection spec from `regression` into `smoke` ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** N/A ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk: this only updates CI/workflow and a helper script to reference the relocated fixture validation test path, with no production code changes. > > **Overview** > The fixture update workflow and local `update-e2e-fixture.sh` script now run `tests/smoke/fixtures/fixture-validation.spec.ts` instead of the prior `tests/regression/...` location, aligning automation with the test’s new suite placement. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 635861157816279d1dca0d098576ad2bf492ec7b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .github/workflows/update-e2e-fixtures.yml | 2 +- tests/scripts/update-e2e-fixture.sh | 2 +- tests/{regression => smoke}/fixtures/fixture-validation.spec.ts | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename tests/{regression => smoke}/fixtures/fixture-validation.spec.ts (100%) diff --git a/.github/workflows/update-e2e-fixtures.yml b/.github/workflows/update-e2e-fixtures.yml index 63f5db43701..32c08c238c6 100644 --- a/.github/workflows/update-e2e-fixtures.yml +++ b/.github/workflows/update-e2e-fixtures.yml @@ -255,7 +255,7 @@ jobs: run: | IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' \ yarn detox test -c ios.sim.main.ci --headless \ - tests/regression/fixtures/fixture-validation.spec.ts + tests/smoke/fixtures/fixture-validation.spec.ts env: PREBUILT_IOS_APP_PATH: artifacts/main-qa-MetaMask.app diff --git a/tests/scripts/update-e2e-fixture.sh b/tests/scripts/update-e2e-fixture.sh index 3904efa161f..3a0a3f5f96d 100755 --- a/tests/scripts/update-e2e-fixture.sh +++ b/tests/scripts/update-e2e-fixture.sh @@ -10,7 +10,7 @@ TARGET_FILE="tests/framework/fixtures/json/default-fixture.json" if [ ! -f "$REPORT_FILE" ]; then echo "Error: $REPORT_FILE not found." echo "Run the fixture validation test first:" - echo " yarn detox test tests/regression/fixtures/fixture-validation.spec.ts -c " + echo " yarn detox test tests/smoke/fixtures/fixture-validation.spec.ts -c " exit 1 fi diff --git a/tests/regression/fixtures/fixture-validation.spec.ts b/tests/smoke/fixtures/fixture-validation.spec.ts similarity index 100% rename from tests/regression/fixtures/fixture-validation.spec.ts rename to tests/smoke/fixtures/fixture-validation.spec.ts From 2433e3e59d0b248d00b22401065621b73eb1dde1 Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Wed, 6 May 2026 18:21:41 +0200 Subject: [PATCH 21/27] chore: add explore v2 metrics (#29732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Added metrics for the new explore page v2 to be tracked for the next RC Segment changes: https://github.com/Consensys/segment-schema/pull/551 ## **Changelog** CHANGELOG entry: add explore v2 metrics ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-2957 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Primarily adds optional analytics callbacks and tracking calls without changing core navigation flows; risk is low aside from potential duplicate/missing events due to new hook/prop wiring across many UI components. > > **Overview** > Adds new MetaMetrics event `EXPLORE_INTERACTED` and a `trackExploreInteracted` helper with typed properties for Explore V2 interactions. > > Instruments Explore V2 UI to emit interaction events for **tab switches**, **section “See all” taps**, **item taps** (tokens, perps, predictions, sites), and **prediction buy/vote taps**. This is implemented by threading optional `onCardPress`/`onBuyButtonPress` callbacks through Predict/Trending/perps/site row components and introducing a shared `PerpsToggleBlock` used by Macro/RWAs to standardize pill-toggled perps tracking. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ac1becb92d561d2abaff9196f250a188611bed47. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../PredictMarket/PredictMarket.tsx | 12 ++ .../PredictMarketMultiple.tsx | 8 ++ .../PredictMarketSingle.tsx | 8 ++ .../PredictMarketSportCard.tsx | 8 ++ .../PredictSportCardFooter.tsx | 12 +- .../TrendingTokenRowItem.tsx | 9 +- .../Views/TrendingView/TrendingView.tsx | 33 ++++- .../TrendingView/components/SectionHeader.tsx | 64 ++++++--- .../feeds/dapps/SiteTileRowItem.tsx | 8 +- .../feeds/perps/PerpsPillItem.tsx | 5 +- .../TrendingView/feeds/perps/PerpsRowItem.tsx | 5 +- .../feeds/perps/PerpsTileRowItem.tsx | 4 + .../feeds/perps/PerpsToggleBlock.tsx | 98 +++++++++++++ .../feeds/predictions/PredictionRowItem.tsx | 8 +- .../TrendingView/feeds/sites/SiteRowItem.tsx | 22 ++- .../feeds/tokens/CryptoMoversPillItem.tsx | 10 +- .../feeds/tokens/TokenRowItem.tsx | 4 + .../Views/TrendingView/search/analytics.ts | 57 ++++++++ .../Views/TrendingView/tabs/CryptoTab.tsx | 52 ++++++- .../Views/TrendingView/tabs/DappsTab.tsx | 69 ++++++++- .../Views/TrendingView/tabs/MacroTab.tsx | 89 ++++++------ .../Views/TrendingView/tabs/NowTab.tsx | 75 +++++++++- .../Views/TrendingView/tabs/RwasTab.tsx | 87 +++++++----- .../Views/TrendingView/tabs/SportsTab.tsx | 133 +++++++++++++----- app/core/Analytics/MetaMetrics.events.ts | 5 + 25 files changed, 730 insertions(+), 155 deletions(-) create mode 100644 app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx diff --git a/app/components/UI/Predict/components/PredictMarket/PredictMarket.tsx b/app/components/UI/Predict/components/PredictMarket/PredictMarket.tsx index f14340b2839..b81fc740555 100644 --- a/app/components/UI/Predict/components/PredictMarket/PredictMarket.tsx +++ b/app/components/UI/Predict/components/PredictMarket/PredictMarket.tsx @@ -12,6 +12,10 @@ interface PredictMarketProps { testID?: string; entryPoint?: PredictEntryPoint; isCarousel?: boolean; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; + /** Called when the user taps a buy button (before betslip opens). */ + onBuyButtonPress?: (marketId: string) => void; } const PredictMarket: React.FC = ({ @@ -19,6 +23,8 @@ const PredictMarket: React.FC = ({ testID, entryPoint: propEntryPoint, isCarousel = false, + onCardPress, + onBuyButtonPress, }) => { const contextEntryPoint = usePredictEntryPoint(); const entryPoint = @@ -32,6 +38,8 @@ const PredictMarket: React.FC = ({ testID={testID} entryPoint={entryPoint} isCarousel={isCarousel} + onCardPress={onCardPress} + onBuyButtonPress={onBuyButtonPress} /> ); } @@ -43,6 +51,8 @@ const PredictMarket: React.FC = ({ testID={testID} entryPoint={entryPoint} isCarousel={isCarousel} + onCardPress={onCardPress} + onBuyButtonPress={onBuyButtonPress} /> ); } @@ -53,6 +63,8 @@ const PredictMarket: React.FC = ({ testID={testID} entryPoint={entryPoint} isCarousel={isCarousel} + onCardPress={onCardPress} + onBuyButtonPress={onBuyButtonPress} /> ); }; diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx index 6e29aa63367..3dab46dbe3b 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx @@ -50,6 +50,10 @@ interface PredictMarketMultipleProps { testID?: string; entryPoint?: PredictEntryPoint; isCarousel?: boolean; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; + /** Called when the user taps a buy button (before betslip opens). */ + onBuyButtonPress?: (marketId: string) => void; } const PredictMarketMultiple: React.FC = ({ @@ -57,6 +61,8 @@ const PredictMarketMultiple: React.FC = ({ testID, entryPoint: propEntryPoint, isCarousel = false, + onCardPress, + onBuyButtonPress, }) => { const contextEntryPoint = usePredictEntryPoint(); const baseEntryPoint = @@ -137,6 +143,7 @@ const PredictMarketMultiple: React.FC = ({ outcome: PredictOutcome, outcomeToken: PredictOutcomeToken, ) => { + onBuyButtonPress?.(market.id); executeGuardedAction( () => { openBuySheet({ @@ -161,6 +168,7 @@ const PredictMarketMultiple: React.FC = ({ { + onCardPress?.(); navigation.navigate(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_DETAILS, params: { diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx index b831b609ccf..cd52f0264ff 100644 --- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx +++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx @@ -128,6 +128,10 @@ interface PredictMarketSingleProps { testID?: string; entryPoint?: PredictEntryPoint; isCarousel?: boolean; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; + /** Called when the user taps a buy button (before betslip opens). */ + onBuyButtonPress?: (marketId: string) => void; } const PredictMarketSingle: React.FC = ({ @@ -135,6 +139,8 @@ const PredictMarketSingle: React.FC = ({ testID, entryPoint: propEntryPoint, isCarousel = false, + onCardPress, + onBuyButtonPress, }) => { const contextEntryPoint = usePredictEntryPoint(); const baseEntryPoint = @@ -185,6 +191,7 @@ const PredictMarketSingle: React.FC = ({ const yesPercentage = getYesPercentage(); const handleBuy = (token: PredictOutcomeToken) => { + onBuyButtonPress?.(market.id); executeGuardedAction( () => { openBuySheet({ @@ -204,6 +211,7 @@ const PredictMarketSingle: React.FC = ({ { + onCardPress?.(); navigation.navigate(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_DETAILS, params: { diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx index b25adb66bb4..18700a3f9fa 100644 --- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx +++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx @@ -27,6 +27,10 @@ interface PredictMarketSportCardProps { entryPoint?: PredictEntryPoint; onDismiss?: () => void; isCarousel?: boolean; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; + /** Called when the user taps a buy button (before betslip opens). */ + onBuyButtonPress?: (marketId: string) => void; } const PredictMarketSportCard: React.FC = ({ @@ -35,6 +39,8 @@ const PredictMarketSportCard: React.FC = ({ entryPoint: propEntryPoint, onDismiss, isCarousel, + onCardPress, + onBuyButtonPress, }) => { const tw = useTailwind(); const contextEntryPoint = usePredictEntryPoint(); @@ -57,6 +63,7 @@ const PredictMarketSportCard: React.FC = ({ style={tw.style(isCarousel ? '' : 'my-[8px]')} testID={testID} onPress={() => { + onCardPress?.(); navigation.navigate(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_DETAILS, params: { @@ -102,6 +109,7 @@ const PredictMarketSportCard: React.FC = ({ entryPoint={resolvedEntryPoint} testID={testID ? `${testID}-footer` : undefined} isCarousel={isCarousel} + onBuyButtonPress={onBuyButtonPress} /> diff --git a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx index 69792b7c999..f963826c67b 100644 --- a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx +++ b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx @@ -27,6 +27,8 @@ interface PredictSportCardFooterProps { testID?: string; entryPoint?: PredictEntryPoint; isCarousel?: boolean; + /** Called when the user taps a buy button (before betslip opens). */ + onBuyButtonPress?: (marketId: string) => void; } const PredictSportCardFooter: React.FC = ({ @@ -34,6 +36,7 @@ const PredictSportCardFooter: React.FC = ({ testID, entryPoint: propEntryPoint, isCarousel, + onBuyButtonPress, }) => { const tw = useTailwind(); const navigation = @@ -82,6 +85,7 @@ const PredictSportCardFooter: React.FC = ({ ), ) ?? market.outcomes?.[0]; + onBuyButtonPress?.(market.id); executeGuardedAction( () => { openBuySheet({ @@ -96,7 +100,13 @@ const PredictSportCardFooter: React.FC = ({ }, ); }, - [executeGuardedAction, resolvedEntryPoint, openBuySheet, market], + [ + executeGuardedAction, + resolvedEntryPoint, + openBuySheet, + market, + onBuyButtonPress, + ], ); const handleClaimPress = useCallback(async () => { diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx index 1d6ba218465..efc92ff877a 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx @@ -72,6 +72,11 @@ interface TrendingTokenRowItemProps { * asset details screen (including network-add logic and analytics tracking). */ onPress?: (token: TrendingAsset) => void; + /** + * Called synchronously before the card's press handler fires. + * Useful for injecting analytics without overriding navigation. + */ + onCardPress?: () => void; /** * When the same token row appears in multiple Explore sections, set this to keep * `testID` (and E2E selectors) unique per instance. @@ -126,6 +131,7 @@ const TrendingTokenRowItem = ({ tokenDetailsSource = TokenDetailsSource.Trending, transactionActiveAbTests, onPress, + onCardPress, testIdInstanceKey, }: TrendingTokenRowItemProps) => { const { styles } = useStyles(styleSheet, {}); @@ -165,12 +171,13 @@ const TrendingTokenRowItem = ({ }); const handlePress = useCallback(async () => { + onCardPress?.(); if (onPress) { onPress(token); return; } await defaultOnPress(); - }, [onPress, token, defaultOnPress]); + }, [onPress, onCardPress, token, defaultOnPress]); const rowTestId = testIdInstanceKey ? `trending-token-row-item-${testIdInstanceKey}-${token.assetId}` diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx index 2f37f6c9bfc..6d42545cb2f 100644 --- a/app/components/Views/TrendingView/TrendingView.tsx +++ b/app/components/Views/TrendingView/TrendingView.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { TouchableOpacity } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; @@ -33,6 +33,19 @@ import SportsTab from './tabs/SportsTab'; import DappsTab from './tabs/DappsTab'; import { TrendingViewSelectorsIDs } from './TrendingView.testIds'; import ExplorePageV1 from './ExplorePageV1'; +import { + trackExploreInteracted, + type ExploreTabName, +} from './search/analytics'; + +const TAB_NAMES: ExploreTabName[] = [ + 'Now', + 'Macro', + 'RWAs', + 'Crypto', + 'Sports', + 'Sites', +]; export const ExploreFeed: React.FC = () => { const tw = useTailwind(); @@ -89,6 +102,19 @@ export const ExploreFeed: React.FC = () => { navigation.navigate(Routes.EXPLORE_SEARCH); }, [navigation]); + const previousTabRef = useRef('Now'); + + const handleTabChange = useCallback(({ i }: { i: number }) => { + const destinationTab = TAB_NAMES[i]; + if (!destinationTab) return; + trackExploreInteracted({ + interaction_type: 'tab_switched', + tab_name: destinationTab, + previous_tab: previousTabRef.current, + }); + previousTabRef.current = destinationTab; + }, []); + return ( { {!isBasicFunctionalityEnabled ? ( ) : isExplorePageV2Enabled ? ( - + void; testID?: string; + /** Tab context for analytics — required when onViewAll is set. */ + tabName?: ExploreTabName; + /** Section context for analytics — required when onViewAll is set. */ + sectionName?: ExploreSectionName; } const SectionHeader: React.FC = ({ @@ -19,24 +28,39 @@ const SectionHeader: React.FC = ({ subtitle, onViewAll, testID, -}) => ( - <> - - {subtitle && ( - - {subtitle} - - )} - -); + tabName, + sectionName, +}) => { + const handleViewAll = useCallback(() => { + if (tabName && sectionName) { + trackExploreInteracted({ + interaction_type: 'section_see_all_tapped', + tab_name: tabName, + section_name: sectionName, + }); + } + onViewAll?.(); + }, [onViewAll, tabName, sectionName]); + + return ( + <> + + {subtitle && ( + + {subtitle} + + )} + + ); +}; export default SectionHeader; diff --git a/app/components/Views/TrendingView/feeds/dapps/SiteTileRowItem.tsx b/app/components/Views/TrendingView/feeds/dapps/SiteTileRowItem.tsx index ddde0d1ecbf..d19aae4afa7 100644 --- a/app/components/Views/TrendingView/feeds/dapps/SiteTileRowItem.tsx +++ b/app/components/Views/TrendingView/feeds/dapps/SiteTileRowItem.tsx @@ -55,17 +55,23 @@ const styleSheet = ({ theme }: { theme: Theme }) => interface SiteTileRowItemProps { site: SiteData; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; } /** * Compact tile (icon, title, url) for Explore "Recents" / "Networks" carousels. */ -const SiteTileRowItem: React.FC = ({ site }) => { +const SiteTileRowItem: React.FC = ({ + site, + onCardPress, +}) => { const navigation = useNavigation(); const { styles } = useStyles(styleSheet); const tw = useTailwind(); const onPress = () => { + onCardPress?.(); navigation.navigate(Routes.BROWSER.HOME, { screen: Routes.BROWSER.VIEW, params: { diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsPillItem.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsPillItem.tsx index 2872d094b51..c24549de87c 100644 --- a/app/components/Views/TrendingView/feeds/perps/PerpsPillItem.tsx +++ b/app/components/Views/TrendingView/feeds/perps/PerpsPillItem.tsx @@ -15,9 +15,11 @@ const LOGO_SIZE = 24; interface PerpsPillItemProps { item: PerpsFeedItem; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; } -const PerpsPillItem: React.FC = ({ item }) => { +const PerpsPillItem: React.FC = ({ item, onCardPress }) => { const navigation = useNavigation>(); const { market } = item; @@ -44,6 +46,7 @@ const PerpsPillItem: React.FC = ({ item }) => { }, [market.change24hPercent]); const onPress = () => { + onCardPress?.(); navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: { market, source: PERPS_EVENT_VALUE.SOURCE.EXPLORE }, diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsRowItem.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsRowItem.tsx index 1d2e09bfd29..cef900b9124 100644 --- a/app/components/Views/TrendingView/feeds/perps/PerpsRowItem.tsx +++ b/app/components/Views/TrendingView/feeds/perps/PerpsRowItem.tsx @@ -10,15 +10,18 @@ import Routes from '../../../../../constants/navigation/Routes'; interface PerpsRowItemProps { market: PerpsMarketData; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; } /** Compact list row for perps — used by pill-toggled lists and search. */ -const PerpsRowItem: React.FC = ({ market }) => { +const PerpsRowItem: React.FC = ({ market, onCardPress }) => { const navigation = useNavigation>(); return ( { + onCardPress?.(); navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: { market, source: PERPS_EVENT_VALUE.SOURCE.EXPLORE }, diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsTileRowItem.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsTileRowItem.tsx index 5839b6c3fce..d640187d8fd 100644 --- a/app/components/Views/TrendingView/feeds/perps/PerpsTileRowItem.tsx +++ b/app/components/Views/TrendingView/feeds/perps/PerpsTileRowItem.tsx @@ -9,11 +9,14 @@ import type { PerpsFeedItem } from './usePerpsFeed'; interface PerpsTileRowItemProps { item: PerpsFeedItem; testIdPrefix: string; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; } const PerpsTileRowItem: React.FC = ({ item, testIdPrefix, + onCardPress, }) => { const navigation = useNavigation>(); const { market, sparkline, isWatchlisted } = item; @@ -25,6 +28,7 @@ const PerpsTileRowItem: React.FC = ({ showFavoriteTag={isWatchlisted} testID={`${testIdPrefix}-${market.symbol}`} onPress={() => { + onCardPress?.(); navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: { market, source: PERPS_EVENT_VALUE.SOURCE.EXPLORE }, diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx new file mode 100644 index 00000000000..048a00954c4 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx @@ -0,0 +1,98 @@ +import React, { useCallback, useRef } from 'react'; +import { Box } from '@metamask/design-system-react-native'; +import type { ListRenderItem } from '@shopify/flash-list'; +import type { PerpsMarketData } from '@metamask/perps-controller'; +import PerpsRowItem from './PerpsRowItem'; +import PerpsRowSkeleton from '../../../../UI/Perps/components/PerpsRowSkeleton'; +import PillToggleCardList, { + type PillToggleCardListTab, +} from '../../components/PillToggleCardList'; +import SectionHeader from '../../components/SectionHeader'; +import { + type ExploreTabName, + type ExploreSectionName, + trackExploreInteracted, +} from '../../search/analytics'; + +const PerpsRowSingleSkeleton: React.FC = () => ; + +export interface PerpsToggleBlockProps { + title: string; + tabs: PillToggleCardListTab[]; + isLoading: boolean; + defaultPillKey: string; + onViewAll: (filter: string) => void; + /** Analytics context */ + tabName: ExploreTabName; + sectionName: ExploreSectionName; + /** Test IDs */ + headerTestID: string; + idPrefix: string; + testIdPrefix: string; + listTestId: string; +} + +/** + * Shared perps section that renders a pill-toggled list of perp rows with + * a "See all" header. Used by MacroTab and RwasTab. + */ +const PerpsToggleBlock: React.FC = ({ + title, + tabs, + isLoading, + defaultPillKey, + onViewAll, + tabName, + sectionName, + headerTestID, + idPrefix, + testIdPrefix, + listTestId, +}) => { + const activePillKey = useRef(defaultPillKey); + + const renderItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: tabName, + section_name: sectionName, + asset_type: 'perp', + position: index, + item_clicked: item.symbol, + }) + } + /> + ), + [tabName, sectionName], + ); + + return ( + + onViewAll(activePillKey.current)} + testID={headerTestID} + tabName={tabName} + sectionName={sectionName} + /> + + tabs={tabs} + isLoading={isLoading} + renderItem={renderItem} + Skeleton={PerpsRowSingleSkeleton} + idPrefix={idPrefix} + onPillChange={(key) => { + activePillKey.current = key; + }} + testIdPrefix={testIdPrefix} + listTestId={listTestId} + /> + + ); +}; + +export default PerpsToggleBlock; diff --git a/app/components/Views/TrendingView/feeds/predictions/PredictionRowItem.tsx b/app/components/Views/TrendingView/feeds/predictions/PredictionRowItem.tsx index 9700f539ae6..47140b46a6a 100644 --- a/app/components/Views/TrendingView/feeds/predictions/PredictionRowItem.tsx +++ b/app/components/Views/TrendingView/feeds/predictions/PredictionRowItem.tsx @@ -7,17 +7,23 @@ import type { PredictMarket as PredictMarketType } from '../../../../UI/Predict/ interface PredictionCarouselRowItemProps { market: PredictMarketType; testIdPrefix?: string; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; + /** Called when the user taps a buy button (before betslip opens). */ + onBuyButtonPress?: (marketId: string) => void; } /** Carousel-style market card used inside Explore home tabs. */ export const PredictionCarouselRowItem: React.FC< PredictionCarouselRowItemProps -> = ({ market, testIdPrefix }) => ( +> = ({ market, testIdPrefix, onCardPress, onBuyButtonPress }) => ( ); diff --git a/app/components/Views/TrendingView/feeds/sites/SiteRowItem.tsx b/app/components/Views/TrendingView/feeds/sites/SiteRowItem.tsx index 8c49c0592d6..0c53d1687d3 100644 --- a/app/components/Views/TrendingView/feeds/sites/SiteRowItem.tsx +++ b/app/components/Views/TrendingView/feeds/sites/SiteRowItem.tsx @@ -22,27 +22,41 @@ const openSiteInBrowser = (navigation: AppNavigationProp, site: SiteData) => { interface SiteRowItemProps { site: SiteData; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; } /** Generic site row (sites + dapps_favorites without remove action). */ -export const SiteRowItem: React.FC = ({ site }) => { +export const SiteRowItem: React.FC = ({ + site, + onCardPress, +}) => { const navigation = useNavigation(); return ( openSiteInBrowser(navigation, site)} + onPress={() => { + onCardPress?.(); + openSiteInBrowser(navigation, site); + }} /> ); }; /** Favorite-site row with the "remove from favorites" affordance. */ -export const FavoriteSiteRowItem: React.FC = ({ site }) => { +export const FavoriteSiteRowItem: React.FC = ({ + site, + onCardPress, +}) => { const navigation = useNavigation(); const dispatch = useDispatch(); return ( openSiteInBrowser(navigation, site)} + onPress={() => { + onCardPress?.(); + openSiteInBrowser(navigation, site); + }} onRemoveFavorite={() => dispatch( removeBookmark({ diff --git a/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.tsx b/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.tsx index 7f3c0dd441f..3500053eebc 100644 --- a/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.tsx +++ b/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.tsx @@ -26,19 +26,27 @@ const LOGO_SIZE = 24; interface CryptoMoversPillItemProps { token: TrendingAsset; index: number; + /** Called synchronously before the card's press handler fires. */ + onCardPress?: () => void; } const CryptoMoversPillItem: React.FC = ({ token, index, + onCardPress, }) => { - const { onPress } = useTrendingTokenPress({ + const { onPress: defaultOnPress } = useTrendingTokenPress({ token, index, filterContext: CRYPTO_MOVERS_HOME_FILTER_CONTEXT, tokenDetailsSource: TokenDetailsSource.ExploreNowMovers, }); + const onPress = React.useCallback(async () => { + onCardPress?.(); + await defaultOnPress(); + }, [onCardPress, defaultOnPress]); + const networkBadgeImageSource = useMemo(() => { const caipChainId = getCaipChainIdFromAssetId(token.assetId); if (!isCaipChainId(caipChainId)) return undefined; diff --git a/app/components/Views/TrendingView/feeds/tokens/TokenRowItem.tsx b/app/components/Views/TrendingView/feeds/tokens/TokenRowItem.tsx index 297710c5b21..514e607e164 100644 --- a/app/components/Views/TrendingView/feeds/tokens/TokenRowItem.tsx +++ b/app/components/Views/TrendingView/feeds/tokens/TokenRowItem.tsx @@ -13,6 +13,8 @@ interface TokenRowItemProps { index: number; /** When omitted, defaults to {@link TokenDetailsSource.Trending} in the row item. */ tokenDetailsSource?: TokenDetailsSource; + /** Called synchronously before the card's press handler fires. */ + onCardPress?: () => void; } /** Token row used inside the home tabs. */ @@ -20,12 +22,14 @@ export const TokenRowItem: React.FC = ({ token, index, tokenDetailsSource, + onCardPress, }) => ( ); diff --git a/app/components/Views/TrendingView/search/analytics.ts b/app/components/Views/TrendingView/search/analytics.ts index d8c87e2325c..2676dca8487 100644 --- a/app/components/Views/TrendingView/search/analytics.ts +++ b/app/components/Views/TrendingView/search/analytics.ts @@ -3,6 +3,63 @@ import { analytics } from '../../../../util/analytics/analytics'; import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; +export type ExploreTabName = + | 'Now' + | 'Macro' + | 'RWAs' + | 'Crypto' + | 'Sports' + | 'Sites'; + +export type ExploreSectionName = + | 'tokens_movers' + | 'tokens_trending' + | 'perps_movers' + | 'perps_stocks_commodities' + | 'perps_markets' + | 'perps_crypto' + | 'stocks' + | 'predictions_trending' + | 'predictions_politics' + | 'predictions_crypto' + | 'predictions_sports' + | 'predictions_football' + | 'predictions_basketball' + | 'predictions_tennis' + | 'sites_recents' + | 'sites_favorites' + | 'sites_ecosystems' + | 'sites_popular'; + +export interface ExploreInteractedProperties { + interaction_type: + | 'tab_switched' + | 'section_see_all_tapped' + | 'section_item_tapped' + | 'prediction_voted'; + tab_name: ExploreTabName; + section_name?: ExploreSectionName; + position?: number; + asset_type?: 'token' | 'stock' | 'perp' | 'prediction' | 'dapp'; + previous_tab?: ExploreTabName; + token_address?: string; + token_symbol?: string; + chain_id?: string; + item_clicked?: string; +} + +export const trackExploreInteracted = ( + properties: ExploreInteractedProperties, +): void => { + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( + MetaMetricsEvents.EXPLORE_INTERACTED, + ) + .addProperties(properties as unknown as Record) + .build(), + ); +}; + /** Single-line wrapper around the analytics builder boilerplate. */ export const trackExploreEvent = ( event: Parameters[0], diff --git a/app/components/Views/TrendingView/tabs/CryptoTab.tsx b/app/components/Views/TrendingView/tabs/CryptoTab.tsx index 267201865b9..7a5213d96f4 100644 --- a/app/components/Views/TrendingView/tabs/CryptoTab.tsx +++ b/app/components/Views/TrendingView/tabs/CryptoTab.tsx @@ -12,6 +12,7 @@ import Routes from '../../../../constants/navigation/Routes'; import { strings } from '../../../../../locales/i18n'; import { TokenDetailsSource } from '../../../UI/TokenDetails/constants/constants'; import { useTokensFeed } from '../feeds/tokens/useTokensFeed'; +import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/TrendingTokenRowItem/utils'; import { TokenRowItem } from '../feeds/tokens/TokenRowItem'; import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; import { usePerpsFeed, type PerpsFeedItem } from '../feeds/perps/usePerpsFeed'; @@ -29,6 +30,7 @@ import HorizontalCarousel from '../components/HorizontalCarousel'; import SectionHeader from '../components/SectionHeader'; import TileCarousel from '../components/TileCarousel'; import type { TabProps } from '../hooks/useExploreRefresh'; +import { trackExploreInteracted } from '../search/analytics'; interface CryptoPerpsBlockProps { refresh: TabProps['refresh']; @@ -53,14 +55,26 @@ const CryptoPerpsBlock: React.FC = ({ title={strings('trending.crypto_perps_section')} onViewAll={onViewAll} testID="section-header-view-all-crypto_perps" + tabName="Crypto" + sectionName="perps_crypto" /> data={perps.data} isLoading={perps.isLoading} - renderItem={(item) => ( + renderItem={(item, index) => ( + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Crypto', + section_name: 'perps_crypto', + asset_type: 'perp', + position: index, + item_clicked: item.market.symbol, + }) + } /> )} keyExtractor={(item) => item.market.symbol} @@ -91,16 +105,46 @@ const CryptoTab: React.FC = ({ refresh, refreshing, onRefresh }) => { token={item} index={index} tokenDetailsSource={TokenDetailsSource.ExploreCryptoTrending} + onCardPress={() => + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Crypto', + section_name: 'tokens_trending', + asset_type: 'token', + position: index, + token_symbol: item.symbol, + chain_id: getCaipChainIdFromAssetId(item.assetId), + item_clicked: item.assetId, + }) + } /> ), [], ); const renderPredictionItem: ListRenderItem = useCallback( - ({ item }) => ( + ({ item, index }) => ( + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Crypto', + section_name: 'predictions_crypto', + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'Crypto', + section_name: 'predictions_crypto', + item_clicked: marketId, + }) + } /> ), [], @@ -120,6 +164,8 @@ const CryptoTab: React.FC = ({ refresh, refreshing, onRefresh }) => { navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW) } testID="section-header-view-all-tokens" + tabName="Crypto" + sectionName="tokens_trending" /> data={tokens.data} @@ -148,6 +194,8 @@ const CryptoTab: React.FC = ({ refresh, refreshing, onRefresh }) => { title={strings('trending.predictions')} onViewAll={() => navigateToPredictionsList(navigation, 'crypto')} testID="section-header-view-all-crypto_predictions" + tabName="Crypto" + sectionName="predictions_crypto" /> data={cryptoPredictions.data} diff --git a/app/components/Views/TrendingView/tabs/DappsTab.tsx b/app/components/Views/TrendingView/tabs/DappsTab.tsx index 4f47a786ad3..6e5ad0b18e4 100644 --- a/app/components/Views/TrendingView/tabs/DappsTab.tsx +++ b/app/components/Views/TrendingView/tabs/DappsTab.tsx @@ -19,6 +19,7 @@ import ExploreScroll from '../components/ExploreScroll'; import SectionHeader from '../components/SectionHeader'; import TileCarousel from '../components/TileCarousel'; import type { TabProps } from '../hooks/useExploreRefresh'; +import { trackExploreInteracted } from '../search/analytics'; const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const navigation = useNavigation(); @@ -29,12 +30,40 @@ const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const sites = useSitesFeed({ refresh }); const renderFavorite: ListRenderItem = useCallback( - ({ item }) => , + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sites', + section_name: 'sites_favorites', + asset_type: 'dapp', + position: index, + item_clicked: item.url, + }) + } + /> + ), [], ); const renderSite: ListRenderItem = useCallback( - ({ item }) => , + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sites', + section_name: 'sites_popular', + asset_type: 'dapp', + position: index, + item_clicked: item.url, + }) + } + /> + ), [], ); @@ -53,7 +82,21 @@ const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { data={recents.data} isLoading={recents.isLoading} - renderItem={(site) => } + renderItem={(site, index) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sites', + section_name: 'sites_recents', + asset_type: 'dapp', + position: index, + item_clicked: site.url, + }) + } + /> + )} keyExtractor={(site) => site.url} Skeleton={SiteTileSkeleton} compactSectionTail @@ -70,6 +113,8 @@ const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { navigation.navigate(Routes.SITES_FULL_VIEW, { mode: 'favorites' }) } testID="section-header-view-all-dapps_favorites" + tabName="Sites" + sectionName="sites_favorites" /> data={favorites.data} @@ -90,7 +135,21 @@ const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { data={networks.data} isLoading={false} - renderItem={(site) => } + renderItem={(site, index) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sites', + section_name: 'sites_ecosystems', + asset_type: 'dapp', + position: index, + item_clicked: site.url, + }) + } + /> + )} keyExtractor={(site) => site.url} Skeleton={SiteTileSkeleton} testID="explore-dapps_networks-carousel" @@ -103,6 +162,8 @@ const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { title={strings('trending.popular')} onViewAll={() => navigation.navigate(Routes.SITES_FULL_VIEW)} testID="section-header-view-all-sites" + tabName="Sites" + sectionName="sites_popular" /> data={sites.data} diff --git a/app/components/Views/TrendingView/tabs/MacroTab.tsx b/app/components/Views/TrendingView/tabs/MacroTab.tsx index d50702420fb..e20a30ee710 100644 --- a/app/components/Views/TrendingView/tabs/MacroTab.tsx +++ b/app/components/Views/TrendingView/tabs/MacroTab.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { Box } from '@metamask/design-system-react-native'; @@ -12,8 +12,7 @@ import { selectPredictEnabledFlag } from '../../../UI/Predict'; import { strings } from '../../../../../locales/i18n'; import { usePerpsFeed } from '../feeds/perps/usePerpsFeed'; import PerpsSectionProvider from '../feeds/perps/PerpsSectionProvider'; -import PerpsRowItem from '../feeds/perps/PerpsRowItem'; -import PerpsRowSkeleton from '../../../UI/Perps/components/PerpsRowSkeleton'; +import PerpsToggleBlock from '../feeds/perps/PerpsToggleBlock'; import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation'; import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; import { PredictionCarouselRowItem } from '../feeds/predictions/PredictionRowItem'; @@ -21,13 +20,10 @@ import PredictionsSkeleton from '../feeds/predictions/PredictionsSkeleton'; import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavigation'; import ExploreScroll from '../components/ExploreScroll'; import HorizontalCarousel from '../components/HorizontalCarousel'; -import PillToggleCardList, { - type PillToggleCardListTab, -} from '../components/PillToggleCardList'; +import type { PillToggleCardListTab } from '../components/PillToggleCardList'; import SectionHeader from '../components/SectionHeader'; import type { TabProps } from '../hooks/useExploreRefresh'; - -const PerpsRowSingleSkeleton: React.FC = () => ; +import { trackExploreInteracted } from '../search/analytics'; interface MacroPerpsBlockProps { refresh: TabProps['refresh']; @@ -39,58 +35,43 @@ const MacroPerpsBlock: React.FC = ({ onViewAll, }) => { const perps = usePerpsFeed({ variant: 'macro', refresh }); - const activePillKey = useRef('stocks'); const tabs = useMemo[]>(() => { - const stocks = perps.data - .filter((d) => d.market.marketType === 'equity') - .slice(0, 3) - .map((d) => d.market); - const commodities = perps.data - .filter((d) => d.market.marketType === 'commodity') - .slice(0, 3) - .map((d) => d.market); + const byType = (type: PerpsMarketData['marketType']) => + perps.data + .filter((d) => d.market.marketType === type) + .slice(0, 3) + .map((d) => d.market); return [ { key: 'stocks', name: strings('trending.macro_pill_stocks'), - items: stocks, + items: byType('equity'), }, { key: 'commodities', name: strings('trending.macro_pill_commodities'), - items: commodities, + items: byType('commodity'), }, ]; }, [perps.data]); - const renderItem: ListRenderItem = useCallback( - ({ item }) => , - [], - ); - if (!perps.isLoading && perps.data.length === 0) return null; return ( - - onViewAll(activePillKey.current)} - testID="section-header-view-all-macro_stocks_commodity_perps" - /> - - tabs={tabs} - isLoading={perps.isLoading} - renderItem={renderItem} - Skeleton={PerpsRowSingleSkeleton} - idPrefix="macro_stocks_commodity_perps" - onPillChange={(key) => { - activePillKey.current = key; - }} - testIdPrefix="macro-stocks-commodity-pills" - listTestId="macro-stocks-commodity-perps-list" - /> - + ); }; @@ -104,10 +85,28 @@ const MacroTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const politics = usePredictionsFeed({ variant: 'politics', refresh }); const renderPredictionItem: ListRenderItem = useCallback( - ({ item }) => ( + ({ item, index }) => ( + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Macro', + section_name: 'predictions_politics', + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'Macro', + section_name: 'predictions_politics', + item_clicked: marketId, + }) + } /> ), [], @@ -126,6 +125,8 @@ const MacroTab: React.FC = ({ refresh, refreshing, onRefresh }) => { navigateToPredictionsList(appNavigation, 'politics') } testID="section-header-view-all-politics_predictions" + tabName="Macro" + sectionName="predictions_politics" /> data={politics.data} diff --git a/app/components/Views/TrendingView/tabs/NowTab.tsx b/app/components/Views/TrendingView/tabs/NowTab.tsx index b02c4eccc46..94b6fb0ae92 100644 --- a/app/components/Views/TrendingView/tabs/NowTab.tsx +++ b/app/components/Views/TrendingView/tabs/NowTab.tsx @@ -27,12 +27,14 @@ import { PredictionCarouselRowItem } from '../feeds/predictions/PredictionRowIte import PredictionsSkeleton from '../feeds/predictions/PredictionsSkeleton'; import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavigation'; import { useStocksFeed } from '../feeds/stocks/useStocksFeed'; +import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/TrendingTokenRowItem/utils'; import CardList from '../components/CardList'; import ExploreScroll from '../components/ExploreScroll'; import HorizontalCarousel from '../components/HorizontalCarousel'; import PillScrollList from '../components/PillScrollList'; import SectionHeader from '../components/SectionHeader'; import type { TabProps } from '../hooks/useExploreRefresh'; +import { trackExploreInteracted } from '../search/analytics'; import WhatsHappeningSection from '../../Homepage/Sections/WhatsHappening'; import type { SectionRefreshHandle } from '../../Homepage/types'; import { selectWhatsHappeningEnabled } from '../../../../selectors/featureFlagController/whatsHappening'; @@ -57,11 +59,27 @@ const PerpsBlock: React.FC = ({ refresh, navigation }) => { title={strings('trending.perps_movers')} onViewAll={() => navigateToPerpsMarketList(navigation)} testID="section-header-view-all-perps" + tabName="Now" + sectionName="perps_movers" /> data={perps.data} isLoading={perps.isLoading} - renderItem={(item) => } + renderItem={(item, index) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Now', + section_name: 'perps_movers', + asset_type: 'perp', + position: index, + item_clicked: item.market.symbol, + }) + } + /> + )} keyExtractor={(item) => item.market.symbol} Skeleton={CryptoMoversSkeleton} listTestId="explore-perps-pills-list" @@ -90,10 +108,28 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const stocks = useStocksFeed({ refresh }); const renderPredictionItem: ListRenderItem = useCallback( - ({ item }) => ( + ({ item, index }) => ( + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Now', + section_name: 'predictions_trending', + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'Now', + section_name: 'predictions_trending', + item_clicked: marketId, + }) + } /> ), [], @@ -105,6 +141,18 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { token={item} index={index} tokenDetailsSource={TokenDetailsSource.ExploreNowStocks} + onCardPress={() => + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Now', + section_name: 'stocks', + asset_type: 'stock', + position: index, + token_symbol: item.symbol, + chain_id: getCaipChainIdFromAssetId(item.assetId), + item_clicked: item.assetId, + }) + } /> ), [], @@ -138,6 +186,8 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { title={strings('wallet.predict')} onViewAll={() => navigateToPredictionsList(navigation, 'trending')} testID="section-header-view-all-predictions" + tabName="Now" + sectionName="predictions_trending" /> data={predictions.data} @@ -157,12 +207,29 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW) } testID="section-header-view-all-crypto_movers" + tabName="Now" + sectionName="tokens_movers" /> data={cryptoMovers.data} isLoading={cryptoMovers.isLoading} renderItem={(token, index) => ( - + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Now', + section_name: 'tokens_movers', + asset_type: 'token', + position: index, + token_symbol: token.symbol, + chain_id: getCaipChainIdFromAssetId(token.assetId), + item_clicked: token.assetId, + }) + } + /> )} keyExtractor={(token) => token.assetId ?? ''} Skeleton={CryptoMoversSkeleton} @@ -185,6 +252,8 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { navigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW) } testID="section-header-view-all-stocks" + tabName="Now" + sectionName="stocks" /> data={stocks.data} diff --git a/app/components/Views/TrendingView/tabs/RwasTab.tsx b/app/components/Views/TrendingView/tabs/RwasTab.tsx index a52d2c6932b..6cd85c8e373 100644 --- a/app/components/Views/TrendingView/tabs/RwasTab.tsx +++ b/app/components/Views/TrendingView/tabs/RwasTab.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { Box } from '@metamask/design-system-react-native'; @@ -16,10 +16,10 @@ import { TokenDetailsSource } from '../../../UI/TokenDetails/constants/constants import { TokenRowItem } from '../feeds/tokens/TokenRowItem'; import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; import { useStocksFeed } from '../feeds/stocks/useStocksFeed'; +import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/TrendingTokenRowItem/utils'; import { usePerpsFeed } from '../feeds/perps/usePerpsFeed'; import PerpsSectionProvider from '../feeds/perps/PerpsSectionProvider'; -import PerpsRowItem from '../feeds/perps/PerpsRowItem'; -import PerpsRowSkeleton from '../../../UI/Perps/components/PerpsRowSkeleton'; +import PerpsToggleBlock from '../feeds/perps/PerpsToggleBlock'; import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation'; import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; import { PredictionCarouselRowItem } from '../feeds/predictions/PredictionRowItem'; @@ -28,13 +28,10 @@ import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavig import CardList from '../components/CardList'; import ExploreScroll from '../components/ExploreScroll'; import HorizontalCarousel from '../components/HorizontalCarousel'; -import PillToggleCardList, { - type PillToggleCardListTab, -} from '../components/PillToggleCardList'; +import type { PillToggleCardListTab } from '../components/PillToggleCardList'; import SectionHeader from '../components/SectionHeader'; import type { TabProps } from '../hooks/useExploreRefresh'; - -const PerpsRowSingleSkeleton: React.FC = () => ; +import { trackExploreInteracted } from '../search/analytics'; interface RwaPerpsBlockProps { refresh: TabProps['refresh']; @@ -46,7 +43,6 @@ const RwaPerpsBlock: React.FC = ({ onViewAll, }) => { const perps = usePerpsFeed({ variant: 'rwa', refresh }); - const activePillKey = useRef('commodities'); const tabs = useMemo[]>(() => { const byType = (type: PerpsMarketData['marketType']) => @@ -73,33 +69,22 @@ const RwaPerpsBlock: React.FC = ({ ]; }, [perps.data]); - const renderItem: ListRenderItem = useCallback( - ({ item }) => , - [], - ); - if (!perps.isLoading && perps.data.length === 0) return null; return ( - - onViewAll(activePillKey.current)} - testID="section-header-view-all-rwa_perps" - /> - - tabs={tabs} - isLoading={perps.isLoading} - renderItem={renderItem} - Skeleton={PerpsRowSingleSkeleton} - idPrefix="rwa_perps" - onPillChange={(key) => { - activePillKey.current = key; - }} - testIdPrefix="rwa-perps-pills" - listTestId="rwa-perps-pill-toggled-list" - /> - + ); }; @@ -114,10 +99,28 @@ const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const stocks = useStocksFeed({ refresh }); const renderPredictionItem: ListRenderItem = useCallback( - ({ item }) => ( + ({ item, index }) => ( + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'RWAs', + section_name: 'predictions_politics', + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'RWAs', + section_name: 'predictions_politics', + item_clicked: marketId, + }) + } /> ), [], @@ -129,6 +132,18 @@ const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => { token={item} index={index} tokenDetailsSource={TokenDetailsSource.ExploreRwasStocks} + onCardPress={() => + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'RWAs', + section_name: 'stocks', + asset_type: 'stock', + position: index, + token_symbol: item.symbol, + chain_id: getCaipChainIdFromAssetId(item.assetId), + item_clicked: item.assetId, + }) + } /> ), [], @@ -148,6 +163,8 @@ const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => { navigateToPredictionsList(appNavigation, 'politics') } testID="section-header-view-all-politics_predictions" + tabName="RWAs" + sectionName="predictions_politics" /> data={politics.data} @@ -167,6 +184,8 @@ const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => { appNavigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW) } testID="section-header-view-all-stocks" + tabName="RWAs" + sectionName="stocks" /> data={stocks.data} diff --git a/app/components/Views/TrendingView/tabs/SportsTab.tsx b/app/components/Views/TrendingView/tabs/SportsTab.tsx index 57963867e20..e7d6d0ed1e3 100644 --- a/app/components/Views/TrendingView/tabs/SportsTab.tsx +++ b/app/components/Views/TrendingView/tabs/SportsTab.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useRef } from 'react'; import { ActivityIndicator, TouchableOpacity, @@ -36,6 +36,16 @@ import HorizontalCarousel from '../components/HorizontalCarousel'; import PillRow from '../components/PillRow'; import SectionHeader from '../components/SectionHeader'; import type { TabProps } from '../hooks/useExploreRefresh'; +import { + trackExploreInteracted, + type ExploreSectionName, +} from '../search/analytics'; + +const SPORT_KEY_TO_SECTION: Record = { + soccer: 'predictions_football', + basketball: 'predictions_basketball', + tennis: 'predictions_tennis', +}; interface SportsListHeaderProps { showSportsPredictions: boolean; @@ -47,10 +57,31 @@ interface SportsListHeaderProps { navigation: AppNavigationProp; } -const renderPredictionItem: ListRenderItem = ({ item }) => ( +const renderPredictionItem: ListRenderItem = ({ + item, + index, +}) => ( + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sports', + section_name: 'predictions_sports', + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'Sports', + section_name: 'predictions_sports', + item_clicked: marketId, + }) + } /> ); @@ -70,6 +101,8 @@ const SportsListHeader: React.FC = ({ title={strings('trending.predictions')} onViewAll={() => navigateToPredictionsList(navigation, 'sports')} testID="section-header-view-all-sports_predictions" + tabName="Sports" + sectionName="predictions_sports" /> data={sportsPredictionsData} @@ -81,39 +114,41 @@ const SportsListHeader: React.FC = ({ )} - - - + - - - {showAllSportsSkeleton && ( - - {[0, 1, 2].map((i) => ( - - - - ))} - - )} - - {showAllSportsEmpty && ( - - + - )} + + {showAllSportsSkeleton && ( + + {[0, 1, 2].map((i) => ( + + + + ))} + + )} + + {showAllSportsEmpty && ( + + + + )} + ); @@ -126,16 +161,44 @@ const SportsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const sportsPredictions = usePredictionsFeed({ variant: 'sports', refresh }); const sportsMarkets = useSportsMarketsFeed({ refresh }); + const { active, activeKey } = sportsMarkets; + const activeKeyRef = useRef(activeKey); + activeKeyRef.current = activeKey; + const renderActiveMarketItem: ListRenderItem = useCallback( - ({ item }) => , + ({ item, index }) => { + const sectionName = + SPORT_KEY_TO_SECTION[activeKeyRef.current] ?? 'predictions_football'; + return ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sports', + section_name: sectionName, + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'Sports', + section_name: sectionName, + item_clicked: marketId, + }) + } + /> + ); + }, [], ); const showSportsPredictions = isPredictEnabled && (sportsPredictions.isLoading || sportsPredictions.data.length > 0); - - const { active, activeKey } = sportsMarkets; const showAllSportsSkeleton = active.isFetching && active.marketData.length === 0; const showAllSportsEmpty = diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 936ba0b0ae0..b2d02a781cb 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -663,6 +663,9 @@ enum EVENT_NAME { // Explore Search EXPLORE_SEARCH_INTERACTED = 'Explore Search Interacted', + // Explore + EXPLORE_INTERACTED = 'Explore Page Interacted', + // Market Insights MARKET_INSIGHTS_CARD_SCROLLED_TO_VIEW = 'Market Insights Card Scrolled to View', MARKET_INSIGHTS_OPENED = 'Market Insights Opened', @@ -1772,6 +1775,8 @@ const events = { EXPLORE_SEARCH_INTERACTED: generateOpt(EVENT_NAME.EXPLORE_SEARCH_INTERACTED), + EXPLORE_INTERACTED: generateOpt(EVENT_NAME.EXPLORE_INTERACTED), + // Share SHARE_ACTION: generateOpt(EVENT_NAME.SHARE_ACTION), From 26869940e390cd5d978bca744de50825b1a5483a Mon Sep 17 00:00:00 2001 From: Jorge Carrasco Date: Wed, 6 May 2026 18:32:00 +0200 Subject: [PATCH 22/27] feat(mobile): add yarn cache to prepare job in release pipeline (#29793) ## **Description** The `prepare` job in `build.yml` runs `yarn install --immutable` without a yarn global cache on every pipeline run. Over 21 runs in the past week, this step averaged **2m 18s** (median 2m 19s, sd=2s -- extremely consistent). The `setup-node-modules.yml` workflow already uses `cache: 'yarn'` in the same `actions/setup-node@v4` action and completes yarn install in ~50s-1m. This PR adds `cache: 'yarn'` to the `prepare` job's `actions/setup-node@v4` step, matching the existing pattern. On cache hit, yarn resolves packages from the local global cache instead of downloading from the registry. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: MCWP-574 ## **Manual testing steps** ```gherkin Feature: Yarn cache in prepare job Scenario: First run populates the cache (cache miss) Given a production build is triggered via workflow_dispatch And no prior yarn cache exists for the current yarn.lock When the "Setup Node.js" step runs in the prepare job Then the step log shows no cache restored And "yarn install --immutable" completes at baseline timing (~2m 18s) And the "Post Setup Node.js" step shows "Cache saved" Scenario: Subsequent run restores the cache (cache hit) Given a production build is triggered via workflow_dispatch And a yarn cache exists from a previous run When the "Setup Node.js" step runs in the prepare job Then the step log shows "Cache restored" And "yarn install --immutable" completes in ~50s-1m (cache hit) And the build completes successfully with all artifacts produced ``` ## **Screenshots/Recordings** N/A - CI workflow change only, no UI impact. ### **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 - [ ] I've included tests if applicable - [ ] 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. --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c4d5f10d9d1..556bc59a3e4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -125,6 +125,7 @@ jobs: uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' + cache: 'yarn' - run: yarn install --immutable - run: node scripts/validate-build-config.js From 3de0bcc180986e477d307b34bb3440a0aaaa9efd Mon Sep 17 00:00:00 2001 From: Christopher Ferreira <104831203+christopherferreira9@users.noreply.github.com> Date: Wed, 6 May 2026 17:46:33 +0100 Subject: [PATCH 23/27] test: runs mm-connect tests on rc only for Samsung devices (#29805) ## **Description** This PR makes the following changes to the MMConnect runs: - Only run on Samsung Devices -> reduces consumption on browserstack and keeps tests stable - Only run on `rc` environment ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MMQA-1797 ## **Manual testing steps** N/A ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes CI workflow conditions and device selection for MM-Connect runs, which could unintentionally reduce test coverage or skip runs if the matrix/filter is wrong. No production app code changes, but it affects release-signal reliability. > > **Overview** > Updates the performance E2E GitHub workflow to **scope Android MM-Connect runs more narrowly**. > > MM-Connect now uses a dedicated `android_mm_connect_matrix` filtered to Samsung devices, and both the MM-Connect job and the RN Playground APK upload step are gated to run only when `build_variant` is `rc` (skipping these steps for experimental builds). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1fb6325eee60552bf0baf9e0092ebdc695fc9b3f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .github/workflows/run-performance-e2e.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/run-performance-e2e.yml b/.github/workflows/run-performance-e2e.yml index a8aef5ffc6b..114bff17c87 100644 --- a/.github/workflows/run-performance-e2e.yml +++ b/.github/workflows/run-performance-e2e.yml @@ -142,6 +142,7 @@ jobs: needs: [determine-branch-name] outputs: android_matrix: ${{ steps.read-matrix.outputs.android_matrix }} + android_mm_connect_matrix: ${{ steps.read-matrix.outputs.android_mm_connect_matrix }} ios_matrix: ${{ steps.read-matrix.outputs.ios_matrix }} steps: - name: Checkout code @@ -165,18 +166,23 @@ jobs: fi ANDROID_MATRIX=$(jq ".android_devices | $FILTER" "$FILE") + ANDROID_MM_CONNECT_MATRIX=$(jq '[.android_devices[] | select(.name | contains("Samsung"))]' "$FILE") IOS_MATRIX=$(jq ".ios_devices | $FILTER" "$FILE") { echo "android_matrix<> "$GITHUB_OUTPUT" echo "Selected: $(echo "$ANDROID_MATRIX" | jq length) Android, $(echo "$IOS_MATRIX" | jq length) iOS" + echo "Selected for Android MM-Connect: $(echo "$ANDROID_MM_CONNECT_MATRIX" | jq length)" set-build-names: name: Set Unified BrowserStack Build Names @@ -333,7 +339,7 @@ jobs: name: Fetch RN Playground APK and Upload to BrowserStack runs-on: ubuntu-latest needs: [wait-for-onboarding-completion] - if: always() && !cancelled() + if: always() && !cancelled() && (inputs.build_variant || 'rc') == 'rc' outputs: browserstack-playground-url: ${{ steps.upload-playground.outputs.browserstack-url }} steps: @@ -376,13 +382,13 @@ jobs: set-build-names, determine-branch-name, ] - if: always() && !cancelled() && (needs.trigger-android-dual-versions.result == 'skipped' || needs.trigger-android-dual-versions.result == 'success') && (inputs.browserstack_app_url_android_imported_wallet != '' || needs.trigger-android-dual-versions.outputs.with-srp-browserstack-url != '') + if: always() && !cancelled() && (inputs.build_variant || 'rc') == 'rc' && (needs.trigger-android-dual-versions.result == 'skipped' || needs.trigger-android-dual-versions.result == 'success') && (inputs.browserstack_app_url_android_imported_wallet != '' || needs.trigger-android-dual-versions.outputs.with-srp-browserstack-url != '') with: platform: android build_type: mm-connect sentry_target: ${{ inputs.sentry_target || 'test' }} build_variant: ${{ inputs.build_variant || 'rc' }} - device_matrix: ${{ needs.read-device-matrix.outputs.android_matrix }} + device_matrix: ${{ needs.read-device-matrix.outputs.android_mm_connect_matrix }} browserstack_app_url: ${{ needs.trigger-android-dual-versions.outputs.with-srp-browserstack-url || inputs.browserstack_app_url_android_imported_wallet }} app_version: ${{ needs.trigger-android-dual-versions.outputs.with-srp-version || 'Manual-Input' }} branch_name: ${{ needs.determine-branch-name.outputs.branch_name }} From dba2c63ebff990a2410b983c5d2c705154850968 Mon Sep 17 00:00:00 2001 From: saustrie-consensys Date: Wed, 6 May 2026 19:51:04 +0300 Subject: [PATCH 24/27] feat(ramp): surface headless buy errors as data (Phase 7) (#29612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR closes **Phase 7** of the incremental **Unified Buy (v2) headless buy** plan (`app/components/UI/Ramp/headless/PLAN.md`): headless consumers now receive structured `HeadlessBuyError` data for hard failures instead of depending on Ramp UI surfaces like banners, ErrorViews, or order toasts. **Reason** - Phase 6 ([#29340](https://github.com/MetaMask/metamask-mobile/pull/29340)) fired `onOrderCreated` and bypassed the order-details redirect on success, but several failure paths were still UI-coupled. Limit failures could be wrapped into generic display errors, Checkout/WebView failures rendered local UI, and one Transak success path could still show a toast before a headless consumer regained control. **What changed** - **`HeadlessBuyErrorCode` + `failSession`** — centralizes error normalization in `sessionRegistry`, preserving explicit error codes/details and closing the session with failed terminal semantics after `onError` fires. - **`HeadlessHost`** — uses `failSession` for auth errors, malformed asset ids, and `continueWithQuote` rejections so the consumer receives one structured `onError` callback and one terminal close. - **`useTransakRouting`** — preserves `LimitExceededError` as `LIMIT_EXCEEDED`, forwards checkout-processing failures through `onError`, and suppresses the manual-bank-transfer toast path when a live headless session owns the flow. - **`Checkout`** — routes callback-processing failures and primary WebView HTTP errors through `onError` for headless sessions, then unwinds the ramp stack instead of rendering the checkout ErrorView. - **`BuildQuote`** — keeps legacy headless params from falling back to banner-only error handling if they are encountered. - **`PLAN.md`** — marks Phase 7 complete. **References** - **Stacked on Phase 6**: [#29340](https://github.com/MetaMask/metamask-mobile/pull/29340) (`poc/headless-buy-phase-6`). **This PR's base is `poc/headless-buy-phase-6`** so the diff is Phase 7-only. - Continues from **Phase 5**: [#29338](https://github.com/MetaMask/metamask-mobile/pull/29338) (Headless Host + quote-first start). **Tests** - `yarn eslint app/components/UI/Ramp/headless/types.ts app/components/UI/Ramp/headless/sessionRegistry.ts app/components/UI/Ramp/headless/sessionRegistry.test.ts app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx app/components/UI/Ramp/hooks/useTransakRouting.ts app/components/UI/Ramp/hooks/useTransakRouting.test.ts app/components/UI/Ramp/Views/Checkout/Checkout.tsx app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx` - `yarn jest --watchman=false app/components/UI/Ramp/headless/sessionRegistry.test.ts app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx app/components/UI/Ramp/hooks/useTransakRouting.test.ts app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx` - `yarn lint:tsc` was attempted, but the local run is blocked by unrelated existing type errors in SocialLeaderboard tests and controller messenger types. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: _No GitHub issue — incremental POC on branch `saustrie-consensys/headless-buy-phase-7`._ Continuity: [#29340](https://github.com/MetaMask/metamask-mobile/pull/29340) (Phase 6 — headless order success callback + stack unwind). [#29338](https://github.com/MetaMask/metamask-mobile/pull/29338) (Phase 5 — Headless Host + quote-first start). ## **Manual testing steps** ```gherkin Feature: Headless Buy Phase 7 (structured errors) Scenario: Native Transak limit failure surfaces as data Given the app is an internal build and I am signed in And I open Settings → Fiat on-ramp → Headless Buy playground When I start a headless native quote that exceeds the user's Transak limit Then the playground event log should show `onError({ code: "LIMIT_EXCEEDED" })` And the session should close without showing a Ramp-only toast or order-details redirect Scenario: Aggregator Checkout failure surfaces as data Given I start a headless aggregator quote from the playground When the Checkout callback or primary WebView request fails Then the consumer should receive `onError({ code: "UNKNOWN", message })` And the app should unwind out of the Ramp stack instead of rendering the Checkout ErrorView Scenario: Non-headless Buy flow is unchanged Given I open Wallet → Buy through the regular flow When a quote, checkout, or limit error occurs Then the existing Ramp UI surfaces should render as before ``` ## **Screenshots/Recordings** ### **Before** N/A — Phase 7 changes error/callback plumbing only. ### **After** N/A — no user-facing UI changes, but here's a video anyways. https://github.com/user-attachments/assets/2ce3a5a7-7205-4490-9174-8be7672ae464 ## **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 - [ ] I've applied the right labels on the PR #### Performance checks (if applicable) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes error handling and navigation unwinding for headless Unified Buy flows across `HeadlessHost`, `Checkout`, and `useTransakRouting`, which could affect session lifecycle and user recovery paths if misrouted. Scope is contained to headless-mode branches with added test coverage, but touches core buy/checkout flow control. > > **Overview** > **Headless buy errors are now surfaced as structured data instead of Ramp UI.** A new `failSession` helper in `headless/sessionRegistry` normalizes thrown/native errors into `HeadlessBuyError` (including `LIMIT_EXCEEDED` mapping and optional `details`), fires `onError`, and closes the session with failed terminal semantics. > > Headless flows now consistently use this failure path: `HeadlessHost` forwards auth/asset/continue failures via `failSession`, `Checkout` sends callback-processing and primary WebView HTTP errors through `onError` and pops the ramp stack instead of rendering an ErrorView, and `useTransakRouting` preserves `LimitExceededError` details, suppresses toasts when a live headless session is present, and routes post-checkout processing failures through `failSession` + stack unwind. Tests are updated/added to cover these headless-specific error paths and regression guards. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7bdaa032caf8ece41f9dc1a1f6ea52be439da1d9. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: saustrie-consensys <270766059+saustrie-consensys@users.noreply.github.com> --- .../UI/Ramp/Views/BuildQuote/BuildQuote.tsx | 21 +++- .../UI/Ramp/Views/Checkout/Checkout.test.tsx | 50 ++++++++ .../UI/Ramp/Views/Checkout/Checkout.tsx | 25 +++- .../Views/HeadlessHost/HeadlessHost.test.tsx | 19 +++ .../Ramp/Views/HeadlessHost/HeadlessHost.tsx | 59 ++------- app/components/UI/Ramp/headless/PLAN.md | 2 +- .../UI/Ramp/headless/sessionRegistry.test.ts | 75 ++++++++++++ .../UI/Ramp/headless/sessionRegistry.ts | 105 ++++++++++++++++ app/components/UI/Ramp/headless/types.ts | 18 +-- .../UI/Ramp/hooks/useTransakRouting.test.ts | 112 +++++++++++++++++- .../UI/Ramp/hooks/useTransakRouting.ts | 47 +++++++- 11 files changed, 469 insertions(+), 64 deletions(-) diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index 4e6887cdcf7..437caae587b 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -69,6 +69,7 @@ import { import TruncatedError from '../../components/TruncatedError'; import { PROVIDER_LINKS } from '../../Aggregator/types'; +import { failSession } from '../../headless/sessionRegistry'; const BAILED_ORDER_STATUSES = new Set([ RampsOrderStatus.Precreated, RampsOrderStatus.IdExpired, @@ -159,10 +160,24 @@ function BuildQuote() { useEffect(() => { if (params?.nativeFlowError) { + if ( + params.headlessSessionId && + failSession( + params.headlessSessionId, + { + code: 'AUTH_FAILED', + message: params.nativeFlowError, + }, + 'AUTH_FAILED', + ) + ) { + navigation.setParams({ nativeFlowError: undefined }); + return; + } setRampsError(params.nativeFlowError); navigation.setParams({ nativeFlowError: undefined }); } - }, [params?.nativeFlowError, navigation]); + }, [params?.headlessSessionId, params?.nativeFlowError, navigation]); const { userRegion, @@ -627,6 +642,9 @@ function BuildQuote() { assetId: selectedToken?.assetId ?? '', }); } catch (err) { + if (failSession(params?.headlessSessionId, err)) { + return; + } setRampsError((err as Error).message); } finally { setIsContinueLoading(false); @@ -642,6 +660,7 @@ function BuildQuote() { selectedPaymentMethod?.id, rampRoutingDecision, userRegion?.regionCode, + params?.headlessSessionId, trackEvent, createEventBuilder, continueWithQuote, diff --git a/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx b/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx index b1408bb9a46..8d4f519e825 100644 --- a/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx +++ b/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx @@ -58,6 +58,7 @@ jest.mock('../../utils/v2OrderToast', () => ({ jest.mock('../../headless/sessionRegistry', () => ({ getSession: jest.fn(), closeSession: jest.fn(), + failSession: jest.fn(), })); jest.mock('../../../../../util/Logger', () => ({ @@ -618,6 +619,8 @@ describe('Checkout', () => { .getSession as jest.Mock; const mockCloseSession = jest.requireMock('../../headless/sessionRegistry') .closeSession as jest.Mock; + const mockFailSession = jest.requireMock('../../headless/sessionRegistry') + .failSession as jest.Mock; const showV2OrderToastMock = jest.requireMock('../../utils/v2OrderToast') .showV2OrderToast as jest.Mock; @@ -641,6 +644,7 @@ describe('Checkout', () => { beforeEach(() => { mockGetSession.mockReset(); mockCloseSession.mockReset(); + mockFailSession.mockReset(); mockParentPop = jest.fn(); mockNavigation.getParent.mockReturnValue({ pop: mockParentPop }); mockGetOrderFromCallback.mockResolvedValue(mockOrder); @@ -740,6 +744,52 @@ describe('Checkout', () => { expect(mockParentPop).toHaveBeenCalled(); }); + it('surfaces callback processing failures through onError and skips the ErrorView', async () => { + mockUseParams.mockReturnValue(callbackFlowParams); + mockGetOrderFromCallback.mockRejectedValueOnce( + new Error('callback failed'), + ); + mockFailSession.mockReturnValue({ + code: 'UNKNOWN', + message: 'callback failed', + }); + + const { getByTestId, queryByText } = renderWithProvider( + , + {}, + true, + false, + ); + + await act(async () => { + fireEvent.press(getByTestId('trigger-callback-navigation')); + }); + + await waitFor(() => { + expect(mockFailSession).toHaveBeenCalledWith('hs-1', expect.any(Error)); + }); + expect(mockParentPop).toHaveBeenCalled(); + expect(showV2OrderToastMock).not.toHaveBeenCalled(); + expect(queryByText('callback failed')).toBeNull(); + }); + + it('surfaces provider WebView HTTP errors through onError when headless', async () => { + mockUseParams.mockReturnValue(callbackFlowParams); + mockFailSession.mockReturnValue({ + code: 'UNKNOWN', + message: 'fiat_on_ramp_aggregator.webview_received_error', + }); + + const { getByTestId } = renderWithProvider(, {}, true, false); + + await act(async () => { + fireEvent.press(getByTestId('trigger-http-error-main-uri')); + }); + + expect(mockFailSession).toHaveBeenCalledWith('hs-1', expect.any(Error)); + expect(mockParentPop).toHaveBeenCalled(); + }); + it('falls back to the regular reset + toast when session id is present but session is missing from registry', async () => { mockGetSession.mockReturnValue(undefined); mockUseParams.mockReturnValue(callbackFlowParams); diff --git a/app/components/UI/Ramp/Views/Checkout/Checkout.tsx b/app/components/UI/Ramp/Views/Checkout/Checkout.tsx index a11923bc9b6..0dcc8437aa3 100644 --- a/app/components/UI/Ramp/Views/Checkout/Checkout.tsx +++ b/app/components/UI/Ramp/Views/Checkout/Checkout.tsx @@ -27,7 +27,11 @@ import { import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import useRampsUnifiedV2Enabled from '../../hooks/useRampsUnifiedV2Enabled'; import { showV2OrderToast } from '../../utils/v2OrderToast'; -import { closeSession, getSession } from '../../headless/sessionRegistry'; +import { + closeSession, + failSession, + getSession, +} from '../../headless/sessionRegistry'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './Checkout.styles'; import Device from '../../../../../util/device'; @@ -120,6 +124,18 @@ const Checkout = () => { } }, [uri, createEventBuilder, trackEvent, rampRoutingDecision]); + const failHeadlessCheckout = useCallback( + (checkoutError: unknown) => { + if (!failSession(headlessSessionId, checkoutError)) { + return false; + } + // @ts-expect-error navigation prop mismatch + navigation.getParent()?.pop(); + return true; + }, + [headlessSessionId, navigation], + ); + useEffect(() => { // For external-browser flows (e.g. PayPal), addPrecreatedOrder is called in // BuildQuote; the user never reaches Checkout. For WebView flows, @@ -234,6 +250,9 @@ const Checkout = () => { Logger.error(navError as Error, { message: 'UnifiedCheckout: error handling callback', }); + if (failHeadlessCheckout(navError)) { + return; + } setError((navError as Error)?.message); } }, @@ -248,6 +267,7 @@ const Checkout = () => { isV2Enabled, params?.cryptocurrency, headlessSessionId, + failHeadlessCheckout, ], ); @@ -344,6 +364,9 @@ const Checkout = () => { 'fiat_on_ramp_aggregator.webview_received_error', { code: nativeEvent.statusCode }, ); + if (failHeadlessCheckout(new Error(webviewHttpError))) { + return; + } setError(webviewHttpError); } else { Logger.log( diff --git a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx index 898358b451f..67d2031d98a 100644 --- a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx +++ b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx @@ -348,6 +348,25 @@ describe('HeadlessHost', () => { expect(screen.getByText('quote expired')).toBeOnTheScreen(); }); + it('surfaces limit failures as onError(LIMIT_EXCEEDED, ...)', async () => { + const limitError = new Error('Daily limit exceeded'); + limitError.name = 'LimitExceededError'; + mockContinueWithQuote.mockRejectedValueOnce(limitError); + const quote = buildNativeQuote(); + const session = seedSession(quote); + const callbacks = session.callbacks; + renderHost({ headlessSessionId: session.id }); + await waitFor(() => { + expect(callbacks.onError).toHaveBeenCalledWith({ + code: 'LIMIT_EXCEEDED', + message: 'Daily limit exceeded', + }); + }); + expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' }); + expect(getSession(session.id)).toBeUndefined(); + expect(screen.getByText('Daily limit exceeded')).toBeOnTheScreen(); + }); + it('does not run the continueWithQuote rejection path after unmount', async () => { let rejectDeferred: ((error: Error) => void) | undefined; mockContinueWithQuote.mockImplementation( diff --git a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx index 313c1da6685..22a843f8170 100644 --- a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx +++ b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx @@ -27,7 +27,7 @@ import Logger from '../../../../../util/Logger'; // Going through the barrel would leave the registry exports `undefined` // at evaluation time inside this module. import { - closeSession, + failSession, getSession, setStatus, } from '../../headless/sessionRegistry'; @@ -132,26 +132,17 @@ function HeadlessHost() { if (!nativeFlowError) { return; } - const liveSession = getSession(headlessSessionId); - if (!liveSession) { - return; - } - setErrorMessage(nativeFlowError); - try { - liveSession.callbacks.onError({ - code: 'AUTH_FAILED', - message: nativeFlowError, - }); - } catch (e) { - Logger.error(e as Error, 'HeadlessHost: onError callback threw'); - } - closeSession( + const headlessError = failSession( headlessSessionId, - { reason: 'unknown' }, { - terminalStatus: 'failed', + code: 'AUTH_FAILED', + message: nativeFlowError, }, + 'AUTH_FAILED', ); + if (headlessError) { + setErrorMessage(headlessError.message ?? nativeFlowError); + } }, [nativeFlowError, headlessSessionId]); // Process the session. Uses `useEffect` (not `useFocusEffect`) so that @@ -202,25 +193,11 @@ function HeadlessHost() { if (!chainId) { const message = `HeadlessHost: invalid assetId "${currentSession.params.assetId}"`; Logger.error(new Error(message)); - try { - currentSession.callbacks.onError({ - code: 'UNKNOWN', - message, - }); - } catch (e) { - Logger.error(e as Error, 'HeadlessHost: onError callback threw'); - } // closeSession alone does not trigger a re-render; without setState the // render-time `session` ref stays truthy and the loader would spin // forever. Surface the same message in UI as other error paths. setErrorMessage(message); - closeSession( - headlessSessionId, - { reason: 'unknown' }, - { - terminalStatus: 'failed', - }, - ); + failSession(headlessSessionId, { code: 'UNKNOWN', message }); return; } // Defer until walletAddress resolves — avoids calling continueWithQuote @@ -270,22 +247,8 @@ function HeadlessHost() { if (!liveSession) { return; } - setErrorMessage(message); - try { - liveSession.callbacks.onError({ - code: 'UNKNOWN', - message, - }); - } catch (e) { - Logger.error(e as Error, 'HeadlessHost: onError callback threw'); - } - closeSession( - headlessSessionId, - { reason: 'unknown' }, - { - terminalStatus: 'failed', - }, - ); + const headlessError = failSession(headlessSessionId, error); + setErrorMessage(headlessError?.message ?? message); }); return () => { cancelled = true; diff --git a/app/components/UI/Ramp/headless/PLAN.md b/app/components/UI/Ramp/headless/PLAN.md index 61a1ac708a8..b85630e161b 100644 --- a/app/components/UI/Ramp/headless/PLAN.md +++ b/app/components/UI/Ramp/headless/PLAN.md @@ -14,7 +14,7 @@ - [x] **Phase 5 (revised)** — Quote-first headless start path — `startHeadlessBuy({ quote, redirectUrl? })` creates a session carrying the quote, navigates to Headless Host, Host calls `continueWithQuote(quote, ctx)` and re-orchestrates after auth loops - [ ] **Phase 5b (deferred)** — `startHeadlessBuy({ assetId, amount, paymentMethodId, providerId? })` "open BuildQuote / Host fetches quotes" mode — picked up after the quote-first path is stable - [x] **Phase 6** — Bypass order-processing redirect in Transak/aggregator routing when headless; fire `onOrderCreated` and end session -- [ ] **Phase 7** — Extract UI-coupled error/limit surfacing; route errors through `onError` as typed `HeadlessBuyError` +- [x] **Phase 7** — Extract UI-coupled error/limit surfacing; route errors through `onError` as typed `HeadlessBuyError` - [ ] **Phase 8** — Cancellation + `onClose` semantics (including user-dismissed detection) - [ ] **Phase 9** — Expose `getOrder` / `refreshOrder` from hook and show in playground - [ ] **Phase 10** — Playground polish — event log, input persistence, aggregator/native presets diff --git a/app/components/UI/Ramp/headless/sessionRegistry.test.ts b/app/components/UI/Ramp/headless/sessionRegistry.test.ts index 78222812c41..74b36ea39cb 100644 --- a/app/components/UI/Ramp/headless/sessionRegistry.test.ts +++ b/app/components/UI/Ramp/headless/sessionRegistry.test.ts @@ -3,9 +3,11 @@ import { closeSession, createSession, endSession, + failSession, getActiveSessionId, getSession, setStatus, + toHeadlessBuyError, } from './sessionRegistry'; import type { HeadlessBuyCallbacks, HeadlessBuyParams } from './types'; import type { Quote } from '../types'; @@ -185,4 +187,77 @@ describe('sessionRegistry', () => { expect(getActiveSessionId()).toBeUndefined(); }); }); + + describe('toHeadlessBuyError', () => { + it('preserves explicit headless error codes and details', () => { + expect( + toHeadlessBuyError({ + code: 'LIMIT_EXCEEDED', + message: 'Daily limit exceeded', + details: { period: 'daily' }, + }), + ).toEqual({ + code: 'LIMIT_EXCEEDED', + message: 'Daily limit exceeded', + details: { period: 'daily' }, + }); + }); + + it('maps LimitExceededError instances to LIMIT_EXCEEDED', () => { + const error = new Error('Daily limit exceeded'); + error.name = 'LimitExceededError'; + expect(toHeadlessBuyError(error)).toEqual({ + code: 'LIMIT_EXCEEDED', + message: 'Daily limit exceeded', + }); + }); + + it('falls back to UNKNOWN for regular errors', () => { + expect(toHeadlessBuyError(new Error('provider failed'))).toEqual({ + code: 'UNKNOWN', + message: 'provider failed', + }); + }); + }); + + describe('failSession', () => { + it('fires onError then onClose and removes the session', () => { + const callbacks = buildCallbacks(); + const session = createSession(baseParams, callbacks); + + const headlessError = failSession(session.id, { + code: 'QUOTE_FAILED', + message: 'Quote expired', + }); + + expect(headlessError).toEqual({ + code: 'QUOTE_FAILED', + message: 'Quote expired', + details: undefined, + }); + expect(callbacks.onError).toHaveBeenCalledWith(headlessError); + expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' }); + expect(session.status).toBe('failed'); + expect(getSession(session.id)).toBeUndefined(); + }); + + it('logs onError failures but still closes the session', () => { + const callbacks = { + ...buildCallbacks(), + onError: jest.fn(() => { + throw new Error('consumer onError boom'); + }), + }; + const session = createSession(baseParams, callbacks); + + failSession(session.id, new Error('provider failed')); + + expect(mockLoggerError).toHaveBeenCalledWith( + expect.any(Error), + 'headless sessionRegistry: onError callback threw', + ); + expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' }); + expect(getSession(session.id)).toBeUndefined(); + }); + }); }); diff --git a/app/components/UI/Ramp/headless/sessionRegistry.ts b/app/components/UI/Ramp/headless/sessionRegistry.ts index 2f7f354b79c..586e303337f 100644 --- a/app/components/UI/Ramp/headless/sessionRegistry.ts +++ b/app/components/UI/Ramp/headless/sessionRegistry.ts @@ -3,17 +3,86 @@ import type { CloseSessionOptions, HeadlessBuyCallbacks, HeadlessBuyCloseInfo, + HeadlessBuyError, + HeadlessBuyErrorCode, HeadlessBuyParams, HeadlessSession, HeadlessSessionStatus, } from './types'; +const HEADLESS_BUY_ERROR_CODES: ReadonlySet = new Set([ + 'NO_QUOTES', + 'LIMIT_EXCEEDED', + 'KYC_REQUIRED', + 'AUTH_FAILED', + 'QUOTE_FAILED', + 'USER_CANCELLED', + 'UNKNOWN', +]); + function isTerminalSessionStatus(status: HeadlessSessionStatus): boolean { return ( status === 'completed' || status === 'cancelled' || status === 'failed' ); } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isHeadlessBuyErrorCode(value: unknown): value is HeadlessBuyErrorCode { + return ( + typeof value === 'string' && + HEADLESS_BUY_ERROR_CODES.has(value as HeadlessBuyErrorCode) + ); +} + +function getErrorMessage(error: unknown): string | undefined { + if (error instanceof Error) { + return error.message; + } + if (isRecord(error) && typeof error.message === 'string') { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return undefined; +} + +export function toHeadlessBuyError( + error: unknown, + fallbackCode: HeadlessBuyErrorCode = 'UNKNOWN', +): HeadlessBuyError { + if (isRecord(error)) { + const explicitCode = isHeadlessBuyErrorCode(error.headlessBuyErrorCode) + ? error.headlessBuyErrorCode + : isHeadlessBuyErrorCode(error.code) + ? error.code + : undefined; + + if (explicitCode) { + return { + code: explicitCode, + message: getErrorMessage(error), + details: isRecord(error.details) ? error.details : undefined, + }; + } + } + + if (error instanceof Error && error.name === 'LimitExceededError') { + return { + code: 'LIMIT_EXCEEDED', + message: error.message, + }; + } + + return { + code: fallbackCode, + message: getErrorMessage(error), + }; +} + /** * Module-level registry that holds the live headless buy sessions. Sessions * carry non-serializable callbacks and therefore cannot live in Redux nor in @@ -158,6 +227,42 @@ export function closeSession( } } +/** + * Idempotent "fail and notify" for unrecoverable headless errors. It turns + * thrown/native errors into the public HeadlessBuyError shape, fires `onError`, + * then terminates the session through `closeSession`. + */ +export function failSession( + id: string | undefined, + error: unknown, + fallbackCode: HeadlessBuyErrorCode = 'UNKNOWN', +): HeadlessBuyError | undefined { + if (!id) { + return undefined; + } + const session = sessions.get(id); + if (!session) { + return undefined; + } + const headlessError = toHeadlessBuyError(error, fallbackCode); + try { + session.callbacks.onError(headlessError); + } catch (e) { + Logger.error( + e instanceof Error ? e : new Error(String(e)), + 'headless sessionRegistry: onError callback threw', + ); + } + closeSession( + id, + { reason: 'unknown' }, + { + terminalStatus: 'failed', + }, + ); + return headlessError; +} + /** * Test-only helper. Resets registry state between tests so they do not leak * sessions into one another. diff --git a/app/components/UI/Ramp/headless/types.ts b/app/components/UI/Ramp/headless/types.ts index 4cd3a276dcb..c2eec400413 100644 --- a/app/components/UI/Ramp/headless/types.ts +++ b/app/components/UI/Ramp/headless/types.ts @@ -149,15 +149,17 @@ export interface HeadlessBuyCallbacks { * the UI normally renders. Phase 3 only uses `UNKNOWN`; later phases route * limit/auth/etc. errors through it. */ +export type HeadlessBuyErrorCode = + | 'NO_QUOTES' + | 'LIMIT_EXCEEDED' + | 'KYC_REQUIRED' + | 'AUTH_FAILED' + | 'QUOTE_FAILED' + | 'USER_CANCELLED' + | 'UNKNOWN'; + export interface HeadlessBuyError { - code: - | 'NO_QUOTES' - | 'LIMIT_EXCEEDED' - | 'KYC_REQUIRED' - | 'AUTH_FAILED' - | 'QUOTE_FAILED' - | 'USER_CANCELLED' - | 'UNKNOWN'; + code: HeadlessBuyErrorCode; message?: string; details?: Record; } diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts index 65afcb54df3..d766852dce9 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts @@ -19,6 +19,7 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('../headless/sessionRegistry', () => ({ getSession: jest.fn(), closeSession: jest.fn(), + failSession: jest.fn(), })); const MOCK_WALLET_ADDRESS = '0xabcdef1234567890'; @@ -1047,11 +1048,11 @@ describe('useTransakRouting', () => { describe('navigateToKycWebview', () => { it('resets navigation stack with KycProcessing behind the webview', () => { const { result } = renderHook(() => useTransakRouting()); - const mockQuote = { id: 'quote-789' } as unknown as TransakBuyQuote; + const kycQuote = { id: 'quote-789' } as unknown as TransakBuyQuote; act(() => { result.current.navigateToKycWebview({ - quote: mockQuote, + quote: kycQuote, kycUrl: 'https://kyc.example.com', workFlowRunId: 'wf-456', amount: 30, @@ -1075,7 +1076,7 @@ describe('useTransakRouting', () => { url: 'https://kyc.example.com', providerName: 'Transak', workFlowRunId: 'wf-456', - quote: mockQuote, + quote: kycQuote, amount: 30, }), }), @@ -1389,6 +1390,8 @@ describe('useTransakRouting', () => { .getSession as jest.Mock; const mockCloseSession = jest.requireMock('../headless/sessionRegistry') .closeSession as jest.Mock; + const mockFailSession = jest.requireMock('../headless/sessionRegistry') + .failSession as jest.Mock; const mockShowV2OrderToast = jest.requireMock('../utils/v2OrderToast') .showV2OrderToast as jest.Mock; @@ -1455,6 +1458,7 @@ describe('useTransakRouting', () => { beforeEach(() => { mockGetSession.mockReset(); mockCloseSession.mockReset(); + mockFailSession.mockReset(); mockParentPop.mockReset(); mockGetOrder.mockResolvedValue(depositOrder); mockRefreshOrder.mockResolvedValue(refreshedOrder); @@ -1562,6 +1566,108 @@ describe('useTransakRouting', () => { expect(mockParentPop).toHaveBeenCalled(); }); + it('preserves LIMIT_EXCEEDED errors for the Headless Host to surface as data', async () => { + mockGetUserDetails.mockResolvedValue({ + firstName: 'John', + address: {}, + }); + mockGetKycRequirement.mockResolvedValue({ + status: 'APPROVED', + kycType: 'SIMPLE', + }); + mockGetUserLimits.mockResolvedValue({ + remaining: { '1': 50, '30': 50000, '365': 200000 }, + }); + + const { result } = renderHook(() => useTransakRouting(HEADLESS_CONFIG)); + + await expect( + act(async () => { + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); + }), + ).rejects.toMatchObject({ + name: 'LimitExceededError', + headlessBuyErrorCode: 'LIMIT_EXCEEDED', + }); + + expect(mockShowV2OrderToast).not.toHaveBeenCalled(); + expect(mockFailSession).not.toHaveBeenCalled(); + }); + + it('surfaces headless checkout processing failures through onError and skips toasts', async () => { + const handler = await runApprovedFlowHeadless(); + expect(handler).not.toBeNull(); + if (!handler) return; + + mockGetOrder.mockRejectedValue(new Error('Network error')); + mockFailSession.mockReturnValue({ + code: 'UNKNOWN', + message: 'Network error', + }); + + await act(async () => { + await handler({ + url: 'https://redirect.example.com?orderId=order-hs', + }); + }); + + expect(mockFailSession).toHaveBeenCalledWith('hs-1', expect.any(Error)); + expect(mockShowV2OrderToast).not.toHaveBeenCalled(); + expect(mockParentPop).toHaveBeenCalled(); + }); + + it('routes manual bank transfer order success through headless callbacks without showing a toast', async () => { + const onOrderCreated = jest.fn(); + mockGetSession.mockReturnValue({ + id: 'hs-1', + status: 'continued', + callbacks: { + onOrderCreated, + onError: jest.fn(), + onClose: jest.fn(), + }, + }); + mockSelectedPaymentMethod = { + id: '/payments/bank-transfer', + isManualBankTransfer: true, + }; + mockGetUserDetails.mockResolvedValue({ + firstName: 'John', + lastName: 'Doe', + mobileNumber: '+1', + dob: '1990-01-01', + address: {}, + }); + mockGetKycRequirement.mockResolvedValue({ + status: 'APPROVED', + kycType: 'SIMPLE', + }); + mockGetUserLimits.mockResolvedValue({ + remaining: { '1': 10000, '30': 50000, '365': 200000 }, + }); + mockTransakCreateOrder.mockResolvedValue(depositOrder); + mockRefreshOrder.mockResolvedValue(refreshedOrder); + + const { result } = renderHook(() => useTransakRouting(HEADLESS_CONFIG)); + + await act(async () => { + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); + }); + + expect(onOrderCreated).toHaveBeenCalledWith('order-hs'); + expect(mockCloseSession).toHaveBeenCalledWith('hs-1', { + reason: 'completed', + }); + expect(mockShowV2OrderToast).not.toHaveBeenCalled(); + expect(mockParentPop).toHaveBeenCalled(); + }); + it('falls back to the regular order-details reset + toast when session id is present but session is missing from registry', async () => { mockGetSession.mockReturnValue(undefined); diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.ts b/app/components/UI/Ramp/hooks/useTransakRouting.ts index c15fc3afa7e..5319a7e41f8 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.ts @@ -28,7 +28,11 @@ import useRampAccountAddress from './useRampAccountAddress'; import { isHttpUnauthorized } from '../utils/isHttpUnauthorized'; import { parseUserFacingError } from '../utils/parseUserFacingError'; import { useRampsOrders } from './useRampsOrders'; -import { closeSession, getSession } from '../headless/sessionRegistry'; +import { + closeSession, + failSession, + getSession, +} from '../headless/sessionRegistry'; interface RampStackParamList { /** `baseRouteParams` (e.g. `headlessSessionId`) are merged onto this route in resets — see `navigateToVerifyIdentityCallback`. */ @@ -69,9 +73,14 @@ interface RampStackParamList { } class LimitExceededError extends Error { - constructor(message: string) { + readonly headlessBuyErrorCode = 'LIMIT_EXCEEDED'; + + readonly details?: Record; + + constructor(message: string, details?: Record) { super(message); this.name = 'LimitExceededError'; + this.details = details; } } @@ -194,6 +203,11 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { period: 'daily', remaining: `${dailyLimit} ${fiatCurrency}`, }), + { + period: 'daily', + remaining: dailyLimit, + currency: fiatCurrency, + }, ); } @@ -203,6 +217,11 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { period: 'monthly', remaining: `${monthlyLimit} ${fiatCurrency}`, }), + { + period: 'monthly', + remaining: monthlyLimit, + currency: fiatCurrency, + }, ); } @@ -212,6 +231,11 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { period: 'yearly', remaining: `${yearlyLimit} ${fiatCurrency}`, }), + { + period: 'yearly', + remaining: yearlyLimit, + currency: fiatCurrency, + }, ); } } catch (error) { @@ -435,6 +459,12 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { Logger.error(error as Error, { message: 'useTransakRouting: Failed to process order after checkout', }); + if (failSession(headlessSessionId, error)) { + // @ts-expect-error `pop` exists on the parent stack navigator at + // runtime but is not surfaced on the generic `NavigationProp` + // type returned by `getParent()`. + navigation.getParent()?.pop(); + } } }, [ @@ -446,6 +476,7 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { regionIsoCode, trackEvent, headlessSessionId, + navigation, ], ); @@ -564,6 +595,13 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { paymentDetails: depositOrder.paymentDetails, }); + if (getSession(headlessSessionId)) { + navigateToOrderProcessingCallback({ + orderId: rampsOrder.providerOrderId, + }); + return true; + } + showV2OrderToast({ orderId: rampsOrder.providerOrderId, cryptocurrency: rampsOrder.cryptoCurrency?.symbol ?? '', @@ -600,6 +638,9 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { } return true; } catch (error) { + if (error instanceof LimitExceededError) { + throw error; + } throw new Error( parseUserFacingError( error, @@ -723,6 +764,8 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { addOrder, refreshOrder, navigateToBankDetailsCallback, + navigateToOrderProcessingCallback, + headlessSessionId, navigateToWebviewModalCallback, navigateToKycProcessingCallback, submitPurposeOfUsageForm, From 584d490c8550d8c5f372b7f003d830264b771941 Mon Sep 17 00:00:00 2001 From: Alexey Kureev Date: Wed, 6 May 2026 19:24:35 +0200 Subject: [PATCH 25/27] feat(MUSD-747): peek-and-hide Add money footer when stepper is visible (#29736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Implements MUSD-747: peek-and-hide behaviour for the Money Home "Add money" footer button so it doesn't visually clash with the onboarding stepper card's primary CTA while the stepper is in the viewport. - The footer is always mounted as an absolutely-positioned overlay; visibility is driven by a Reanimated `translateY` shared value (`FOOTER_HIDDEN_OFFSET` off-screen by default). - When the user scrolls past the onboarding stepper's bottom edge, the footer slides in (`withTiming(0, 300ms)`, `Easing.out(cubic)`). - When the user scrolls back so the stepper re-enters the viewport, the footer slides out (`withTiming(FOOTER_HIDDEN_OFFSET, 300ms)`, `Easing.in(cubic)`). - The stepper itself is rendered unconditionally — its render condition is upstream and this PR does not change it. - ScrollView bottom padding is driven by the measured footer height so the footer never visually covers reachable content. - Visibility is tracked in a ref (`isStepperVisibleRef`); scroll events do not trigger React re-renders, only crossing the threshold flips the ref and runs the animation. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: MUSD-747 ## **Manual testing steps** ```gherkin Feature: Add money footer peek-and-hide Scenario: stepper is fully in the viewport Given the user is on the Money Home view And the onboarding stepper card is in the viewport Then the "Add money" footer is hidden (translated off-screen) Scenario: user scrolls past the stepper Given the user is on the Money Home view When the user scrolls so the stepper card's bottom edge is above the viewport Then the "Add money" footer slides up into view Scenario: user scrolls the stepper back into view Given the "Add money" footer is visible after scrolling past the stepper When the user scrolls back up so the stepper re-enters the viewport Then the "Add money" footer slides out Scenario: stepper layout has not yet measured (initial paint) Given the user opens the Money Home view for the first time When the stepper's onLayout has not yet reported a non-zero height Then the "Add money" footer remains hidden, avoiding a flash of "Add money" ``` ## **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 - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Moderate UI/UX change to `MoneyHomeView` scroll/layout behavior using Reanimated and layout measurements; risk is mainly regressions in footer visibility/overlap across devices and safe-area/scroll edge cases. > > **Overview** > **Adds a peek-and-hide animated "Add money" footer on `MoneyHomeView`.** The footer is now an absolutely-positioned overlay that slides in/out via Reanimated `translateY` based on whether the onboarding stepper is considered visible. > > Introduces `computeStepperVisibility` to decide when the user has scrolled past the stepper bottom, tracks scroll/measurements via refs (to avoid per-frame re-renders), and dynamically pads the `ScrollView` bottom using the measured footer height so content isn’t covered. Updates/extends tests to cover the new navigation cases and the footer visibility/measurement/scroll handlers, and adds unit tests for the visibility computation. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6f62266c8a4ad683190c6acb7e994afdd697f596. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../MoneyHomeView/MoneyHomeView.styles.ts | 7 +- .../MoneyHomeView/MoneyHomeView.test.tsx | 312 +++++++++++++++++- .../Views/MoneyHomeView/MoneyHomeView.tsx | 137 +++++++- .../utils/computeStepperVisibility.test.ts | 65 ++++ .../utils/computeStepperVisibility.ts | 36 ++ 5 files changed, 545 insertions(+), 12 deletions(-) create mode 100644 app/components/UI/Money/Views/MoneyHomeView/utils/computeStepperVisibility.test.ts create mode 100644 app/components/UI/Money/Views/MoneyHomeView/utils/computeStepperVisibility.ts diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.styles.ts b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.styles.ts index e7239008a51..f2168f26c5a 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.styles.ts +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.styles.ts @@ -7,8 +7,11 @@ const styleSheet = (params: { theme: Theme }) => flex: 1, backgroundColor: params.theme.colors.background.default, }, - scrollContent: { - paddingBottom: 0, + footerOverlay: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, }, }); diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx index 13c224dcfbe..927b5e656d6 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import { fireEvent } from '@testing-library/react-native'; +import { act, fireEvent } from '@testing-library/react-native'; +import { Linking } from 'react-native'; +import type { ReactTestInstance } from 'react-test-renderer'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import MoneyHomeView from './MoneyHomeView'; import { MoneyHomeViewTestIds } from './MoneyHomeView.testIds'; @@ -371,6 +373,43 @@ describe('MoneyHomeView', () => { }); }); + it('navigates to Card root when Get now row is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT); + }); + + it('navigates to potential earnings screen when View potential earnings is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyPotentialEarningsTestIds.VIEW_ALL_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.POTENTIAL_EARNINGS); + }); + + it('opens the MUSD learn more URL when learn more is pressed in empty state', () => { + const mockOpenURL = jest + .spyOn(Linking, 'openURL') + .mockResolvedValue(undefined); + + mockUseMoneyAccountTransactions.mockReturnValue({ + allTransactions: [], + deposits: [], + transfers: [], + submittedTransactions: [], + moneyAddress: '0x0000000000000000000000000000000000000001', + }); + + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyWhatYouGetTestIds.LEARN_MORE_BUTTON)); + + expect(mockOpenURL).toHaveBeenCalledTimes(1); + mockOpenURL.mockRestore(); + }); + describe('monthly and yearly earnings', () => { it('passes the formatted monthly earnings to MoneyEarnings', () => { mockMoneyFormatFiat.mockReturnValue('$0.12'); @@ -817,4 +856,275 @@ describe('MoneyHomeView', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT); }); }); + + describe('Add money footer peek-and-hide', () => { + it('mounts the footer in its hidden initial position', () => { + const { getByTestId } = renderWithProvider(); + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('handleScrollViewLayout updates scroll view height and calls updateStepperVisibility', () => { + const { getByTestId } = renderWithProvider(); + const scrollView = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW); + + act(() => { + fireEvent(scrollView, 'layout', { + nativeEvent: { layout: { height: 700, width: 390, x: 0, y: 0 } }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('handleScrollViewLayout is a no-op when height is unchanged', () => { + const { getByTestId } = renderWithProvider(); + const scrollView = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW); + + act(() => { + fireEvent(scrollView, 'layout', { + nativeEvent: { layout: { height: 600, width: 390, x: 0, y: 0 } }, + }); + }); + + act(() => { + fireEvent(scrollView, 'layout', { + nativeEvent: { layout: { height: 600, width: 390, x: 0, y: 0 } }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('handleScroll records the current scroll offset and calls updateStepperVisibility', () => { + const { getByTestId } = renderWithProvider(); + const scrollView = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW); + + act(() => { + fireEvent.scroll(scrollView, { + nativeEvent: { + contentOffset: { y: 300, x: 0 }, + contentSize: { height: 1200, width: 390 }, + layoutMeasurement: { height: 700, width: 390 }, + }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('handleStepperLayout stores new layout and triggers visibility update', () => { + const { UNSAFE_getAllByType, getByTestId } = renderWithProvider( + , + ); + + const Box = jest.requireActual( + '@metamask/design-system-react-native', + ).Box; + const stepperBox = UNSAFE_getAllByType(Box).find( + (b: { props: { onLayout?: unknown } }) => b.props.onLayout, + ); + + act(() => { + stepperBox?.props.onLayout({ + nativeEvent: { layout: { y: 200, height: 120, x: 0, width: 390 } }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('handleStepperLayout is a no-op when layout dimensions are unchanged', () => { + const { UNSAFE_getAllByType, getByTestId } = renderWithProvider( + , + ); + + const Box = jest.requireActual( + '@metamask/design-system-react-native', + ).Box; + const stepperBox = UNSAFE_getAllByType(Box).find( + (b: { props: { onLayout?: unknown } }) => b.props.onLayout, + ); + + act(() => { + stepperBox?.props.onLayout({ + nativeEvent: { layout: { y: 200, height: 120, x: 0, width: 390 } }, + }); + }); + + act(() => { + stepperBox?.props.onLayout({ + nativeEvent: { layout: { y: 200, height: 120, x: 0, width: 390 } }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('footer peek-in: scrolling past stepper bottom triggers animateFooter(true)', () => { + const { UNSAFE_getAllByType, getByTestId } = renderWithProvider( + , + ); + + const scrollView = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW); + + act(() => { + fireEvent(scrollView, 'layout', { + nativeEvent: { layout: { height: 700, width: 390, x: 0, y: 0 } }, + }); + }); + + const Box = jest.requireActual( + '@metamask/design-system-react-native', + ).Box; + const stepperBox = UNSAFE_getAllByType(Box).find( + (b: { props: { onLayout?: unknown } }) => b.props.onLayout, + ); + + act(() => { + stepperBox?.props.onLayout({ + nativeEvent: { layout: { y: 100, height: 200, x: 0, width: 390 } }, + }); + }); + + act(() => { + fireEvent.scroll(scrollView, { + nativeEvent: { + contentOffset: { y: 500, x: 0 }, + contentSize: { height: 2000, width: 390 }, + layoutMeasurement: { height: 700, width: 390 }, + }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('footer hide: scrolling back above stepper bottom triggers animateFooter(false)', () => { + const { UNSAFE_getAllByType, getByTestId } = renderWithProvider( + , + ); + + const scrollView = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW); + + act(() => { + fireEvent(scrollView, 'layout', { + nativeEvent: { layout: { height: 700, width: 390, x: 0, y: 0 } }, + }); + }); + + const Box = jest.requireActual( + '@metamask/design-system-react-native', + ).Box; + const stepperBox = UNSAFE_getAllByType(Box).find( + (b: { props: { onLayout?: unknown } }) => b.props.onLayout, + ); + + act(() => { + stepperBox?.props.onLayout({ + nativeEvent: { layout: { y: 100, height: 200, x: 0, width: 390 } }, + }); + }); + + act(() => { + fireEvent.scroll(scrollView, { + nativeEvent: { + contentOffset: { y: 500, x: 0 }, + contentSize: { height: 2000, width: 390 }, + layoutMeasurement: { height: 700, width: 390 }, + }, + }); + }); + + act(() => { + fireEvent.scroll(scrollView, { + nativeEvent: { + contentOffset: { y: 50, x: 0 }, + contentSize: { height: 2000, width: 390 }, + layoutMeasurement: { height: 700, width: 390 }, + }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('updateStepperVisibility does not animate when visibility is unchanged', () => { + const { getByTestId } = renderWithProvider(); + const scrollView = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW); + + act(() => { + fireEvent.scroll(scrollView, { + nativeEvent: { + contentOffset: { y: 0, x: 0 }, + contentSize: { height: 2000, width: 390 }, + layoutMeasurement: { height: 700, width: 390 }, + }, + }); + }); + + act(() => { + fireEvent.scroll(scrollView, { + nativeEvent: { + contentOffset: { y: 10, x: 0 }, + contentSize: { height: 2000, width: 390 }, + layoutMeasurement: { height: 700, width: 390 }, + }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('handleFooterLayout updates footer height on first measurement', () => { + const { getByTestId } = renderWithProvider(); + + const footerEl = getByTestId(MoneyFooterTestIds.CONTAINER); + let footerAnimatedView: ReactTestInstance | null = null; + let cursor: ReactTestInstance | null = footerEl.parent ?? null; + while (cursor) { + if (typeof cursor.props?.onLayout === 'function') { + footerAnimatedView = cursor; + break; + } + cursor = cursor.parent ?? null; + } + + act(() => { + footerAnimatedView?.props.onLayout?.({ + nativeEvent: { layout: { height: 80, width: 390, x: 0, y: 0 } }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('handleFooterLayout is a no-op when footer height is unchanged', () => { + const { getByTestId } = renderWithProvider(); + + const footerEl = getByTestId(MoneyFooterTestIds.CONTAINER); + let footerAnimatedView: ReactTestInstance | null = null; + let cursor: ReactTestInstance | null = footerEl.parent ?? null; + while (cursor) { + if (typeof cursor.props?.onLayout === 'function') { + footerAnimatedView = cursor; + break; + } + cursor = cursor.parent ?? null; + } + + act(() => { + footerAnimatedView?.props.onLayout?.({ + nativeEvent: { layout: { height: 80, width: 390, x: 0, y: 0 } }, + }); + }); + + act(() => { + footerAnimatedView?.props.onLayout?.({ + nativeEvent: { layout: { height: 80, width: 390, x: 0, y: 0 } }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx index 56db726370c..dc97b58b752 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx @@ -1,5 +1,17 @@ -import React, { useCallback, useMemo } from 'react'; -import { ScrollView, Linking } from 'react-native'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { + LayoutChangeEvent, + Linking, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, +} from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; @@ -23,6 +35,7 @@ import MoneyFooter from '../../components/MoneyFooter'; import Routes from '../../../../../constants/navigation/Routes'; import { MoneyHomeViewTestIds } from './MoneyHomeView.testIds'; import styleSheet from './MoneyHomeView.styles'; +import { computeStepperVisibility } from './utils/computeStepperVisibility'; import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; import { useMoneyAccountTransactions } from '../../hooks/useMoneyAccountTransactions'; @@ -43,6 +56,11 @@ import { Hex } from '@metamask/utils'; const Divider = () => ; +// Slide distance for the footer peek-in/out animation. Large enough to fully +// clear any realistic footer height (button + safe-area insets). +const FOOTER_HIDDEN_OFFSET = 240; +const FOOTER_ANIMATION_DURATION_MS = 300; + type MoneyHomeState = 'empty' | 'milestone' | 'filled'; const getMoneyHomeState = (transactionCount: number): MoneyHomeState => { @@ -211,6 +229,97 @@ const MoneyHomeView = () => { showMoneyActivityUnderConstructionAlert(); }, []); + // Stepper layout, scroll offset, and scroll view height are read on every + // scroll event (~60fps with scrollEventThrottle={16}). Storing them as state + // would re-render MoneyHomeView on every frame during scrolling. + const stepperLayoutRef = useRef<{ y: number; height: number } | null>(null); + const scrollOffsetYRef = useRef(0); + const scrollViewHeightRef = useRef(0); + // ScrollView reserves matching bottom padding so the absolutely positioned + // footer overlay never hides scroll content -- state, not a ref, so the + // padding update triggers a re-render. + const [footerHeight, setFooterHeight] = useState(0); + + const footerTranslateY = useSharedValue(FOOTER_HIDDEN_OFFSET); + const footerAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: footerTranslateY.value }], + })); + const scrollContentStyle = useMemo( + () => ({ paddingBottom: footerHeight }), + [footerHeight], + ); + + // Default to "visible" until layouts settle so the footer stays hidden on + // initial paint and we avoid a flash of "Add money". + const isStepperVisibleRef = useRef(true); + + const getStepperVisible = useCallback( + () => + computeStepperVisibility({ + stepperLayout: stepperLayoutRef.current, + scrollViewHeight: scrollViewHeightRef.current, + scrollOffsetY: scrollOffsetYRef.current, + }), + [], + ); + + const animateFooter = useCallback( + (visible: boolean) => { + footerTranslateY.value = withTiming(visible ? 0 : FOOTER_HIDDEN_OFFSET, { + duration: FOOTER_ANIMATION_DURATION_MS, + easing: visible ? Easing.out(Easing.cubic) : Easing.in(Easing.cubic), + }); + }, + [footerTranslateY], + ); + + const updateStepperVisibility = useCallback(() => { + const next = getStepperVisible(); + if (next === isStepperVisibleRef.current) return; + isStepperVisibleRef.current = next; + animateFooter(!next); + }, [getStepperVisible, animateFooter]); + + const handleStepperLayout = useCallback( + (event: LayoutChangeEvent) => { + const { y, height } = event.nativeEvent.layout; + const prev = stepperLayoutRef.current; + if (prev && prev.y === y && prev.height === height) { + return; + } + stepperLayoutRef.current = { y, height }; + updateStepperVisibility(); + }, + [updateStepperVisibility], + ); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + // Update the ref unconditionally on every scroll frame (cheap) but only + // commit a state change when the visibility boolean actually flips. + scrollOffsetYRef.current = event.nativeEvent.contentOffset.y; + updateStepperVisibility(); + }, + [updateStepperVisibility], + ); + + const handleScrollViewLayout = useCallback( + (event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout; + if (scrollViewHeightRef.current === height) { + return; + } + scrollViewHeightRef.current = height; + updateStepperVisibility(); + }, + [updateStepperVisibility], + ); + + const handleFooterLayout = useCallback((event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout; + setFooterHeight((prev) => (prev === height ? prev : height)); + }, []); + const handleOnboardingCtaPress = useCallback(() => { if (isCardholderWithMilestone) { handleLinkCardPress(); @@ -243,8 +352,11 @@ const MoneyHomeView = () => { /> { onTransferPress={handleTransferPress} onCardPress={handleCardPress} /> - + + + { /> )} - + + + ); }; diff --git a/app/components/UI/Money/Views/MoneyHomeView/utils/computeStepperVisibility.test.ts b/app/components/UI/Money/Views/MoneyHomeView/utils/computeStepperVisibility.test.ts new file mode 100644 index 00000000000..7f1c2a90ede --- /dev/null +++ b/app/components/UI/Money/Views/MoneyHomeView/utils/computeStepperVisibility.test.ts @@ -0,0 +1,65 @@ +import { computeStepperVisibility } from './computeStepperVisibility'; + +describe('computeStepperVisibility', () => { + it('returns true when layout is null (still measuring)', () => { + expect( + computeStepperVisibility({ + stepperLayout: null, + scrollViewHeight: 600, + scrollOffsetY: 0, + }), + ).toBe(true); + }); + + it('returns true when stepper height is 0 (unmeasured)', () => { + expect( + computeStepperVisibility({ + stepperLayout: { y: 0, height: 0 }, + scrollViewHeight: 600, + scrollOffsetY: 0, + }), + ).toBe(true); + }); + + it('returns true when scrollViewHeight is 0 (scrollview not laid out)', () => { + expect( + computeStepperVisibility({ + stepperLayout: { y: 100, height: 300 }, + scrollViewHeight: 0, + scrollOffsetY: 0, + }), + ).toBe(true); + }); + + it('returns false when user has scrolled past the stepper bottom', () => { + // Stepper occupies y=[100, 400]. Scrolling to 500 puts the user past it. + expect( + computeStepperVisibility({ + stepperLayout: { y: 100, height: 300 }, + scrollViewHeight: 600, + scrollOffsetY: 500, + }), + ).toBe(false); + }); + + it('returns true when user is exactly at the stepper bottom (boundary, inclusive)', () => { + // stepperBottom = 100 + 300 = 400; offset === 400 stays "visible". + expect( + computeStepperVisibility({ + stepperLayout: { y: 100, height: 300 }, + scrollViewHeight: 600, + scrollOffsetY: 400, + }), + ).toBe(true); + }); + + it('returns true when stepper is fully on screen and user has not scrolled', () => { + expect( + computeStepperVisibility({ + stepperLayout: { y: 100, height: 300 }, + scrollViewHeight: 600, + scrollOffsetY: 0, + }), + ).toBe(true); + }); +}); diff --git a/app/components/UI/Money/Views/MoneyHomeView/utils/computeStepperVisibility.ts b/app/components/UI/Money/Views/MoneyHomeView/utils/computeStepperVisibility.ts new file mode 100644 index 00000000000..218972af07d --- /dev/null +++ b/app/components/UI/Money/Views/MoneyHomeView/utils/computeStepperVisibility.ts @@ -0,0 +1,36 @@ +export interface StepperLayout { + y: number; + height: number; +} + +export interface ComputeStepperVisibilityArgs { + stepperLayout: StepperLayout | null; + scrollViewHeight: number; + scrollOffsetY: number; +} + +/** + * Returns true when the onboarding stepper should be considered "visible" + * for footer peek-and-hide purposes. + * + * Layout / measurement still pending (any of: layout null, height === 0, + * scrollViewHeight === 0): return true so the footer stays hidden until the + * stepper's onLayout reports a real height. Avoids a flash of "Add money" + * before measurements settle. + * + * User has scrolled past the stepper's bottom edge: return false (the footer + * should peek in). + * + * Otherwise (stepper still on screen or below the fold): return true. + */ +export const computeStepperVisibility = ({ + stepperLayout, + scrollViewHeight, + scrollOffsetY, +}: ComputeStepperVisibilityArgs): boolean => { + if (!stepperLayout || stepperLayout.height === 0 || scrollViewHeight === 0) { + return true; + } + const stepperBottom = stepperLayout.y + stepperLayout.height; + return scrollOffsetY <= stepperBottom; +}; From b283dbd9704fac8e12866ba7117303d1683bbb4d Mon Sep 17 00:00:00 2001 From: Vince Howard Date: Wed, 6 May 2026 11:27:51 -0600 Subject: [PATCH 26/27] feat: hub page discovery tabs A/B test (#29193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Implements the treatment UI for the `coreMCU589AbtestHubPageDiscoveryTabs` A/B test When the treatment variant is active, the homepage replaces the standard scrollable layout with a top-tab navigation bar exposing three verticals: - **Portfolio:** existing homepage sections with balance header and pull-to-refresh - **Perpetuals:** `PerpsHomeView` wrapped in connection + stream providers - **Predictions:** `PredictFeed` wrapped in preview sheet provider When the control variant is active (or the flag is absent), the existing homepage layout is fully preserved. Key implementation details: - `HomepageDiscoveryTabs`: new component that owns tab layout, per-tab gradient crossfade, and wallet header hide/show coordination across all three tabs - `TabsList` — design-system tab primitive with `keepMounted` support. Perpetuals and Predictions both use `keepMounted={false}`: - **Performance**: both screens have a heavy hydration cost on first mount; keeping them alive while invisible wastes resources on tabs the user may never visit - **Open connections**: `PerpsHomeView` establishes WebSocket channels for live market data via its stream providers on mount; leaving these running in the background wastes bandwidth and server-side connection slots - **Memory**: `PredictFeed` and its preview sheet provider hold feed state that can safely be discarded between visits - Portfolio uses `keepMounted={true}` (the default) since it is the landing tab and its scroll position and section state should survive switching away and back - Wallet header animates up/down on scroll via Reanimated shared values; icon collapse and gradient opacity are synced via `TabIconAnimationContext` - Scroll event forwarding keeps existing `HOME_VIEWED` section analytics working in the Portfolio tab - A/B gating is handled by `useABTest(HUB_PAGE_DISCOVERY_TABS_AB_KEY)` [Figma Design](https://www.figma.com/design/z0panHXrMSMUSof2SaPkd4/Home-2026?m=auto&node-id=4280-62214&t=AAKr2hzmyPx57F4Y-1) for reference. Only difference between this UI and the Figma is that the tabs take up the entire space ## **Changelog** CHANGELOG entry:null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-591 ## **Manual testing steps** ```gherkin Feature: Hub Page Discovery Tabs Scenario: treatment variant shows top tabs Given the coreMCU589AbtestHubPageDiscoveryTabs flag is set to "treatment" When user opens the app to the Wallet screen Then three tabs are visible: Portfolio, Perpetuals, Predictions And switching between tabs is smooth with no layout shift And the wallet header hides on scroll and restores on tab switch And pull-to-refresh works on the Portfolio tab Scenario: control variant preserves existing layout Given the coreMCU589AbtestHubPageDiscoveryTabs flag is set to "control" When user opens the app to the Wallet screen Then the existing homepage layout is shown with no tabs ``` ## **Screenshots/Recordings** ### iOS Dark Mode https://github.com/user-attachments/assets/7df6a46d-b3b3-44cc-a697-b796581dd759 Light Mode (No Gradient) https://github.com/user-attachments/assets/97b1f901-6092-42f9-926c-e8e6785c6f4e ### Android Dark Mode https://github.com/user-attachments/assets/4232f3fc-a47c-4516-93c2-104e4a3fb5cb Light Mode (No Gradient) https://github.com/user-attachments/assets/3f11dd8f-696f-438e-9867-1b08dc69200d ### **Before** `~` ### **After** `~` ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Introduces a new A/B-test-gated navigation structure and shared header animation/scroll handling on the Wallet home screen, which could affect core navigation and scroll/refresh behavior. Risk is mitigated by control-path preservation and added unit coverage, but Reanimated worklet interactions and tab switching edge cases remain sensitive. > > **Overview** > When the Hub Page Discovery Tabs A/B test is in *treatment* (and homepage sections are enabled), Wallet home now renders a new top-level `HomepageDiscoveryTabs` experience with three tabs: Portfolio, Perpetuals (`PerpsHomeView`), and Predictions (`PredictFeed`). The control path preserves the existing scroll layout. > > Adds `useDiscoveryScrollManager`, a Reanimated-backed hook that hides/shows the shared Wallet header based on scroll threshold, restores per-tab header state on tab entry, forwards scroll events back to JS (for analytics), and emits `onHeaderHiddenChange` to sync sibling animations. > > Updates `Wallet` to measure/animate the header via shared values, pass refresh/portfolio header/scroll callbacks into the new tabs view, and crossfade a dark-mode gradient overlay per active tab. `PerpsHomeView` and `PredictFeed` gain `hideHeader` and header-sync props, and `useFeedScrollManager` can now notify header hidden/show changes; tests are added/updated across these components and the new hook. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 38e679d20c800a000ba1755ca4ddf60092e33859. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Cursor --- .../PerpsHomeView/PerpsHomeView.test.tsx | 92 +++ .../Views/PerpsHomeView/PerpsHomeView.tsx | 77 ++- .../hooks/useDiscoveryScrollManager.test.ts | 558 ++++++++++++++++++ .../hooks/useDiscoveryScrollManager.ts | 201 +++++++ .../UI/Predict/hooks/useFeedScrollManager.ts | 5 + .../views/PredictFeed/PredictFeed.test.tsx | 54 ++ .../Predict/views/PredictFeed/PredictFeed.tsx | 55 +- .../HomepageDiscoveryTabs.styles.ts | 21 + .../HomepageDiscoveryTabs.test.tsx | 201 +++++++ .../HomepageDiscoveryTabs.tsx | 368 ++++++++++++ .../components/HomepageDiscoveryTabs/index.ts | 1 + app/components/Views/Wallet/index.test.tsx | 163 +++++ app/components/Views/Wallet/index.tsx | 439 +++++++++----- 13 files changed, 2056 insertions(+), 179 deletions(-) create mode 100644 app/components/UI/Predict/hooks/useDiscoveryScrollManager.test.ts create mode 100644 app/components/UI/Predict/hooks/useDiscoveryScrollManager.ts create mode 100644 app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.styles.ts create mode 100644 app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.test.tsx create mode 100644 app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx create mode 100644 app/components/Views/Homepage/components/HomepageDiscoveryTabs/index.ts diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx index 124110bfe2f..0e5d68c95bd 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx @@ -4,6 +4,25 @@ import PerpsHomeView from './PerpsHomeView'; import { PERPS_EVENT_VALUE } from '@metamask/perps-controller'; import { selectPerpsFeedbackEnabledFlag } from '../../selectors/featureFlags'; import { mockTheme } from '../../../../../util/theme'; +import { useDiscoveryScrollManager } from '../../../Predict/hooks/useDiscoveryScrollManager'; + +// Mock useDiscoveryScrollManager +const mockPerpsOnTabEnter = jest.fn(); +const mockPerpsScrollHandler = jest.fn(); +jest.mock('../../../Predict/hooks/useDiscoveryScrollManager', () => ({ + useDiscoveryScrollManager: jest.fn(() => ({ + scrollHandler: mockPerpsScrollHandler, + onTabEnter: mockPerpsOnTabEnter, + headerHidden: false, + })), +})); + +// Mock react-native-reanimated +jest.mock('react-native-reanimated', () => { + const Reanimated = jest.requireActual('react-native-reanimated/mock'); + Reanimated.default.ScrollView = jest.requireActual('react-native').ScrollView; + return Reanimated; +}); // Mock navigation const mockNavigate = jest.fn(); @@ -877,4 +896,77 @@ describe('PerpsHomeView', () => { }); }); }); + + describe('hideHeader prop', () => { + it('renders the header by default', () => { + const { getByTestId } = render(); + expect(getByTestId('back-button')).toBeTruthy(); + expect(getByTestId('perps-home-search-toggle')).toBeTruthy(); + }); + + it('hides the header when hideHeader is true', () => { + const { queryByTestId } = render(); + expect(queryByTestId('back-button')).toBeNull(); + expect(queryByTestId('perps-home-search-toggle')).toBeNull(); + }); + + it('still renders content when hideHeader is true', () => { + const { UNSAFE_getByType } = render(); + expect( + UNSAFE_getByType('PerpsMarketBalanceActions' as never), + ).toBeTruthy(); + }); + }); + + describe('tabEnterCallbackRef prop', () => { + it('populates tabEnterCallbackRef.current with onTabEnter after mount', () => { + const ref = { current: null } as React.MutableRefObject< + (() => void) | null + >; + render(); + expect(ref.current).toBe(mockPerpsOnTabEnter); + }); + + it('updates tabEnterCallbackRef.current when onTabEnter changes', () => { + const ref = { current: null } as React.MutableRefObject< + (() => void) | null + >; + const newOnTabEnter = jest.fn(); + (useDiscoveryScrollManager as jest.Mock).mockReturnValueOnce({ + scrollHandler: mockPerpsScrollHandler, + onTabEnter: newOnTabEnter, + headerHidden: false, + }); + render(); + expect(ref.current).toBe(newOnTabEnter); + }); + + it('does not throw when tabEnterCallbackRef is not provided', () => { + expect(() => render()).not.toThrow(); + }); + }); + + describe('useDiscoveryScrollManager integration', () => { + it('passes walletHeaderHeight to useDiscoveryScrollManager', () => { + render(); + expect(useDiscoveryScrollManager).toHaveBeenCalledWith( + expect.objectContaining({ walletHeaderHeight: 56 }), + ); + }); + + it('passes onHeaderHiddenChange to useDiscoveryScrollManager', () => { + const onHeaderHiddenChange = jest.fn(); + render(); + expect(useDiscoveryScrollManager).toHaveBeenCalledWith( + expect.objectContaining({ onHeaderHiddenChange }), + ); + }); + + it('uses default walletHeaderHeight of 0 when not provided', () => { + render(); + expect(useDiscoveryScrollManager).toHaveBeenCalledWith( + expect.objectContaining({ walletHeaderHeight: 0 }), + ); + }); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx index 2baecf8cf13..1a00e887ada 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx @@ -5,7 +5,7 @@ import React, { useEffect, useMemo, } from 'react'; -import { View, ScrollView, Modal } from 'react-native'; +import { View, Modal, NativeScrollEvent } from 'react-native'; import { useSelector } from 'react-redux'; import { SafeAreaView, @@ -57,6 +57,8 @@ import PerpsHomeHeader from '../../components/PerpsHomeHeader'; import type { PerpsNavigationParamList } from '../../types/navigation'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import Reanimated, { SharedValue } from 'react-native-reanimated'; +import { useDiscoveryScrollManager } from '../../../Predict/hooks/useDiscoveryScrollManager'; import styleSheet from './PerpsHomeView.styles'; import { TraceName } from '../../../../../util/trace'; import { @@ -72,7 +74,23 @@ import PerpsNavigationCard, { NavigationItem, } from '../../components/PerpsNavigationCard/PerpsNavigationCard'; -const PerpsHomeView = () => { +interface PerpsHomeViewProps { + hideHeader?: boolean; + walletHeaderTranslateY?: SharedValue; + walletHeaderHeight?: number; + /** Ref populated with this tab's onTabEnter so the parent can call it on tab switch. */ + tabEnterCallbackRef?: React.MutableRefObject<(() => void) | null>; + /** Forwarded to useDiscoveryScrollManager to sync icon animations with header hide/show. */ + onHeaderHiddenChange?: (hidden: boolean) => void; +} + +const PerpsHomeView = ({ + hideHeader = false, + walletHeaderTranslateY, + walletHeaderHeight = 0, + tabEnterCallbackRef, + onHeaderHiddenChange, +}: PerpsHomeViewProps) => { const { styles } = useStyles(styleSheet, {}); const insets = useSafeAreaInsets(); const navigation = useNavigation(); @@ -124,6 +142,38 @@ const PerpsHomeView = () => { const { handleSectionLayout, handleScroll, resetTracking } = usePerpsHomeSectionTracking(); + // Bridge analytics handler into the Reanimated worklet via onScrollEvent + const handleScrollEvent = useCallback( + (scrollY: number, viewportHeight: number) => { + handleScroll({ + nativeEvent: { + contentOffset: { x: 0, y: scrollY }, + layoutMeasurement: { width: 0, height: viewportHeight }, + } as NativeScrollEvent, + }); + }, + [handleScroll], + ); + + const { scrollHandler: perpsScrollHandler, onTabEnter: perpsOnTabEnter } = + useDiscoveryScrollManager({ + walletHeaderHeight, + walletHeaderTranslateY, + onScrollEvent: handleScrollEvent, + onHeaderHiddenChange, + }); + + // Expose onTabEnter to the parent so it can restore this tab's header state on switch. + useEffect(() => { + if (tabEnterCallbackRef) { + tabEnterCallbackRef.current = perpsOnTabEnter; + return () => { + tabEnterCallbackRef.current = null; + }; + } + return undefined; + }, [tabEnterCallbackRef, perpsOnTabEnter]); + // Get balance state directly from Redux const { account: perpsAccount } = usePerpsLiveAccount({ throttleMs: 1000 }); const totalBalance = perpsAccount?.totalBalance || '0'; @@ -417,20 +467,25 @@ const PerpsHomeView = () => { const handleBackPress = perpsNavigation.navigateToWallet; return ( - + {/* Header */} - + {!hideHeader && ( + + )} {/* Main Content - ScrollView with all carousels */} - { {/* Bottom spacing for tab bar */} - + {/* Close All Positions Bottom Sheet */} {showCloseAllSheet && ( diff --git a/app/components/UI/Predict/hooks/useDiscoveryScrollManager.test.ts b/app/components/UI/Predict/hooks/useDiscoveryScrollManager.test.ts new file mode 100644 index 00000000000..01b49ce5364 --- /dev/null +++ b/app/components/UI/Predict/hooks/useDiscoveryScrollManager.test.ts @@ -0,0 +1,558 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { SharedValue } from 'react-native-reanimated'; +import { + useDiscoveryScrollManager, + ANIMATION_DURATION, + SCROLL_THRESHOLD, +} from './useDiscoveryScrollManager'; + +const mockWithTiming = jest.fn((toValue: unknown) => toValue); +const mockWithDelay = jest.fn( + (_delay: unknown, animation: unknown) => animation, +); +const mockRunOnJS = jest.fn( + (fn: (...args: unknown[]) => void) => + (...args: unknown[]) => + fn(...args), +); + +jest.mock('react-native-reanimated', () => { + const { useRef } = jest.requireActual('react'); + return { + // useRef keeps the SharedValue object alive across re-renders, matching + // the real implementation and preventing isHeaderHidden from resetting to + // its initial value whenever a state update triggers a re-render. + useSharedValue: jest.fn((initialValue: unknown) => { + const ref = useRef({ value: initialValue }); + return ref.current; + }), + useAnimatedScrollHandler: jest.fn( + (config: { onScroll: (event: ScrollEvent) => void }) => config, + ), + withTiming: mockWithTiming, + withDelay: mockWithDelay, + Easing: { + out: jest.fn((easing: unknown) => easing), + cubic: jest.fn(), + }, + runOnJS: mockRunOnJS, + }; +}); + +interface ScrollEvent { + contentOffset: { x?: number; y: number }; + contentSize: { width?: number; height: number }; + layoutMeasurement: { width?: number; height: number }; +} + +interface ScrollHandler { + onScroll?: (event: ScrollEvent) => void; +} + +/** Build a realistic scroll event. contentSize defaults to a tall page. */ +const makeScrollEvent = ( + y: number, + opts: { contentHeight?: number; viewportHeight?: number } = {}, +): ScrollEvent => ({ + contentOffset: { x: 0, y }, + contentSize: { width: 390, height: opts.contentHeight ?? 2000 }, + layoutMeasurement: { width: 390, height: opts.viewportHeight ?? 800 }, +}); + +describe('useDiscoveryScrollManager', () => { + const createSharedValue = (initial: number) => + ({ value: initial }) as unknown as SharedValue; + + const createDefaultProps = (overrides = {}) => ({ + walletHeaderHeight: 56, + walletHeaderTranslateY: createSharedValue(0), + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + // The global Reanimated.setUpTests() in testSetup.js may override the + // module-level jest.mock factory. Patch all used functions at runtime so + // worklet-called and JS-thread functions resolve to our controllable mocks. + const reanimated = jest.requireMock('react-native-reanimated'); + reanimated.withTiming = mockWithTiming; + reanimated.withDelay = mockWithDelay; + reanimated.runOnJS = mockRunOnJS; + mockWithTiming.mockImplementation((toValue: unknown) => toValue); + mockWithDelay.mockImplementation( + (_delay: unknown, animation: unknown) => animation, + ); + }); + + // ─── exports ─────────────────────────────────────────────────────────────── + + describe('exports', () => { + it('exports ANIMATION_DURATION as 300', () => { + expect(ANIMATION_DURATION).toBe(300); + }); + + it('exports SCROLL_THRESHOLD as 100', () => { + expect(SCROLL_THRESHOLD).toBe(100); + }); + }); + + describe('initialization', () => { + it('returns required properties', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + expect(typeof result.current.headerHidden).toBe('boolean'); + expect(typeof result.current.onTabEnter).toBe('function'); + expect(result.current.scrollHandler).toBeDefined(); + }); + + it('initializes headerHidden as false', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + expect(result.current.headerHidden).toBe(false); + }); + + it('works without optional walletHeaderTranslateY', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager({ walletHeaderHeight: 56 }), + ); + + expect(result.current.headerHidden).toBe(false); + expect(result.current.scrollHandler).toBeDefined(); + }); + + it('works without any optional props', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager({ walletHeaderHeight: 56 }), + ); + + expect(result.current.headerHidden).toBe(false); + }); + }); + + describe('walletHeaderHeight sync', () => { + it('updates sharedHeaderHeight when walletHeaderHeight prop changes', () => { + const props = createDefaultProps({ walletHeaderHeight: 56 }); + + const { rerender } = renderHook( + (p: typeof props) => useDiscoveryScrollManager(p), + { initialProps: props }, + ); + + expect(() => + rerender({ ...props, walletHeaderHeight: 80 }), + ).not.toThrow(); + }); + }); + + describe('scrollHandler', () => { + it('processes a scroll event without throwing', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + expect(() => { + handler.onScroll?.(makeScrollEvent(0)); + }).not.toThrow(); + }); + + it('does not hide header when accumulated scroll is below threshold', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(50)); // 50px < SCROLL_THRESHOLD (100) + }); + + expect(result.current.headerHidden).toBe(false); + }); + + it('hides header after scrolling down past the threshold', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(101)); // > 100px threshold + }); + + expect(result.current.headerHidden).toBe(true); + }); + + it('shows header again after scrolling up past threshold when hidden', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + + expect(result.current.headerHidden).toBe(true); + + act(() => { + handler.onScroll?.(makeScrollEvent(200)); + handler.onScroll?.(makeScrollEvent(50)); // -150px upward (> threshold) + }); + + expect(result.current.headerHidden).toBe(false); + }); + + it('shows header immediately when scrolled back to top', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + expect(result.current.headerHidden).toBe(true); + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); // atTop forces show + }); + expect(result.current.headerHidden).toBe(false); + }); + + it('resets accumulated delta when scroll direction reverses', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + // Scroll down 80px (below threshold) + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(80)); + + // Reverse — accumulated delta resets, only 20px down so far + handler.onScroll?.(makeScrollEvent(60)); + handler.onScroll?.(makeScrollEvent(80)); // 20px down — still below threshold + }); + + expect(result.current.headerHidden).toBe(false); + }); + + it('ignores zero-delta events', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(50)); + handler.onScroll?.(makeScrollEvent(50)); // delta = 0, should be ignored + }); + + expect(result.current.headerHidden).toBe(false); + }); + + it('ignores bounce events past the bottom edge', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + expect(result.current.headerHidden).toBe(true); + + act(() => { + // Bounce: scroll past bottom (contentHeight=2000, viewport=800 → max=1200) + handler.onScroll?.( + makeScrollEvent(1250, { contentHeight: 2000, viewportHeight: 800 }), + ); + // Snapback upward should NOT re-show header (atBottom guard) + handler.onScroll?.( + makeScrollEvent(1200, { contentHeight: 2000, viewportHeight: 800 }), + ); + }); + + expect(result.current.headerHidden).toBe(true); + }); + + it('invokes onPortfolioScroll on every scroll event', () => { + const onPortfolioScroll = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps({ onPortfolioScroll })), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + act(() => { + handler.onScroll?.(makeScrollEvent(10)); + handler.onScroll?.(makeScrollEvent(20)); + }); + + expect(onPortfolioScroll).toHaveBeenCalledTimes(2); + }); + + it('invokes onScrollEvent with scrollY and viewportHeight on every event', () => { + const onScrollEvent = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps({ onScrollEvent })), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + act(() => { + handler.onScroll?.(makeScrollEvent(42, { viewportHeight: 800 })); + }); + + expect(onScrollEvent).toHaveBeenCalledWith(42, 800); + }); + + it('invokes both onPortfolioScroll and onScrollEvent in the same scroll event', () => { + const onPortfolioScroll = jest.fn(); + const onScrollEvent = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager( + createDefaultProps({ onPortfolioScroll, onScrollEvent }), + ), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + act(() => { + handler.onScroll?.(makeScrollEvent(10, { viewportHeight: 750 })); + }); + + expect(onPortfolioScroll).toHaveBeenCalledTimes(1); + expect(onScrollEvent).toHaveBeenCalledWith(10, 750); + }); + + it('calls onHeaderHiddenChange(true) when header hides', () => { + const onHeaderHiddenChange = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps({ onHeaderHiddenChange })), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + + expect(onHeaderHiddenChange).toHaveBeenCalledWith(true); + }); + + it('calls onHeaderHiddenChange(false) when header shows after scrolling up', () => { + const onHeaderHiddenChange = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps({ onHeaderHiddenChange })), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + onHeaderHiddenChange.mockClear(); + + act(() => { + handler.onScroll?.(makeScrollEvent(200)); + handler.onScroll?.(makeScrollEvent(50)); + }); + + expect(onHeaderHiddenChange).toHaveBeenCalledWith(false); + }); + + it('calls onHeaderHiddenChange(false) when scrolled back to top', () => { + const onHeaderHiddenChange = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps({ onHeaderHiddenChange })), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + onHeaderHiddenChange.mockClear(); + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + }); + + expect(onHeaderHiddenChange).toHaveBeenCalledWith(false); + }); + }); + + describe('onTabEnter', () => { + it('does not throw when header is visible', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + expect(() => { + act(() => { + result.current.onTabEnter(); + }); + }).not.toThrow(); + + expect(result.current.headerHidden).toBe(false); + }); + + it('does not throw after scrolling past threshold', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + + expect(() => { + act(() => { + result.current.onTabEnter(); + }); + }).not.toThrow(); + }); + + it('does not throw on re-entry after header was hidden by scroll', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + expect(result.current.headerHidden).toBe(true); + + expect(() => { + act(() => { + result.current.onTabEnter(); + }); + }).not.toThrow(); + }); + + it('shows header when re-entering a tab that was at the top', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + act(() => { + result.current.onTabEnter(); + }); + + expect(result.current.headerHidden).toBe(false); + }); + + it('calls onHeaderHiddenChange(true) when entering a tab with hidden header', () => { + const onHeaderHiddenChange = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps({ onHeaderHiddenChange })), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + onHeaderHiddenChange.mockClear(); + + act(() => { + result.current.onTabEnter(); + }); + + expect(onHeaderHiddenChange).toHaveBeenCalledWith(true); + }); + + it('calls onHeaderHiddenChange(false) when entering a tab with visible header', () => { + const onHeaderHiddenChange = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps({ onHeaderHiddenChange })), + ); + + act(() => { + result.current.onTabEnter(); + }); + + expect(onHeaderHiddenChange).toHaveBeenCalledWith(false); + }); + + it('skips settling scroll events after a tab switch', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + result.current.onTabEnter(); + }); + + // Fire 5 settling events (tabSwitchEventsToSkip = 5) — none should hide header + act(() => { + for (let i = 0; i < 5; i++) { + handler.onScroll?.(makeScrollEvent(500)); + } + }); + + expect(result.current.headerHidden).toBe(false); + }); + + it('processes scroll normally after the settling window expires', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + result.current.onTabEnter(); + }); + + // Burn through all 5 skip slots + act(() => { + for (let i = 0; i < 5; i++) { + handler.onScroll?.(makeScrollEvent(0)); + } + }); + + // Now normal scroll should work + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + + expect(result.current.headerHidden).toBe(true); + }); + }); + + describe('cleanup', () => { + it('unmounts without errors', () => { + const { unmount } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + expect(() => unmount()).not.toThrow(); + }); + }); +}); diff --git a/app/components/UI/Predict/hooks/useDiscoveryScrollManager.ts b/app/components/UI/Predict/hooks/useDiscoveryScrollManager.ts new file mode 100644 index 00000000000..c6cccf35749 --- /dev/null +++ b/app/components/UI/Predict/hooks/useDiscoveryScrollManager.ts @@ -0,0 +1,201 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { + useSharedValue, + useAnimatedScrollHandler, + SharedValue, + withTiming, + withDelay, + Easing, + runOnJS, +} from 'react-native-reanimated'; + +export const ANIMATION_DURATION = 300; +export const SCROLL_THRESHOLD = 100; + +const hideAnimationConfig = { + duration: ANIMATION_DURATION, + easing: Easing.out(Easing.cubic), +}; + +const showAnimationConfig = { + duration: 250, + easing: Easing.out(Easing.cubic), +}; + +interface UseDiscoveryScrollManagerParams { + walletHeaderHeight: number; + walletHeaderTranslateY?: SharedValue; + onPortfolioScroll?: () => void; + /** + * Called from the scroll worklet (via runOnJS) with the current scroll Y and + * viewport height. Use this to forward events to JS-thread scroll handlers + * (e.g. analytics section tracking) without needing a separate ScrollView. + */ + onScrollEvent?: (scrollY: number, viewportHeight: number) => void; + /** + * Called via runOnJS at the exact moment the hide/show decision is made — + * same worklet frame as the withTiming call. Use this to sync sibling + * animations (e.g. icon collapse) without position-based polling. + */ + onHeaderHiddenChange?: (hidden: boolean) => void; +} + +interface UseDiscoveryScrollManagerReturn { + headerHidden: boolean; + scrollHandler: ReturnType; + onTabEnter: () => void; +} + +export const useDiscoveryScrollManager = ({ + walletHeaderHeight, + walletHeaderTranslateY: externalTranslateY, + onPortfolioScroll, + onScrollEvent, + onHeaderHiddenChange, +}: UseDiscoveryScrollManagerParams): UseDiscoveryScrollManagerReturn => { + const fallbackTranslateY = useSharedValue(0); + const walletHeaderTranslateY = externalTranslateY ?? fallbackTranslateY; + const isHeaderHidden = useSharedValue(0); + const sharedHeaderHeight = useSharedValue(walletHeaderHeight); + const lastScrollY = useSharedValue(0); + const accumulatedDelta = useSharedValue(0); + const lastDirection = useSharedValue(0); + const tabSwitchEventsToSkip = useSharedValue(0); + + const [headerHidden, setHeaderHidden] = useState(false); + + // Keep shared height in sync as it's measured after first render + useEffect(() => { + sharedHeaderHeight.value = walletHeaderHeight; + }, [walletHeaderHeight, sharedHeaderHeight]); + + const onPortfolioScrollRef = useRef<(() => void) | undefined>(undefined); + onPortfolioScrollRef.current = onPortfolioScroll; + + const onScrollEventRef = useRef< + ((scrollY: number, viewportHeight: number) => void) | undefined + >(undefined); + onScrollEventRef.current = onScrollEvent; + + const callScrollCallbacks = useCallback( + (scrollY: number, viewportHeight: number) => { + onPortfolioScrollRef.current?.(); + onScrollEventRef.current?.(scrollY, viewportHeight); + }, + [], + ); + + const onHeaderHiddenChangeRef = useRef< + ((hidden: boolean) => void) | undefined + >(undefined); + onHeaderHiddenChangeRef.current = onHeaderHiddenChange; + const callHeaderHiddenChange = useCallback((hidden: boolean) => { + onHeaderHiddenChangeRef.current?.(hidden); + }, []); + + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + 'worklet'; + + const currentY = event.contentOffset.y; + runOnJS(callScrollCallbacks)(currentY, event.layoutMeasurement.height); + + // Skip settling events after a tab switch. Multiple events can fire + // when views remain mounted (opacity approach) and settle back into view. + if (tabSwitchEventsToSkip.value > 0) { + tabSwitchEventsToSkip.value -= 1; + lastScrollY.value = currentY; + accumulatedDelta.value = 0; + lastDirection.value = 0; + return; + } + + const delta = currentY - lastScrollY.value; + lastScrollY.value = currentY; + + const atTop = currentY <= 0; + const maxScrollY = + event.contentSize.height - event.layoutMeasurement.height; + const atBottom = maxScrollY > 0 && currentY >= maxScrollY; + + // Ignore bounce events past the bottom edge — the snapback registers as + // an upward scroll and would falsely trigger the show-header logic. + if (atBottom) { + accumulatedDelta.value = 0; + return; + } + + const currentDirection = delta > 0 ? 1 : delta < 0 ? -1 : 0; + if (currentDirection === 0) return; + + // Reset accumulated delta when direction reverses + if (currentDirection !== lastDirection.value) { + lastDirection.value = currentDirection; + accumulatedDelta.value = 0; + } + + accumulatedDelta.value += Math.abs(delta); + + // Always show header when at the top of the list + if (atTop && isHeaderHidden.value === 1) { + isHeaderHidden.value = 0; + walletHeaderTranslateY.value = withTiming(0, showAnimationConfig); + accumulatedDelta.value = 0; + lastDirection.value = 0; + runOnJS(setHeaderHidden)(false); + runOnJS(callHeaderHiddenChange)(false); + return; + } + + if (accumulatedDelta.value < SCROLL_THRESHOLD) return; + + // Scroll down — hide header + if (currentDirection === 1 && isHeaderHidden.value === 0) { + isHeaderHidden.value = 1; + walletHeaderTranslateY.value = withTiming( + -sharedHeaderHeight.value, + hideAnimationConfig, + ); + accumulatedDelta.value = 0; + runOnJS(setHeaderHidden)(true); + runOnJS(callHeaderHiddenChange)(true); + } + + // Scroll up — show header + if (currentDirection === -1 && isHeaderHidden.value === 1) { + isHeaderHidden.value = 0; + walletHeaderTranslateY.value = withTiming(0, showAnimationConfig); + accumulatedDelta.value = 0; + runOnJS(setHeaderHidden)(false); + runOnJS(callHeaderHiddenChange)(false); + } + }, + }); + + // Restore this tab's header state when the user enters it. + // If this tab was previously scrolled with the header hidden, keep it hidden. + // If this tab is at the top (or hasn't been visited), show the header. + const onTabEnter = useCallback(() => { + tabSwitchEventsToSkip.value = 5; + if (isHeaderHidden.value === 1) { + walletHeaderTranslateY.value = withDelay( + 100, + withTiming(-sharedHeaderHeight.value, hideAnimationConfig), + ); + setHeaderHidden(true); + callHeaderHiddenChange(true); + } else { + walletHeaderTranslateY.value = withTiming(0, showAnimationConfig); + setHeaderHidden(false); + callHeaderHiddenChange(false); + } + }, [ + tabSwitchEventsToSkip, + isHeaderHidden, + walletHeaderTranslateY, + sharedHeaderHeight, + callHeaderHiddenChange, + ]); + + return { headerHidden, scrollHandler, onTabEnter }; +}; diff --git a/app/components/UI/Predict/hooks/useFeedScrollManager.ts b/app/components/UI/Predict/hooks/useFeedScrollManager.ts index 5537e9a87dd..e30627127f8 100644 --- a/app/components/UI/Predict/hooks/useFeedScrollManager.ts +++ b/app/components/UI/Predict/hooks/useFeedScrollManager.ts @@ -17,6 +17,7 @@ export interface UseFeedScrollManagerParams { headerRef: React.RefObject; tabBarRef: React.RefObject; setActiveIndex: (index: number) => void; + onHeaderHiddenChange?: (hidden: boolean) => void; } export interface UseFeedScrollManagerReturn { @@ -69,6 +70,7 @@ export const useFeedScrollManager = ({ headerRef, tabBarRef, setActiveIndex, + onHeaderHiddenChange, }: UseFeedScrollManagerParams): UseFeedScrollManagerReturn => { const isHeaderHidden = useSharedValue(0); const headerTranslateY = useSharedValue(0); @@ -186,6 +188,7 @@ export const useFeedScrollManager = ({ accumulatedDelta.value = 0; lastDirection.value = 0; runOnJS(setHeaderHidden)(false); + if (onHeaderHiddenChange) runOnJS(onHeaderHiddenChange)(false); return; } @@ -202,6 +205,7 @@ export const useFeedScrollManager = ({ ); accumulatedDelta.value = 0; runOnJS(setHeaderHidden)(true); + if (onHeaderHiddenChange) runOnJS(onHeaderHiddenChange)(true); } // Scrolling up -> show header @@ -210,6 +214,7 @@ export const useFeedScrollManager = ({ headerTranslateY.value = withTiming(0, animationConfig); accumulatedDelta.value = 0; runOnJS(setHeaderHidden)(false); + if (onHeaderHiddenChange) runOnJS(onHeaderHiddenChange)(false); } }, }); diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx index edb94687527..c905e01ae5b 100644 --- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx +++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx @@ -1012,4 +1012,58 @@ describe('PredictFeed', () => { expect(queryByPlaceholderText('Search prediction markets')).toBeNull(); }); }); + + describe('hideHeader prop', () => { + it('renders header nav by default when hideHeader is not provided', () => { + const { getByTestId } = render(); + + expect( + getByTestId(PredictMarketListSelectorsIDs.BACK_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON), + ).toBeOnTheScreen(); + }); + + it('hides header nav when hideHeader is true', () => { + const { queryByTestId } = render(); + + expect( + queryByTestId(PredictMarketListSelectorsIDs.BACK_BUTTON), + ).toBeNull(); + expect(queryByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)).toBeNull(); + }); + + it('still renders container, tabs, and pager when hideHeader is true', () => { + const { getByTestId } = render(); + + expect( + getByTestId(PredictMarketListSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + expect(getByTestId(PredictFeedSelectorsIDs.TABS)).toBeOnTheScreen(); + expect( + getByTestId(PredictFeedMockSelectorsIDs.PAGER_VIEW), + ).toBeOnTheScreen(); + }); + }); + + describe('onHeaderHiddenChange prop', () => { + it('passes onHeaderHiddenChange callback to useFeedScrollManager', () => { + const onHeaderHiddenChange = jest.fn(); + + render(); + + expect(mockUseFeedScrollManager).toHaveBeenCalledWith( + expect.objectContaining({ onHeaderHiddenChange }), + ); + }); + + it('passes undefined to useFeedScrollManager when onHeaderHiddenChange is not provided', () => { + render(); + + expect(mockUseFeedScrollManager).toHaveBeenCalledWith( + expect.objectContaining({ onHeaderHiddenChange: undefined }), + ); + }); + }); }); diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx index 6b1af5de4ec..d8ad1825e8a 100644 --- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx +++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx @@ -604,7 +604,15 @@ const PredictSearchOverlay: React.FC = ({ ); }; -const PredictFeed: React.FC = () => { +interface PredictFeedProps { + hideHeader?: boolean; + onHeaderHiddenChange?: (hidden: boolean) => void; +} + +const PredictFeed: React.FC = ({ + hideHeader = false, + onHeaderHiddenChange, +}) => { const { tabs, activeIndex, @@ -684,6 +692,7 @@ const PredictFeed: React.FC = () => { headerRef, tabBarRef, setActiveIndex, + onHeaderHiddenChange, }); const handleTabPress = useCallback( @@ -714,27 +723,29 @@ const PredictFeed: React.FC = () => { twClassName="flex-1" style={{ backgroundColor: colors.background.default }} > - - - + {!hideHeader && ( + + + + )} + StyleSheet.create({ + flex: { + flex: 1, + }, + gradient: { + position: 'absolute', + left: 0, + right: 0, + zIndex: 1, + pointerEvents: 'none', + }, + gradientFill: { + flex: 1, + }, + }); + +export default styleSheet; diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.test.tsx b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.test.tsx new file mode 100644 index 00000000000..5e9d672772e --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.test.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import { InteractionManager, Text } from 'react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import type { SectionRefreshHandle } from '../../types'; +import HomepageDiscoveryTabs from './HomepageDiscoveryTabs'; + +jest.mock('react-native-reanimated', () => { + const Reanimated = jest.requireActual('react-native-reanimated/mock'); + Reanimated.default.ScrollView = jest.requireActual('react-native').ScrollView; + return Reanimated; +}); + +jest + .spyOn(InteractionManager, 'runAfterInteractions') + .mockImplementation((task) => { + if (task == null) { + return { cancel: jest.fn(), then: jest.fn(), done: jest.fn() }; + } + if (typeof task === 'function') { + task(); + } else { + void task.gen(); + } + return { cancel: jest.fn(), then: jest.fn(), done: jest.fn() }; + }); + +jest.mock('../../Homepage', () => { + const ReactLib = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ReactLib.forwardRef( + ( + _props: Record, + ref: React.ForwardedRef, + ) => { + ReactLib.useImperativeHandle(ref, () => ({ + refresh: jest.fn(async () => undefined), + })); + return ReactLib.createElement(View, { testID: 'homepage' }); + }, + ), + }; +}); + +jest.mock('../../../../UI/Perps/Views/PerpsHomeView/PerpsHomeView', () => { + const { View } = jest.requireActual('react-native'); + const ReactLib = jest.requireActual('react'); + return function MockPerpsHomeView({ + tabEnterCallbackRef, + }: { + tabEnterCallbackRef?: React.MutableRefObject<(() => void) | null>; + }) { + if (tabEnterCallbackRef) tabEnterCallbackRef.current = jest.fn(); + return ReactLib.createElement(View, { testID: 'perps-home-view' }); + }; +}); + +jest.mock('../../../../UI/Predict/views/PredictFeed', () => { + const { View } = jest.requireActual('react-native'); + const ReactLib = jest.requireActual('react'); + return function MockPredictFeed() { + return ReactLib.createElement(View, { testID: 'predict-feed' }); + }; +}); + +jest.mock('../../../../UI/Perps/providers/PerpsConnectionProvider', () => ({ + PerpsConnectionProvider: ({ children }: { children: React.ReactNode }) => + children, +})); + +jest.mock('../../../../UI/Perps/providers/PerpsStreamManager', () => ({ + PerpsStreamProvider: ({ children }: { children: React.ReactNode }) => + children, +})); + +jest.mock('../../../../UI/Predict/contexts', () => ({ + PredictPreviewSheetProvider: ({ children }: { children: React.ReactNode }) => + children, +})); + +jest.mock('../../../../UI/Predict/hooks/useDiscoveryScrollManager', () => ({ + useDiscoveryScrollManager: jest.fn(() => ({ + scrollHandler: jest.fn(), + onTabEnter: jest.fn(), + headerHidden: false, + })), +})); + +jest.mock('../../../../../util/theme', () => ({ + useTheme: () => ({ themeAppearance: 'dark', colors: {} }), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: () => ({}) }), +})); + +jest.mock('react-native-linear-gradient', () => 'LinearGradient'); + +const pressTab = async (label: string) => { + await act(async () => { + fireEvent.press(screen.getAllByText(label)[0]); + }); +}; + +const renderComponent = (props = {}) => + render(); + +describe('HomepageDiscoveryTabs', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('initial render', () => { + it('renders the Portfolio tab bar label', () => { + renderComponent(); + expect(screen.getByText('Portfolio')).toBeOnTheScreen(); + }); + + it('renders the Perpetuals tab bar label', () => { + renderComponent(); + expect(screen.getByText('Perpetuals')).toBeOnTheScreen(); + }); + + it('renders the Predictions tab bar label', () => { + renderComponent(); + expect(screen.getByText('Predictions')).toBeOnTheScreen(); + }); + + it('shows Portfolio content on initial mount', () => { + renderComponent(); + expect(screen.getByTestId('homepage')).toBeOnTheScreen(); + }); + }); + + describe('tab switching', () => { + it('shows Perpetuals content after pressing the Perpetuals tab', async () => { + renderComponent(); + await pressTab('Perpetuals'); + expect(screen.getByTestId('perps-home-view')).toBeOnTheScreen(); + }); + + it('shows Predictions content after pressing the Predictions tab', async () => { + renderComponent(); + await pressTab('Predictions'); + expect(screen.getByTestId('predict-feed')).toBeOnTheScreen(); + }); + + it('returns to Portfolio content after switching back', async () => { + renderComponent(); + await pressTab('Perpetuals'); + await pressTab('Portfolio'); + expect(screen.getByTestId('homepage')).toBeOnTheScreen(); + }); + }); + + describe('portfolioHeader prop', () => { + it('renders portfolioHeader inside the Portfolio tab', () => { + renderComponent({ + portfolioHeader: Header, + }); + expect(screen.getByTestId('portfolio-header')).toBeOnTheScreen(); + }); + + it('keeps portfolioHeader mounted but hidden when switching to Perpetuals', async () => { + renderComponent({ + portfolioHeader: Header, + }); + await pressTab('Perpetuals'); + // Portfolio tab is keepMounted — header stays in tree but is not accessible + // (pointerEvents="none" + display:none via twClassName="hidden") + expect(screen.queryByTestId('perps-home-view')).toBeOnTheScreen(); + }); + }); + + describe('ref / imperative handle', () => { + it('exposes a refresh method via ref', () => { + const ref = React.createRef<{ refresh: () => Promise }>(); + render(); + expect(typeof ref.current?.refresh).toBe('function'); + }); + + it('calling refresh does not throw', async () => { + const ref = React.createRef<{ refresh: () => Promise }>(); + render(); + await act(async () => { + await ref.current?.refresh(); + }); + }); + }); + + describe('walletHeaderOffset prop', () => { + it('renders without throwing when walletHeaderOffset is provided', () => { + expect(() => renderComponent({ walletHeaderOffset: 100 })).not.toThrow(); + }); + + it('renders without throwing when walletHeaderOffset is 0', () => { + expect(() => renderComponent({ walletHeaderOffset: 0 })).not.toThrow(); + }); + }); +}); diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx new file mode 100644 index 00000000000..51c2f1ff9bc --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx @@ -0,0 +1,368 @@ +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useRef, +} from 'react'; +import { Animated, StyleSheet, View } from 'react-native'; +import Reanimated, { + SharedValue, + withTiming, + Easing, +} from 'react-native-reanimated'; +import LinearGradient from 'react-native-linear-gradient'; +import TabsIconList from '../../../../../component-library/components-temp/Tabs/TabsIconList/TabsIconList'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { TabsIconListRef } from '../../../../../component-library/components-temp/Tabs/TabsIconList/TabsIconList.types'; +import Homepage from '../../Homepage'; +import PerpsHomeView from '../../../../UI/Perps/Views/PerpsHomeView/PerpsHomeView'; +import PredictFeed from '../../../../UI/Predict/views/PredictFeed'; +import { PerpsConnectionProvider } from '../../../../UI/Perps/providers/PerpsConnectionProvider'; +import { PerpsStreamProvider } from '../../../../UI/Perps/providers/PerpsStreamManager'; +import { PredictPreviewSheetProvider } from '../../../../UI/Predict/contexts'; +import { SectionRefreshHandle } from '../../types'; +import { IconName } from '../../../../../component-library/components/Icons/Icon/Icon.types'; +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './HomepageDiscoveryTabs.styles'; +import { useDiscoveryScrollManager } from '../../../../UI/Predict/hooks/useDiscoveryScrollManager'; +import { useTheme } from '../../../../../util/theme'; +import { AppThemeKey } from '../../../../../util/theme/models'; +import { TabIconAnimationContext } from '../../../../../component-library/components-temp/Tabs/TabsIconTab/TabsIconAnimationContext'; + +// Tab indices — kept as a const so future tabs can be added without renumbering. +const TAB_INDEX = { + PORTFOLIO: 0, + PERPETUALS: 1, + PREDICTIONS: 2, +} as const; + +// Static per-tab gradient color stops. Keyed by TAB_INDEX so adding a new tab +// only requires adding an entry here. +// Design spec: linear-gradient(180deg, 0%, rgba(, 0) 100%) +const TAB_GRADIENT_COLORS: Record = { + [TAB_INDEX.PORTFOLIO]: [ + 'rgba(75, 80, 92, 0.9)', + 'rgba(75, 80, 92, 0.2)', + 'transparent', + ], + [TAB_INDEX.PERPETUALS]: [ + 'rgba(25, 0, 102, 0.9)', + 'rgba(25, 0, 102, 0.2)', + 'transparent', + ], + [TAB_INDEX.PREDICTIONS]: [ + 'rgba(61, 6, 95, 0.9)', + 'rgba(61, 6, 95, 0.2)', + 'transparent', + ], +}; + +/** + * Thin wrapper that exposes a `tabLabel` prop consumed by TabsList to build + * the tab bar. The children are rendered as the tab's content. + */ +interface DiscoveryTabViewProps { + tabLabel: string; + tabIcon?: IconName; + keepMounted?: boolean; + children?: React.ReactNode; +} + +const discoveryTabViewStyles = StyleSheet.create({ root: { flex: 1 } }); + +const DiscoveryTabView: React.FC = ({ children }) => ( + {children} +); + +export interface HomepageDiscoveryTabsProps { + /** + * Content rendered above the Homepage sections inside the Portfolio tab scroll. + * Receives AccountGroupBalance, AssetDetailsActions, and Carousel from Wallet. + */ + portfolioHeader?: React.ReactNode; + /** + * Forwarded to the Portfolio tab ScrollView — used by HomepageScrollContext + * pub/sub to notify scroll subscribers without triggering re-renders. + */ + onPortfolioScroll?: () => void; + /** + * RefreshControl element for pull-to-refresh on the Portfolio tab. + */ + refreshControl?: React.ReactElement; + /** + * Combined height of the wallet header + safe area top inset, used to + * position the gradient overlay so it bleeds up into the header area. + */ + walletHeaderOffset?: number; + /** + * Reanimated SharedValue controlling vertical translation of the wallet header. + * Updated from the scroll worklet so the header hides/shows on the native thread. + */ + walletHeaderTranslateY?: SharedValue; + /** + * Height of the wallet header — used to know how far to translate it off screen. + */ + walletHeaderHeight?: number; +} + +/** + * HomepageDiscoveryTabs + * + * Hub Page Navigational Discovery Tabs (coreMCU589AbtestHubPageDiscoveryTabs). + * + * Uses the design-system TabsList which renders the TabsBar at the top and + * lazy-mounts tab content via InteractionManager — screens only initialise + * when the user first visits that tab. + * + * Tabs: + * - Portfolio: scrollable homepage sections with balance header + * - Perpetuals: PerpsHomeView wrapped in connection + stream providers + * - Predictions: PredictFeed wrapped in preview sheet provider + */ +const HomepageDiscoveryTabs = forwardRef< + SectionRefreshHandle, + HomepageDiscoveryTabsProps +>( + ( + { + portfolioHeader, + onPortfolioScroll, + refreshControl, + walletHeaderOffset = 0, + walletHeaderTranslateY, + walletHeaderHeight = 0, + }, + ref, + ) => { + const tabsRef = useRef(null); + const homepageRef = useRef(null); + const perpsTabEnterRef = useRef<(() => void) | null>(null); + const tw = useTailwind(); + const { styles } = useStyles(styleSheet, {}); + const { themeAppearance } = useTheme(); + const isDarkMode = themeAppearance === AppThemeKey.dark; + // One Animated.Value per tab — pre-rendered at mount so no re-render is needed + // during a tab switch. Portfolio starts fully visible; others start at 0. + const tabGradientOpacities = useRef( + Object.keys(TAB_GRADIENT_COLORS).map( + (_, i) => new Animated.Value(i === TAB_INDEX.PORTFOLIO ? 1 : 0), + ), + ).current; + const activeTabIndexRef = useRef(TAB_INDEX.PORTFOLIO); + + // 0 = icons expanded (header visible), 1 = icons collapsed (header hidden) + const iconCollapseAnim = useRef(new Animated.Value(0)).current; + // Ref so the animated reaction closure always calls the latest animation starter + const iconCollapseAnimRef = useRef(iconCollapseAnim); + + // Drives TabsBar height collapse on the Predictions tab only (useNativeDriver: false) + const tabBarCollapseAnim = useRef(new Animated.Value(0)).current; + const tabBarCollapseAnimRef = useRef(tabBarCollapseAnim); + + // Triggered directly from the scroll worklet via onHeaderHiddenChange — + // fires in the same frame as the hide/show decision, not based on position. + const animateIcons = useCallback((hidden: boolean) => { + const toValue = hidden ? 1 : 0; + const duration = hidden ? 300 : 250; + + Animated.timing(iconCollapseAnimRef.current, { + toValue, + duration, + useNativeDriver: true, + }).start(); + + if (activeTabIndexRef.current === TAB_INDEX.PREDICTIONS) { + Animated.timing(tabBarCollapseAnimRef.current, { + toValue, + duration, + useNativeDriver: false, + }).start(); + } + }, []); + + const { scrollHandler, onTabEnter: portfolioOnTabEnter } = + useDiscoveryScrollManager({ + walletHeaderHeight, + walletHeaderTranslateY, + onPortfolioScroll, + onHeaderHiddenChange: animateIcons, + }); + + useImperativeHandle(ref, () => ({ + refresh: async () => { + await homepageRef.current?.refresh(); + }, + })); + + const handleChangeTab = useCallback( + ({ i }: { i: number }) => { + const prevIndex = activeTabIndexRef.current; + activeTabIndexRef.current = i; + + // Restore each tab's own header state on entry. + // Predictions has no scroll manager so we always show the header. + if (i === TAB_INDEX.PORTFOLIO) { + portfolioOnTabEnter(); + } else if (i === TAB_INDEX.PERPETUALS) { + if (perpsTabEnterRef.current) { + perpsTabEnterRef.current(); + } else { + // First visit — Perps not mounted yet so ref is null. It will mount + // at the top of scroll, so show the header/icons immediately. + walletHeaderTranslateY && + (walletHeaderTranslateY.value = withTiming(0, { + duration: 250, + easing: Easing.out(Easing.cubic), + })); + Animated.timing(iconCollapseAnimRef.current, { + toValue: 0, + duration: 250, + useNativeDriver: true, + }).start(); + } + } else { + // Predictions has no scroll manager — always show header + icons on entry. + walletHeaderTranslateY && + (walletHeaderTranslateY.value = withTiming(0, { + duration: 250, + easing: Easing.out(Easing.cubic), + })); + Animated.timing(iconCollapseAnimRef.current, { + toValue: 0, + duration: 250, + useNativeDriver: true, + }).start(); + } + + // Reset tab bar collapse when leaving Predictions + if ( + prevIndex === TAB_INDEX.PREDICTIONS && + i !== TAB_INDEX.PREDICTIONS + ) { + Animated.timing(tabBarCollapseAnimRef.current, { + toValue: 0, + duration: 250, + useNativeDriver: false, + }).start(); + } + + if (prevIndex !== i) { + // Snap outgoing to 1 and incoming to 0 before animating, in case a + // previous transition was interrupted mid-flight. + tabGradientOpacities[prevIndex].setValue(1); + tabGradientOpacities[i].setValue(0); + + Animated.parallel([ + Animated.timing(tabGradientOpacities[prevIndex], { + toValue: 0, + duration: 350, + useNativeDriver: true, + }), + Animated.timing(tabGradientOpacities[i], { + toValue: 1, + duration: 350, + useNativeDriver: true, + }), + ]).start(); + } + }, + [tabGradientOpacities, portfolioOnTabEnter, walletHeaderTranslateY], + ); + + return ( + + + + + + {portfolioHeader} + + + + + + + + + + + + + + + + + + + + {/* Gradient overlay — dark mode only. One layer per tab, each always mounted + with fixed colors. Crossfade is pure opacity animation on the native thread — + no state update or unmount/remount during the transition. + Outer wrapper fades the entire gradient out when the header/icons collapse. */} + {walletHeaderOffset > 0 && + isDarkMode && + Object.entries(TAB_GRADIENT_COLORS).map(([idx, colors]) => ( + + + + ))} + + + ); + }, +); + +HomepageDiscoveryTabs.displayName = 'HomepageDiscoveryTabs'; + +export default HomepageDiscoveryTabs; diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryTabs/index.ts b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/index.ts new file mode 100644 index 00000000000..822f80a9a58 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/index.ts @@ -0,0 +1 @@ +export { default } from './HomepageDiscoveryTabs'; diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx index 2aad1303f3e..1a0faedde64 100644 --- a/app/components/Views/Wallet/index.test.tsx +++ b/app/components/Views/Wallet/index.test.tsx @@ -91,6 +91,31 @@ jest.mock('../../../selectors/featureFlagController/homepage', () => ({ selectHomepageSectionsV1Enabled: jest.fn(() => mockHomepageSectionsEnabled), })); +// Control discovery tabs AB test variant per test (default control so existing tests are unaffected) +let mockDiscoveryTabsVariantName = 'control'; +jest.mock('../../../hooks', () => ({ + ...jest.requireActual('../../../hooks'), + useABTest: jest.fn(() => ({ + variantName: mockDiscoveryTabsVariantName, + variant: { + discoveryTabsEnabled: mockDiscoveryTabsVariantName === 'treatment', + }, + })), +})); + +// Track HomepageDiscoveryTabs renders +const mockHomepageDiscoveryTabs = jest.fn(); +jest.mock('../Homepage/components/HomepageDiscoveryTabs', () => { + const React = jest.requireActual('react'); + return { + __esModule: true, + default: React.forwardRef((props: unknown, _ref: unknown) => { + mockHomepageDiscoveryTabs(props); + return null; + }), + }; +}); + // Capture the HomepageScrollContext value by rendering a context-aware mock Homepage. // The mock is only invoked when mockHomepageSectionsEnabled=true (sections flag on), // so existing tests that leave the flag false are completely unaffected. @@ -1606,6 +1631,144 @@ describe('HomepageScrollContext callbacks', () => { }); }); +describe('HomepageDiscoveryTabs AB test', () => { + let mockNavigation: NavigationProp; + + beforeEach(() => { + jest.clearAllMocks(); + mockHomepageSectionsEnabled = true; + mockDiscoveryTabsVariantName = 'control'; + mockHomepageDiscoveryTabs.mockClear(); + + mockNavigation = { + navigate: mockNavigate, + setOptions: mockSetOptions, + addListener: jest.fn(() => jest.fn()), + isFocused: jest.fn(() => false), + dangerouslyGetParent: jest.fn(() => ({ + dangerouslyGetState: jest.fn(() => ({ type: 'stack' })), + addListener: jest.fn(() => jest.fn()), + dangerouslyGetParent: jest.fn(() => ({ + dangerouslyGetState: jest.fn(() => ({ type: 'tab' })), + addListener: jest.fn(() => jest.fn()), + dangerouslyGetParent: jest.fn(() => undefined), + })), + })), + } as unknown as NavigationProp; + + jest + .mocked(useSelector) + .mockImplementation((callback: (state: unknown) => unknown) => + callback(mockInitialState), + ); + }); + + afterEach(() => { + mockHomepageSectionsEnabled = false; + mockDiscoveryTabsVariantName = 'control'; + jest.clearAllMocks(); + }); + + it('renders HomepageDiscoveryTabs when variant is treatment and sections flag is on', () => { + mockDiscoveryTabsVariantName = 'treatment'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockHomepageDiscoveryTabs).toHaveBeenCalled(); + }); + + it('does not render HomepageDiscoveryTabs when variant is control', () => { + mockDiscoveryTabsVariantName = 'control'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockHomepageDiscoveryTabs).not.toHaveBeenCalled(); + }); + + it('passes portfolioHeader, onPortfolioScroll, and refreshControl to HomepageDiscoveryTabs', () => { + mockDiscoveryTabsVariantName = 'treatment'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + const props = mockHomepageDiscoveryTabs.mock.calls.at(-1)?.[0] as Record< + string, + unknown + >; + expect(props).toBeDefined(); + expect(props.portfolioHeader).toBeDefined(); + expect(typeof props.onPortfolioScroll).toBe('function'); + expect(props.refreshControl).toBeDefined(); + }); + + it('passes walletHeaderOffset and walletHeaderHeight to HomepageDiscoveryTabs', () => { + mockDiscoveryTabsVariantName = 'treatment'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + const props = mockHomepageDiscoveryTabs.mock.calls.at(-1)?.[0] as Record< + string, + unknown + >; + expect(typeof props.walletHeaderOffset).toBe('number'); + expect(typeof props.walletHeaderHeight).toBe('number'); + }); + + it('renders Homepage scroll view (not HomepageDiscoveryTabs) when variant is control and sections flag is on', () => { + mockDiscoveryTabsVariantName = 'control'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + // HomepageDiscoveryTabs must not render; the legacy Homepage mock renders instead + expect(mockHomepageDiscoveryTabs).not.toHaveBeenCalled(); + expect(capturedContext).toBeDefined(); + }); + + it('does not render HomepageDiscoveryTabs when sections flag is off regardless of variant', () => { + mockHomepageSectionsEnabled = false; + mockDiscoveryTabsVariantName = 'treatment'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockHomepageDiscoveryTabs).not.toHaveBeenCalled(); + }); +}); + describe('useHomeDeepLinkEffects', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index d977b85e1bc..ab5d51d2456 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -22,7 +22,14 @@ import { StyleSheet as RNStyleSheet, View, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { + SafeAreaView, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; +import Reanimated, { + useSharedValue, + useAnimatedStyle, +} from 'react-native-reanimated'; import { connect, useDispatch, useSelector } from 'react-redux'; import { strings } from '../../../../locales/i18n'; import { @@ -133,6 +140,13 @@ import { Hex } from '@metamask/utils'; import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; import { selectHomepageSectionsV1Enabled } from '../../../selectors/featureFlagController/homepage'; import Homepage from '../Homepage'; +import HomepageDiscoveryTabs from '../Homepage/components/HomepageDiscoveryTabs'; +import { + HUB_PAGE_DISCOVERY_TABS_AB_KEY, + HUB_PAGE_DISCOVERY_TABS_VARIANTS, + HubPageDiscoveryTabsVariant, +} from '../Homepage/abTestConfig'; +import { useABTest } from '../../../hooks'; import { SectionRefreshHandle } from '../Homepage/types'; import { HomepageScrollContext } from '../Homepage/context/HomepageScrollContext'; import type { HomeSectionName } from '../Homepage/hooks/useHomeViewedEvent'; @@ -226,6 +240,13 @@ const createStyles = ({ colors }: Theme) => }, headerAccountPickerStyle: { marginRight: 16, + backgroundColor: 'transparent', + }, + accountGroupBalanceContainer: { + marginBottom: 16, + }, + walletHeaderRoot: { + zIndex: 2, }, }); @@ -596,6 +617,10 @@ const Wallet = ({ // ─── Homepage scroll context state ─────────────────────────────────────── const [viewportHeight, setViewportHeight] = useState(0); const [containerScreenY, setContainerScreenY] = useState(0); + const [headerHeight, setHeaderHeight] = useState(0); + const sharedHeaderHeight = useSharedValue(0); + const walletHeaderTranslateY = useSharedValue(0); + const insets = useSafeAreaInsets(); const { entryPoint, visitId } = useHomepageEntryPoint(navigation); // Ref to the scroll container View — used to measure its absolute screen Y @@ -1016,6 +1041,25 @@ const Wallet = ({ selectHomepageSectionsV1Enabled, ); + const { variantName: discoveryTabsVariantName } = useABTest( + HUB_PAGE_DISCOVERY_TABS_AB_KEY, + HUB_PAGE_DISCOVERY_TABS_VARIANTS, + ); + + const isDiscoveryTabsTreatment = + discoveryTabsVariantName === HubPageDiscoveryTabsVariant.Treatment; + + // translateY slides the header up; negative marginBottom collapses the layout + // space it occupied so the content below moves up in sync. + const animatedHeaderStyle = useAnimatedStyle(() => { + const h = sharedHeaderHeight.value; + return { + transform: [{ translateY: walletHeaderTranslateY.value }], + marginBottom: walletHeaderTranslateY.value, + opacity: h > 0 ? Math.max(0, 1 + walletHeaderTranslateY.value / h) : 1, + }; + }); + const isFocused = useIsFocused(); const homepageRef = useRef(null); @@ -1301,62 +1345,91 @@ const Wallet = ({ ], ); - const content = ( + const bannerContent = ( + + {!basicFunctionalityEnabled ? ( + + {strings('wallet.banner.link')} + + } + /> + ) : null} + + + ); + + const portfolioHeaderBase = ( <> - - {!basicFunctionalityEnabled ? ( - - {strings('wallet.banner.link')} - - } - /> - ) : null} - - - <> - + {bannerContent} + + + {isCarouselBannersEnabled && } + + ); - + const portfolioHeader = ( + <> + {bannerContent} + + + + + {isCarouselBannersEnabled && } + + ); - {isCarouselBannersEnabled && } - - {isHomepageSectionsV1Enabled ? ( - <> - {isFocused && } - - - - - ) : ( - <> - {isFocused && } - - - )} - + // Legacy scroll view content — used only when the sections redesign is off. + const content = ( + <> + {bannerContent} + + + {isCarouselBannersEnabled && } + {isFocused && } + ); const renderLoader = useCallback( @@ -1381,49 +1454,84 @@ const Wallet = ({ > {selectedInternalAccount ? ( <> - - - {isMoneyHomeScreenEnabled && ( - - )} - - - - - {isNotificationsFeatureEnabled() ? ( - + { + const h = e.nativeEvent.layout.height; + if (h > 0) { + setHeaderHeight(h); + sharedHeaderHeight.value = h; } - badge={ - isNotificationEnabled && - unreadNotificationCount > 0 ? ( - - ) : null + } + : undefined + } + testID={WalletViewSelectorsIDs.WALLET_HEADER_ROOT} + style={undefined} + endAccessory={ + + + {isMoneyHomeScreenEnabled && ( + + )} + + + + + {isNotificationsFeatureEnabled() ? ( + 0 ? ( + + ) : null + } + > + + + ) : ( - - ) : ( - - )} + )} + - - } - twClassName="pl-1 pr-3" - > - - navigation.navigate(...createAccountSelectorNavDetails({})) } - testID={WalletViewSelectorsIDs.ACCOUNT_ICON} - hitSlop={touchAreaSlop} - style={styles.headerAccountPickerStyle} - /> - + twClassName="pl-1 pr-3" + > + + navigation.navigate( + ...createAccountSelectorNavDetails({}), + ) + } + testID={WalletViewSelectorsIDs.ACCOUNT_ICON} + hitSlop={touchAreaSlop} + style={styles.headerAccountPickerStyle} + /> + + - - ), - }} - > - {content} - + {isHomepageSectionsV1Enabled ? ( + <> + {isFocused && ( + + )} + + {isDiscoveryTabsTreatment ? ( + + } + /> + ) : ( + + ), + }} + > + {portfolioHeaderBase} + + + )} + + + ) : ( + + ), + }} + > + {content} + + )} ) : ( From f0c0bf8acdf69f8206e379bb27e99908be7d6f91 Mon Sep 17 00:00:00 2001 From: Cal Leung Date: Wed, 6 May 2026 10:33:08 -0700 Subject: [PATCH 27/27] chore: Remove redundant social keys (#29773) ## **Description** This is part of the effort to reduce the amount of GH repo secrets. Remove redundant social auth related keys and unused qa build configs. ``` MAIN_IOS_GOOGLE_CLIENT_ID_UAT MAIN_IOS_GOOGLE_REDIRECT_URI_UAT MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT MAIN_ANDROID_APPLE_CLIENT_ID_UAT ``` ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk because this only removes unused/legacy QA build entries and redundant OAuth-related secret wiring in CI/build scripts; main/flask build paths and canonical secret names remain unchanged. Risk is limited to any external/legacy pipeline still depending on the deleted QA build names or old secret aliases. > > **Overview** > **Removes legacy QA build support** by deleting the `qa-prod`/`qa-dev` entries from `builds.yml` and dropping them from the `build.yml` dispatch options. > > **Simplifies CI secret wiring** by removing the unused `MAIN_*_UAT` and `FLASK_*_PROD` OAuth/social env vars from E2E build/test workflows (`build-android-e2e.yml`, `build-ios-e2e.yml`, `run-e2e-workflow.yml`, `run-e2e-api-specs.yml`, `update-e2e-fixtures.yml`) and deleting the corresponding remap logic from `scripts/build.sh`. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f238c9a99ba2f42ed27ce4ac005495cdf16337b3. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .github/workflows/build-android-e2e.yml | 20 ------- .github/workflows/build-ios-e2e.yml | 15 ----- .github/workflows/build.yml | 2 - .github/workflows/run-e2e-api-specs.yml | 2 - .github/workflows/run-e2e-workflow.yml | 5 -- .github/workflows/update-e2e-fixtures.yml | 5 -- builds.yml | 66 --------------------- scripts/build.sh | 71 ----------------------- 8 files changed, 186 deletions(-) diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 85de7d34ce5..f39ecff8995 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -207,16 +207,6 @@ jobs: SEGMENT_REGULATIONS_ENDPOINT_FLASK: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_FLASK }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - FLASK_IOS_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_IOS_GOOGLE_CLIENT_ID_PROD }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - FLASK_IOS_GOOGLE_REDIRECT_URI_PROD: ${{ secrets.FLASK_IOS_GOOGLE_REDIRECT_URI_PROD }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - FLASK_ANDROID_APPLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_APPLE_CLIENT_ID_PROD }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} - FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} @@ -258,16 +248,6 @@ jobs: SEGMENT_REGULATIONS_ENDPOINT_FLASK: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_FLASK }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - FLASK_IOS_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_IOS_GOOGLE_CLIENT_ID_PROD }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - FLASK_IOS_GOOGLE_REDIRECT_URI_PROD: ${{ secrets.FLASK_IOS_GOOGLE_REDIRECT_URI_PROD }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - FLASK_ANDROID_APPLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_APPLE_CLIENT_ID_PROD }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} - FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index 8c233861ff7..aa2296a5406 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -60,11 +60,6 @@ jobs: SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} @@ -194,11 +189,6 @@ jobs: MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} @@ -232,11 +222,6 @@ jobs: SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 556bc59a3e4..4d48f758bed 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,8 +68,6 @@ on: - flask-test - flask-e2e - flask-dev - - qa-prod - - qa-dev platform: required: true type: choice diff --git a/.github/workflows/run-e2e-api-specs.yml b/.github/workflows/run-e2e-api-specs.yml index 94c5edfbb81..f90fa6ad24b 100644 --- a/.github/workflows/run-e2e-api-specs.yml +++ b/.github/workflows/run-e2e-api-specs.yml @@ -23,8 +23,6 @@ jobs: SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SOLANA_E2E_TEST_SRP: ${{ secrets.MM_SOLANA_E2E_TEST_SRP }} diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml index cf7e218be2a..1cc5a894949 100644 --- a/.github/workflows/run-e2e-workflow.yml +++ b/.github/workflows/run-e2e-workflow.yml @@ -84,11 +84,6 @@ jobs: SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/update-e2e-fixtures.yml b/.github/workflows/update-e2e-fixtures.yml index 32c08c238c6..a9c652ff3d6 100644 --- a/.github/workflows/update-e2e-fixtures.yml +++ b/.github/workflows/update-e2e-fixtures.yml @@ -199,11 +199,6 @@ jobs: SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} diff --git a/builds.yml b/builds.yml index b47b2479f59..c08efa2c42c 100644 --- a/builds.yml +++ b/builds.yml @@ -428,69 +428,3 @@ builds: DEV_OAUTH_CONFIG: 'true' secrets: *secrets code_fencing: *code_fencing_flask - - # ───────────────────────────────────────────────────────────────────────────── - # QA Builds (legacy - for internal testing) - # ───────────────────────────────────────────────────────────────────────────── - - # QA production build - qa-prod: - github_environment: build-uat - signing: *signing_uat - env: - <<: *public_envs - METAMASK_ENVIRONMENT: 'production' - METAMASK_BUILD_TYPE: 'qa' - secrets: - <<: *secrets - # QA uses QA Segment project - SEGMENT_WRITE_KEY: SEGMENT_WRITE_KEY_QA - SEGMENT_PROXY_URL: SEGMENT_PROXY_URL_QA - SEGMENT_DELETE_API_SOURCE_ID: SEGMENT_DELETE_API_SOURCE_ID_QA - SEGMENT_REGULATIONS_ENDPOINT: SEGMENT_REGULATIONS_ENDPOINT_QA - # QA uses UAT OAuth credentials - IOS_GOOGLE_CLIENT_ID: MAIN_IOS_GOOGLE_CLIENT_ID_UAT - IOS_GOOGLE_REDIRECT_URI: MAIN_IOS_GOOGLE_REDIRECT_URI_UAT - ANDROID_GOOGLE_CLIENT_ID: MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT - ANDROID_APPLE_CLIENT_ID: MAIN_ANDROID_APPLE_CLIENT_ID_UAT - ANDROID_GOOGLE_SERVER_CLIENT_ID: MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT - MM_CARD_BAANX_API_CLIENT_KEY: MM_CARD_BAANX_API_CLIENT_KEY_UAT - code_fencing: *code_fencing_main - - # QA dev build / Expo development build (Runway .app for simulator) - qa-dev: - github_environment: build-uat - env: - <<: *public_envs - METAMASK_ENVIRONMENT: 'dev' - METAMASK_BUILD_TYPE: 'qa' - MM_PORTFOLIO_URL: 'https://portfolio.dev-api.cx.metamask.io' - MM_PREDICT_BUILDER_CODE: '0xd48300a99deac0f23265dad1f59d32920be33b75919201a88ed80f457f97b924' - REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' - BAANX_API_URL: 'https://dev.api.baanx.com' - DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' - SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io' - BRIDGE_USE_DEV_APIS: 'true' - RAMPS_ENVIRONMENT: 'staging' - RAMP_INTERNAL_BUILD: 'true' - RAMP_DEV_BUILD: 'true' - IS_TEST: 'false' - MM_ENABLE_SETTINGS_PAGE_DEV_OPTIONS: 'true' - IS_SIM_BUILD: 'true' - CONFIGURATION: 'Debug' - DEV_OAUTH_CONFIG: 'true' - secrets: - <<: *secrets - # QA uses QA Segment project - SEGMENT_WRITE_KEY: SEGMENT_WRITE_KEY_QA - SEGMENT_PROXY_URL: SEGMENT_PROXY_URL_QA - SEGMENT_DELETE_API_SOURCE_ID: SEGMENT_DELETE_API_SOURCE_ID_QA - SEGMENT_REGULATIONS_ENDPOINT: SEGMENT_REGULATIONS_ENDPOINT_QA - # QA uses UAT OAuth credentials - IOS_GOOGLE_CLIENT_ID: MAIN_IOS_GOOGLE_CLIENT_ID_UAT - IOS_GOOGLE_REDIRECT_URI: MAIN_IOS_GOOGLE_REDIRECT_URI_UAT - ANDROID_GOOGLE_CLIENT_ID: MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT - ANDROID_APPLE_CLIENT_ID: MAIN_ANDROID_APPLE_CLIENT_ID_UAT - ANDROID_GOOGLE_SERVER_CLIENT_ID: MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT - MM_CARD_BAANX_API_CLIENT_KEY: MM_CARD_BAANX_API_CLIENT_KEY_UAT - code_fencing: *code_fencing_main diff --git a/scripts/build.sh b/scripts/build.sh index 0e0d56c466f..b8dda7bf595 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -246,15 +246,7 @@ remapEnvVariableQA() { remapEnvVariable "SEGMENT_PROXY_URL_QA" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "MAIN_IOS_GOOGLE_CLIENT_ID_UAT" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_IOS_GOOGLE_REDIRECT_URI_UAT" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "MAIN_ANDROID_APPLE_CLIENT_ID_UAT" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_UAT" "MM_CARD_BAANX_API_CLIENT_KEY" - } # Mapping for Main env variables in the e2e environment @@ -264,13 +256,6 @@ remapMainE2EEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_QA" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "MAIN_IOS_GOOGLE_CLIENT_ID_UAT" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_IOS_GOOGLE_REDIRECT_URI_UAT" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "MAIN_ANDROID_APPLE_CLIENT_ID_UAT" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_UAT" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -281,13 +266,6 @@ remapMainTestEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_QA" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "MAIN_IOS_GOOGLE_CLIENT_ID_UAT" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_IOS_GOOGLE_REDIRECT_URI_UAT" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "MAIN_ANDROID_APPLE_CLIENT_ID_UAT" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_UAT" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -298,13 +276,6 @@ remapMainProdEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_PROD" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_PROD" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_PROD" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "MAIN_IOS_GOOGLE_CLIENT_ID_PROD" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_IOS_GOOGLE_REDIRECT_URI_PROD" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "MAIN_ANDROID_APPLE_CLIENT_ID_PROD" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_CLIENT_ID_PROD" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_PROD" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -315,13 +286,6 @@ remapFlaskProdEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_FLASK" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_FLASK" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_FLASK" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "FLASK_IOS_GOOGLE_CLIENT_ID_PROD" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "FLASK_IOS_GOOGLE_REDIRECT_URI_PROD" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "FLASK_ANDROID_APPLE_CLIENT_ID_PROD" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_PROD" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -332,13 +296,6 @@ remapFlaskTestEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_QA" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "FLASK_IOS_GOOGLE_CLIENT_ID_PROD" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "FLASK_IOS_GOOGLE_REDIRECT_URI_PROD" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "FLASK_ANDROID_APPLE_CLIENT_ID_PROD" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_UAT" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -349,13 +306,6 @@ remapFlaskE2EEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_QA" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "FLASK_IOS_GOOGLE_CLIENT_ID_PROD" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "FLASK_IOS_GOOGLE_REDIRECT_URI_PROD" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "FLASK_ANDROID_APPLE_CLIENT_ID_PROD" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_UAT" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -366,13 +316,6 @@ remapMainBetaEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_BETA" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "MAIN_IOS_GOOGLE_CLIENT_ID_PROD" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_IOS_GOOGLE_REDIRECT_URI_PROD" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "MAIN_ANDROID_APPLE_CLIENT_ID_PROD" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_CLIENT_ID_PROD" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_PROD" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -383,13 +326,6 @@ remapMainReleaseCandidateEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_QA" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "MAIN_IOS_GOOGLE_CLIENT_ID_PROD" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_IOS_GOOGLE_REDIRECT_URI_PROD" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "MAIN_ANDROID_APPLE_CLIENT_ID_PROD" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_CLIENT_ID_PROD" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_PROD" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -401,13 +337,6 @@ remapMainExperimentalEnvVariables() { remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" remapEnvVariable "MAIN_WEB3AUTH_NETWORK_PROD" "WEB3AUTH_NETWORK" - - remapEnvVariable "MAIN_IOS_GOOGLE_CLIENT_ID_UAT" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_IOS_GOOGLE_REDIRECT_URI_UAT" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "MAIN_ANDROID_APPLE_CLIENT_ID_UAT" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_UAT" "MM_CARD_BAANX_API_CLIENT_KEY" }