From e546e1f171ae5680e6674d1d5e5b25a194733efc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Thu, 28 May 2026 19:37:32 +0200 Subject: [PATCH 1/7] refactor(predictions): introduce useHomepagePredictWorldCupMarkets hook and update related components (#30735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Homepage Predict empty-state World Cup discovery was loading markets through a separate path (useHomepagePredictTaggedMarkets → category: 'sports' + hardcoded tag_id=102350), while the dedicated World Cup screen uses usePredictWorldCupMarkets with the ALL tab (buildPredictWorldCupAllQuery → events/keyset?tag_slug=...). This PR aligns the homepage feed with the World Cup screen by introducing useHomepagePredictWorldCupMarkets, which wraps usePredictWorldCupMarkets with PREDICT_WORLD_CUP_TAB_KEYS.ALL and selectPredictWorldCupConfig. The NBA champion discovery feed is unchanged and still uses useHomepagePredictTaggedMarkets. Why: Avoid divergent Polymarket query logic between homepage discovery and the World Cup screen. How: Thin homepage hook + swap the World Cup feed in PredictionsSection; remove the obsolete PREDICT_HOME_WORLD_CUP_TAG_QUERY constant; update types and test mocks. ## **Changelog** CHANGELOG entry: Fix bug that was causing wrong events number in world cup (predict) section ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Homepage Predict empty state World Cup discovery Scenario: World Cup discovery loads when user has no positions Given Predict is enabled And the user is in the Predict positions empty state AB treatment And the user has no open Predict positions When the user opens the Homepage Predict section Then the World Cup discovery rail renders (event count, bracket pills, championship row) And World Cup markets are fetched via the World Cup ALL-tab keyset query (tag_slug from feature flag config) Scenario: World Cup discovery navigation still works Given the World Cup discovery rail is visible on Homepage When the user taps the men's World Cup row or a bracket pill Then navigation opens the World Cup screen (or Predict market list fallback) with the expected initial tab Scenario: NBA champion row is unaffected Given NBA champion homepage discovery is enabled When the user opens the Homepage Predict empty state Then the NBA champion row still loads via the existing tagged-markets hook ``` ## **Screenshots/Recordings** ### **Before** ### **After** Screenshot 2026-05-28 at 13 26 58 ## **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 Polymarket fetch logic for a user-visible homepage discovery rail (fixes wrong event counts) but is scoped to Predict UI with tests updated; NBA feed unchanged. > > **Overview** > Homepage Predict **empty-state World Cup discovery** no longer loads markets through `useHomepagePredictTaggedMarkets` with a hardcoded sports `tag_id=102350`. It now uses a new **`useHomepagePredictWorldCupMarkets`** hook that wraps **`usePredictWorldCupMarkets`** on the World Cup **ALL** tab with **`selectPredictWorldCupConfig`**, matching the dedicated World Cup screen’s keyset/`tag_slug` query path. > > **`PredictionsSection`** and the World Cup discovery UI types were switched to that hook; the obsolete World Cup tag constant was removed from **`HOMEPAGE_PREDICT_TAG_QUERIES`**. **NBA champion** discovery still uses tagged markets. Tests and Homepage mocks were updated for the new hook and World Cup feature-flag/hook stubs. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c053706c35da986f11e50509fdc1c05c7f3a4120. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../Views/Homepage/Homepage.test.tsx | 57 +++++++++++++++++-- .../Predictions/PredictionsSection.test.tsx | 7 +-- .../Predictions/PredictionsSection.tsx | 6 +- .../HomepagePredictTrendingMarkets.tsx | 3 +- .../index.tsx | 3 +- .../Sections/Predictions/hooks/index.ts | 1 + .../hooks/useHomepagePredictTaggedMarkets.ts | 4 -- .../useHomepagePredictWorldCupMarkets.ts | 28 +++++++++ 8 files changed, 90 insertions(+), 19 deletions(-) create mode 100644 app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.ts diff --git a/app/components/Views/Homepage/Homepage.test.tsx b/app/components/Views/Homepage/Homepage.test.tsx index cd4e3362adb..4e9ff5502b7 100644 --- a/app/components/Views/Homepage/Homepage.test.tsx +++ b/app/components/Views/Homepage/Homepage.test.tsx @@ -107,11 +107,20 @@ jest.mock('../../UI/Perps', () => ({ })); jest.mock('../../UI/Perps/providers/PerpsConnectionProvider', () => { - const actual = jest.requireActual( - '../../UI/Perps/providers/PerpsConnectionProvider', - ); + const ReactLib = jest.requireActual('react'); + const PerpsConnectionContext = ReactLib.createContext({ + isConnected: true, + isConnecting: false, + isInitialized: true, + error: null, + connect: jest.fn(), + disconnect: jest.fn(), + resetError: jest.fn(), + reconnectWithNewContext: jest.fn().mockResolvedValue(undefined), + }); + return { - ...actual, + PerpsConnectionContext, PerpsConnectionProvider: ({ children }: { children: React.ReactNode }) => children, }; @@ -190,6 +199,46 @@ jest.mock('../../UI/NftGrid/NftGridItemBottomSheet', () => () => null); jest.mock('../../UI/Predict/selectors/featureFlags', () => ({ selectPredictEnabledFlag: jest.fn(() => true), + selectPredictWorldCupConfig: jest.fn(() => ({ + enabled: false, + minimumVersion: '', + showMainFeedBanner: false, + showMainFeedTab: false, + showWorldCupScreen: false, + seriesId: '10218', + tagSlug: 'fifa-world-cup', + gamesTagId: '100639', + stages: [], + })), + selectPredictWorldCupScreenEnabledFlag: jest.fn(() => false), + selectPredictHomepageDiscoveryNbaChampionEnabledFlag: jest.fn(() => false), +})); + +jest.mock('../../UI/Predict/hooks/usePredictWorldCup', () => ({ + usePredictWorldCupMarkets: () => ({ + marketData: [], + isFetching: false, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: jest.fn().mockResolvedValue(undefined), + fetchMore: jest.fn().mockResolvedValue(undefined), + }), + usePredictWorldCupAvailability: () => ({ + availability: { live: false, props: false, stages: {} }, + isFetching: false, + isLoading: false, + errors: [], + refetch: jest.fn(), + }), + usePredictWorldCupAvailableTabs: () => ({ + availability: { live: false, props: false, stages: {} }, + tabs: [], + isFetching: false, + isLoading: false, + errors: [], + refetch: jest.fn(), + }), })); jest.mock('@tanstack/react-query', () => { diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx index 15a29a0faf2..e12ba28a854 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx @@ -184,12 +184,8 @@ jest.mock('@tanstack/react-query', () => { jest.mock('./hooks', () => { const actual = jest.requireActual('./hooks') as Record; const tagQueries = actual.HOMEPAGE_PREDICT_TAG_QUERIES as { - worldCup: string; nbaChampion: string; }; - // Two distinct jest mocks under the hood so tests can target each feed - // independently (`.mockReturnValue(...)` on either still works); the - // consolidated `useHomepagePredictTaggedMarkets` dispatches by tag query. const worldCupMock = jest.fn(() => worldCupMarketsWithDiscoveryChampionship(), ); @@ -210,11 +206,12 @@ jest.mock('./hooks', () => { error: null, refetch: jest.fn(), })), + useHomepagePredictWorldCupMarkets: worldCupMock, useHomepagePredictTaggedMarkets: jest.fn( ({ customQueryParams }: { customQueryParams: string }) => customQueryParams === tagQueries.nbaChampion ? nbaMock() - : worldCupMock(), + : worldCupHomepageMarketsMock([]), ), __mockUsePredictWorldCupHomepageMarkets: worldCupMock, __mockUsePredictNbaChampionHomepageMarkets: nbaMock, diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx index 56a574e9661..e5abacae766 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx @@ -25,6 +25,7 @@ import { usePredictMarketsForHomepage, usePredictPositionsForHomepage, useHomepagePredictTaggedMarkets, + useHomepagePredictWorldCupMarkets, HOMEPAGE_PREDICT_TAG_QUERIES, usePredictHomepageDiscoveryExperiment, } from './hooks'; @@ -52,10 +53,7 @@ import type { TransactionActiveAbTestEntry } from '../../../../../util/transacti /** Loads both feeds the World Cup discovery rail needs (World Cup tag + NBA Champion event). */ const useWorldCupDiscoveryFeeds = (enabled: boolean) => ({ - worldCup: useHomepagePredictTaggedMarkets({ - enabled, - customQueryParams: HOMEPAGE_PREDICT_TAG_QUERIES.worldCup, - }), + worldCup: useHomepagePredictWorldCupMarkets({ enabled }), nbaChampion: useHomepagePredictTaggedMarkets({ enabled, customQueryParams: HOMEPAGE_PREDICT_TAG_QUERIES.nbaChampion, diff --git a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx index c55cbed27dc..e30838f1da2 100644 --- a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { PredictMarket } from '../../../../../UI/Predict/types'; import type { TransactionActiveAbTestEntry } from '../../../../../../util/transactions/transaction-active-ab-test-attribution-registry'; +import type { UseHomepagePredictWorldCupMarketsResult } from '../hooks/useHomepagePredictWorldCupMarkets'; import type { UseHomepagePredictTaggedMarketsResult } from '../hooks/useHomepagePredictTaggedMarkets'; import type { PredictionsTrendingHeaderTestId } from '../predictionsSectionTypes'; import type { PredictEmptyStateCtaName } from '../../../abTestConfig'; @@ -18,7 +19,7 @@ export interface HomepagePredictTrendingMarketsProps { markets: PredictMarket[]; transactionActiveAbTests?: TransactionActiveAbTestEntry[]; /** Required when `discoveryLayout` is `list` (World Cup discovery rail). */ - worldCupHomepage?: UseHomepagePredictTaggedMarketsResult; + worldCupHomepage?: UseHomepagePredictWorldCupMarketsResult; /** Required when `discoveryLayout` is `list` (NBA champion event, separate from World Cup tag). */ nbaChampionHomepage?: UseHomepagePredictTaggedMarketsResult; emptyStateTransactionActiveAbTests?: TransactionActiveAbTestEntry[]; diff --git a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx index b3416d12ce0..78a162ae1a6 100644 --- a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/index.tsx @@ -23,6 +23,7 @@ import { pickWorldCupWinnerMarket, resolveNbaChampionHomepageMarket, } from '../../utils/marketResolvers'; +import type { UseHomepagePredictWorldCupMarketsResult } from '../../hooks/useHomepagePredictWorldCupMarkets'; import type { UseHomepagePredictTaggedMarketsResult } from '../../hooks/useHomepagePredictTaggedMarkets'; import type { PredictionsTrendingHeaderTestId } from '../../predictionsSectionTypes'; import type { TransactionActiveAbTestEntry } from '../../../../../../../util/transactions/transaction-active-ab-test-attribution-registry'; @@ -39,7 +40,7 @@ export interface HomepagePredictWorldCupDiscoveryProps { transactionActiveAbTests?: TransactionActiveAbTestEntry[], ) => void; headerTestIdKey: PredictionsTrendingHeaderTestId; - worldCup: UseHomepagePredictTaggedMarketsResult; + worldCup: UseHomepagePredictWorldCupMarketsResult; nbaChampion: UseHomepagePredictTaggedMarketsResult; transactionActiveAbTests?: TransactionActiveAbTestEntry[]; onTreatmentCtaClick?: ( diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/index.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/index.ts index ec29f97cd3f..4129bf93486 100644 --- a/app/components/Views/Homepage/Sections/Predictions/hooks/index.ts +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/index.ts @@ -2,6 +2,7 @@ export * from './usePredictMarketsForHomepage'; export * from './usePredictPositionsForHomepage'; export * from './usePredictHomepageDiscoveryExperiment'; export * from './useHomepagePredictTaggedMarkets'; +export * from './useHomepagePredictWorldCupMarkets'; export * from './usePredictionsSectionNavigation'; export * from './usePredictionsDefaultSectionModel'; export * from './useTreatmentDiscoveryFeedsLoading'; diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictTaggedMarkets.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictTaggedMarkets.ts index 4887f832038..aecc3964559 100644 --- a/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictTaggedMarkets.ts +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictTaggedMarkets.ts @@ -4,12 +4,8 @@ import { } from '../../../../../UI/Predict/hooks/usePredictMarketData'; import { PREDICT_HOME_NBA_CHAMPION_EVENT_QUERY } from '../constants/homepageNbaChampionDiscovery'; -/** Polymarket tag for 2026 FIFA World Cup (homepage discovery feed). */ -export const PREDICT_HOME_WORLD_CUP_TAG_QUERY = 'tag_id=102350'; - /** Predefined query parameter slugs the homepage rail loads. */ export const HOMEPAGE_PREDICT_TAG_QUERIES = { - worldCup: PREDICT_HOME_WORLD_CUP_TAG_QUERY, nbaChampion: PREDICT_HOME_NBA_CHAMPION_EVENT_QUERY, } as const; diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.ts new file mode 100644 index 00000000000..f8d37dde11b --- /dev/null +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.ts @@ -0,0 +1,28 @@ +import { useSelector } from 'react-redux'; +import { usePredictWorldCupMarkets } from '../../../../../UI/Predict/hooks/usePredictWorldCup'; +import type { UsePredictMarketDataResult } from '../../../../../UI/Predict/hooks/usePredictMarketData'; +import { PREDICT_WORLD_CUP_TAB_KEYS } from '../../../../../UI/Predict/constants/worldCupTabs'; +import { selectPredictWorldCupConfig } from '../../../../../UI/Predict/selectors/featureFlags'; + +interface UseHomepagePredictWorldCupMarketsArgs { + enabled: boolean; +} + +/** + * Homepage discovery: loads World Cup markets using the same ALL-tab query path + * as the dedicated World Cup screen (`buildPredictWorldCupAllQuery` → keyset API). + */ +export function useHomepagePredictWorldCupMarkets({ + enabled, +}: UseHomepagePredictWorldCupMarketsArgs): UsePredictMarketDataResult { + const config = useSelector(selectPredictWorldCupConfig); + + return usePredictWorldCupMarkets({ + tabKey: PREDICT_WORLD_CUP_TAB_KEYS.ALL, + config, + enabled, + }); +} + +export type UseHomepagePredictWorldCupMarketsResult = + UsePredictMarketDataResult; From 90cfbe95df474ef77474cf6601244f6b6d3832ee Mon Sep 17 00:00:00 2001 From: Brian August Nguyen Date: Thu, 28 May 2026 10:50:32 -0700 Subject: [PATCH 2/7] feat: update Telegram icon to official logo (#30326) ## **Description** Updated the Telegram icon SVG in the component-library to use the official Telegram logo design (without the tail). This was requested by the design team to align with the proper Telegram branding requirements for the Telegram social login feature. The icon was added to: - `app/component-library/components/Icons/Icon/assets/telegram.svg` The icon generation script (`yarn generate-icons`) was run to regenerate: - `Icon.types.ts` - Added `Telegram` to `IconName` enum - `Icon.assets.ts` - Added import and mapping for the Telegram SVG ## **Changelog** CHANGELOG entry: null ## **Related issues** Related to Telegram social login implementation. ## **Manual testing steps** ```gherkin Feature: Telegram icon display Scenario: user views Telegram icon Given the app is running with design system components When user views a component using IconName.Telegram Then the official Telegram paper plane logo is displayed ``` ## **Screenshots/Recordings** ### **Before** N/A - Icon being added ### **After** Icon can be verified by importing and using: ```tsx import { Icon, IconName, IconSize } from '@metamask/design-system-react-native'; ``` ## **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. [Slack Thread](https://consensys.slack.com/archives/C0354T27M5M/p1777358027891429?thread_ts=1777358027.891429&cid=C0354T27M5M)
Open in Web Open in Cursor 
--------- Co-authored-by: Cursor Agent --- app/component-library/components/Icons/Icon/Icon.assets.ts | 2 ++ app/component-library/components/Icons/Icon/Icon.types.ts | 1 + app/component-library/components/Icons/Icon/assets/telegram.svg | 1 + 3 files changed, 4 insertions(+) create mode 100644 app/component-library/components/Icons/Icon/assets/telegram.svg diff --git a/app/component-library/components/Icons/Icon/Icon.assets.ts b/app/component-library/components/Icons/Icon/Icon.assets.ts index 97625ce0070..5f55cdb789f 100644 --- a/app/component-library/components/Icons/Icon/Icon.assets.ts +++ b/app/component-library/components/Icons/Icon/Icon.assets.ts @@ -251,6 +251,7 @@ import tabcloseSVG from './assets/tab-close.svg'; import tablerowSVG from './assets/table-row.svg'; import tabletSVG from './assets/tablet.svg'; import tagSVG from './assets/tag.svg'; +import telegramSVG from './assets/telegram.svg'; import thumbdownfilledSVG from './assets/thumb-down-filled.svg'; import thumbdownSVG from './assets/thumb-down.svg'; import thumbupfilledSVG from './assets/thumb-up-filled.svg'; @@ -540,6 +541,7 @@ export const assetByIconName: AssetByIconName = { [IconName.TableRow]: tablerowSVG, [IconName.Tablet]: tabletSVG, [IconName.Tag]: tagSVG, + [IconName.Telegram]: telegramSVG, [IconName.ThumbDownFilled]: thumbdownfilledSVG, [IconName.ThumbDown]: thumbdownSVG, [IconName.ThumbUpFilled]: thumbupfilledSVG, diff --git a/app/component-library/components/Icons/Icon/Icon.types.ts b/app/component-library/components/Icons/Icon/Icon.types.ts index 649011ddab5..96afde4dbe0 100644 --- a/app/component-library/components/Icons/Icon/Icon.types.ts +++ b/app/component-library/components/Icons/Icon/Icon.types.ts @@ -321,6 +321,7 @@ export enum IconName { TableRow = 'TableRow', Tablet = 'Tablet', Tag = 'Tag', + Telegram = 'Telegram', ThumbDownFilled = 'ThumbDownFilled', ThumbDown = 'ThumbDown', ThumbUpFilled = 'ThumbUpFilled', diff --git a/app/component-library/components/Icons/Icon/assets/telegram.svg b/app/component-library/components/Icons/Icon/assets/telegram.svg new file mode 100644 index 00000000000..4c19924785e --- /dev/null +++ b/app/component-library/components/Icons/Icon/assets/telegram.svg @@ -0,0 +1 @@ + From 0091eff398031913ad91bcd24aafa8f02385637b Mon Sep 17 00:00:00 2001 From: Wei Sun Date: Thu, 28 May 2026 11:21:48 -0700 Subject: [PATCH 3/7] refactor(card): migrate Card main screens to HeaderStandard and consolidate header hook (#30714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** **For reviewer**: please use this link to better see the diff without formatting changes: https://github.com/MetaMask/metamask-mobile/pull/30714/changes?w=1 **Summary** Migrates the main Card stack (MainRoutes in app/components/UI/Card/routes/index.tsx) from navigator-provided headers (headerLeft / headerRight with ButtonIcon) to in-screen HeaderStandard chrome, matching the pattern already used by Card onboarding via OnboardingStep. Also consolidates the two header hooks (onboarding-only + main) into a single shared useCardHeaderHandlers so every Card screen builds its header from the same primitives. **What changed** New shared hook: useCardHeaderHandlers - app/components/UI/Card/hooks/useCardHeaderHandlers.ts (+ tests) - CardHeaderMode: > back → navigation.goBack() > close-with-confirmation → exit alert → WALLET.HOME > close-direct → WALLET.HOME > close-reset-home → navigation.reset to CARD.HOME > none → no header handlers - Returns props that spread directly into . - Close button testID standardized as card-header-close-button. **Removed** - useCardOnboardingNavigationHandlers.ts + its test — replaced by useCardHeaderHandlers. - cardDefaultNavigationOptions, cardChooseYourCardNavigationOptions, cardSpendingLimitNavigationOptions, headerStyle from routes/index.tsx. **Navigator (routes/index.tsx)** - MainRoutes now sets screenOptions={{ headerShown: false }}. - SpendingLimit keeps per-screen options only to disable swipe-back when flow === 'onboarding'. | Screen | Header behavior | | --- | --- | | `CardHome` | `back` (main + error state) | | `ReviewOrder` | `back` | | `OrderCompleted` | `back` | | `Cashback` | `back` | | `CardAuthentication` | `back` (via `OnboardingStep` `headerMode`) | | `ChooseYourCard` | `none` (`flow: onboarding`) / `back` (`upgrade`, `home`); safe-area `edges` adjusted | | `SpendingLimit` | `close-reset-home` (`flow: onboarding`) / `back` (other flows); header in loading, error, and main branches | | `CardWelcome` | Unchanged (no header) | | `OnboardingStep` | Switched to `useCardHeaderHandlers` (types aligned to `CardHeaderMode`) | Android test build: https://github.com/MetaMask/metamask-mobile/actions/runs/26588329872 ## **Changelog** CHANGELOG entry:null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Card screens render in-screen HeaderStandard chrome Background: Given the user is signed in to the wallet And the user has completed Card onboarding (where required) # ----- CardHome ------------------------------------------------------------- Scenario: CardHome shows a back header in the main state Given the user is on the Wallet home screen When the user opens the Card tab Then the CardHome screen is shown And a back arrow is visible at the top-left of the screen And no duplicate header / no gap is rendered above the content Scenario: Tapping back from CardHome returns the user to the previous screen Given the user is on CardHome When the user taps the back arrow in the header Then the user is returned to the previous screen (Wallet home) Scenario: CardHome error state still shows a back header Given CardHome fails to load and renders the error state Then the back arrow is still visible in the header When the user taps the back arrow Then the user is returned to the previous screen # ----- ReviewOrder / OrderCompleted / Cashback / CardAuthentication --------- Scenario Outline: shows a back header Given the user navigates to Then a back arrow is visible at the top-left When the user taps the back arrow Then the user is returned to the previous screen Examples: | screen | | ReviewOrder | | OrderCompleted | | Cashback | | CardAuthentication | # ----- ChooseYourCard ------------------------------------------------------- Scenario: ChooseYourCard in the onboarding flow has no header chrome Given the user is in the Card onboarding flow When the user reaches the ChooseYourCard step Then no back or close button is visible at the top of the screen And the top safe-area inset is respected (no content under the status bar) Scenario Outline: ChooseYourCard outside onboarding shows a back header Given the user opens ChooseYourCard with flow "" Then a back arrow is visible at the top-left When the user taps the back arrow Then the user is returned to the previous screen Examples: | flow | | upgrade | | home | # ----- SpendingLimit -------------------------------------------------------- Scenario Outline: SpendingLimit outside onboarding shows a back header Given the user opens SpendingLimit with flow "" Then a back arrow is visible at the top-left And the screen can be dismissed by swiping back (iOS) When the user taps the back arrow Then the user is returned to the previous screen Examples: | flow | | manage | | enable | Scenario: SpendingLimit in the onboarding flow shows a close button that resets to CardHome Given the user is in the Card onboarding flow And the user is on SpendingLimit with flow "onboarding" Then a close (X) button is visible at the top-left And the screen cannot be dismissed by swiping back When the user taps the close button Then the navigator is reset and the user lands on CardHome And the back stack does not contain the SpendingLimit onboarding screen Scenario: SpendingLimit loading and error states render the header Given SpendingLimit is loading Then the header is visible above the loading indicator Given SpendingLimit fails to load Then the header is visible above the error state # ----- Onboarding regression (header behavior unchanged) -------------------- Scenario Outline: Onboarding step header behavior is unchanged Given the user is on the "" onboarding screen Then the header mode "" is rendered When the user taps the header action Then the expected behavior occurs Examples: | step | mode | | SignUp | back | | ConfirmEmail | back | | PersonalDetails | close-with-confirmation | | PhysicalAddress | close-with-confirmation | | VerifyingVeriffKYC | close-direct | Scenario: Close-with-confirmation prompts before exiting onboarding Given the user is on an onboarding step that uses "close-with-confirmation" When the user taps the close (X) button Then a "leave onboarding?" alert is shown with Cancel and Exit options When the user taps "Cancel" Then the user remains on the current onboarding step When the user taps the close (X) button again and confirms "Exit" Then the user is navigated to Wallet home # ----- Visual / device sanity ---------------------------------------------- Scenario: Headers respect safe area on notched devices Given the device has a top notch / Dynamic Island When the user navigates to any migrated Card screen Then the header sits below the status bar with no overlap and no double inset ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/eabfe621-b661-48e6-bcda-10ed152dee70 ### **After** https://github.com/user-attachments/assets/74a922b3-cfb4-4baa-8c81-834c7aa8aa00 Android test: https://github.com/user-attachments/assets/01826eca-a29b-4766-9d9f-75c50c0a000a ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Navigation and exit behavior changed across many Card screens (including onboarding SpendingLimit reset and gesture rules); regressions would mostly affect UX/back-stack, not security or payments. > > **Overview** > Main Card stack screens now render navigation chrome in-screen with **`HeaderStandard`** and a shared **`useCardHeaderHandlers`** hook, instead of stack **`headerLeft` / `headerRight`** options. **`routes/index.tsx`** defaults to **`headerShown: false`** and drops the old per-screen header option helpers; **SpendingLimit** still disables swipe-back when **`flow === 'onboarding'`**. > > **`useCardOnboardingNavigationHandlers`** is removed and replaced by **`useCardHeaderHandlers`**, which adds **`close-reset-home`** (navigator reset to **`CARD.HOME`**) and standardizes the close button testID. **CardHome**, **Cashback**, **ReviewOrder**, **OrderCompleted**, and related flows use **`back`**; **ChooseYourCard** uses **`none`** for onboarding and **`back`** for upgrade/home with safe-area tweaks; **SpendingLimit** uses **`close-reset-home`** vs **`back`** by flow; **OnboardingStep** / **CardAuthentication** wire the same modes. Tests drop navigator-header assertions and mock **`HeaderStandard`** where needed. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 3bc157db9d383e0d88393ae64e499efdc43f4c55. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../CardAuthentication/CardAuthentication.tsx | 1 + .../UI/Card/Views/CardHome/CardHome.test.tsx | 21 - .../UI/Card/Views/CardHome/CardHome.tsx | 390 +++++++++--------- .../UI/Card/Views/Cashback/Cashback.tsx | 8 + .../ChooseYourCard/ChooseYourCard.test.tsx | 1 + .../Views/ChooseYourCard/ChooseYourCard.tsx | 18 +- .../OrderCompleted/OrderCompleted.test.tsx | 1 + .../Views/OrderCompleted/OrderCompleted.tsx | 8 + .../Views/ReviewOrder/ReviewOrder.test.tsx | 1 + .../UI/Card/Views/ReviewOrder/ReviewOrder.tsx | 8 + .../Views/SpendingLimit/SpendingLimit.tsx | 26 ++ .../Onboarding/OnboardingStep.test.tsx | 4 +- .../components/Onboarding/OnboardingStep.tsx | 10 +- ...est.tsx => useCardHeaderHandlers.test.tsx} | 28 +- .../UI/Card/hooks/useCardHeaderHandlers.ts | 93 +++++ .../useCardOnboardingNavigationHandlers.ts | 60 --- app/components/UI/Card/routes/index.tsx | 141 +------ 17 files changed, 418 insertions(+), 401 deletions(-) rename app/components/UI/Card/hooks/{useCardOnboardingNavigationHandlers.test.tsx => useCardHeaderHandlers.test.tsx} (78%) create mode 100644 app/components/UI/Card/hooks/useCardHeaderHandlers.ts delete mode 100644 app/components/UI/Card/hooks/useCardOnboardingNavigationHandlers.ts diff --git a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx index b09c44a744b..c3ef6fb80dd 100644 --- a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx +++ b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx @@ -580,6 +580,7 @@ const CardAuthentication = () => { description={description} formFields={formFields} actions={actions} + headerMode="back" /> ); }; diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index e6211ea931a..5bce110a4e8 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -68,7 +68,6 @@ import { Alert, Linking } from 'react-native'; import { useSelector } from 'react-redux'; import React from 'react'; import CardHome from './CardHome'; -import { cardDefaultNavigationOptions } from '../../routes'; import { renderScreen } from '../../../../../util/test/renderWithProvider'; import { withCardSDK } from '../../sdk'; import { backgroundState } from '../../../../../util/test/initial-root-state'; @@ -1548,26 +1547,6 @@ describe('CardHome Component', () => { ).toBeTruthy(); }); - it('sets navigation options correctly', () => { - // Given: navigation object - const mockNavigation = { - navigate: mockNavigate, - goBack: mockGoBack, - setOptions: mockSetNavigationOptions, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; - - // When: getting navigation options - const navigationOptions = cardDefaultNavigationOptions({ - navigation: mockNavigation, - }); - - // Then: should include all required header components - expect(navigationOptions).toHaveProperty('headerLeft'); - expect(navigationOptions).toHaveProperty('headerTitle'); - expect(navigationOptions).toHaveProperty('headerRight'); - }); - it('dispatches bridge tokens when opening swaps with non-supported token', async () => { // Given: ETH token (not supported for deposit) jest.mocked(useFocusEffect).mockImplementation(jest.fn()); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 441c862c998..f73366902d8 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -14,7 +14,9 @@ import { Button, ButtonVariant, ButtonSize, + HeaderStandard, } from '@metamask/design-system-react-native'; +import { useCardHeaderHandlers } from '../../hooks/useCardHeaderHandlers'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import Icon, { IconName, @@ -235,36 +237,45 @@ const CardHome = () => { const hasPriorityTokenBalance = (primaryToken?.rawTokenBalance ?? 0) > 0; + const headerHandlers = useCardHeaderHandlers('back'); + // --- Error state --- if (isError) { return ( - - + - - {strings('card.card_home.error_title')} - - - {strings('card.card_home.error_description')} - - - + {strings('card.card_home.error_description')} + + + + ); @@ -272,183 +283,192 @@ const CardHome = () => { // --- Main render --- return ( - - } - > - - {strings('card.card_home.title')} - - - - - !( - a.type === 'close_to_spending_limit' && - isSpendingLimitWarningDismissed - ), - )} - onNavigateToSpendingLimit={actions.manageSpendingLimitAction} - onDismissSpendingLimitWarning={() => - setIsSpendingLimitWarningDismissed(true) - } - /> - + + + + } + > + + {strings('card.card_home.title')} + - - - + + !( + a.type === 'close_to_spending_limit' && + isSpendingLimitWarningDismissed + ), + )} + onNavigateToSpendingLimit={actions.manageSpendingLimitAction} + onDismissSpendingLimitWarning={() => + setIsSpendingLimitWarningDismissed(true) } /> - {!hasSetupActions && !hasAlertOnlyState && ( - - )} + + + + - {showSpendingLimitProgress && data?.primaryFundingAsset && ( - - )} + {!hasSetupActions && !hasAlertOnlyState && ( + + )} - {((data?.actions ?? []).length > 0 || isLoading) && ( - - - - )} - + )} - {!isLoading && canAddToWallet && ( - - {isProvisioning ? ( - - + {((data?.actions ?? []).length > 0 || isLoading) && ( + + - ) : ( - )} - )} - {canLinkMoneyAccount && ( - <> - - - + {!isLoading && canAddToWallet && ( + + {isProvisioning ? ( + + + + ) : ( + + )} - - - )} - - + )} - + {canLinkMoneyAccount && ( + <> + + + + + + + )} + + + + - - + + + ); }; diff --git a/app/components/UI/Card/Views/Cashback/Cashback.tsx b/app/components/UI/Card/Views/Cashback/Cashback.tsx index 3a96f8f154c..f965893d11c 100644 --- a/app/components/UI/Card/Views/Cashback/Cashback.tsx +++ b/app/components/UI/Card/Views/Cashback/Cashback.tsx @@ -9,7 +9,9 @@ import { Button, ButtonVariant, ButtonSize, + HeaderStandard, } from '@metamask/design-system-react-native'; +import { useCardHeaderHandlers } from '../../hooks/useCardHeaderHandlers'; import { IconName } from '../../../../../component-library/components/Icons/Icon'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useTheme } from '../../../../../util/theme'; @@ -55,6 +57,7 @@ const formatAmount = (value: string | number): string => { const Cashback: React.FC = () => { const navigation = useNavigation(); const tw = useTailwind(); + const headerHandlers = useCardHeaderHandlers('back'); const theme = useTheme(); const { toastRef } = useContext(ToastContext); const { trackEvent, createEventBuilder } = useAnalytics(); @@ -222,6 +225,11 @@ const Cashback: React.FC = () => { edges={['bottom']} testID={CashbackSelectors.CONTAINER} > + {requiresLineaFunding ? ( diff --git a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx index 944a78228e4..f9a5e240e90 100644 --- a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx +++ b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.test.tsx @@ -120,6 +120,7 @@ jest.mock('@metamask/design-system-react-native', () => { const { TouchableOpacity } = jest.requireActual('react-native'); return { + HeaderStandard: () => null, Box: ({ children, ...props diff --git a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx index c61bcc451ca..6fe9e5437a8 100644 --- a/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx +++ b/app/components/UI/Card/Views/ChooseYourCard/ChooseYourCard.tsx @@ -27,7 +27,12 @@ import { Button, ButtonVariant, ButtonSize, + HeaderStandard, } from '@metamask/design-system-react-native'; +import { + useCardHeaderHandlers, + type CardHeaderMode, +} from '../../hooks/useCardHeaderHandlers'; import { strings } from '../../../../../../locales/i18n'; import Icon, { IconName, @@ -75,6 +80,10 @@ const ChooseYourCard = () => { const { flow = 'onboarding', shippingAddress } = useParams(); const isUpgradeFlow = flow === 'upgrade'; + // 'onboarding' is the linear sign-up flow; no header chrome there. + // 'upgrade' / 'home' are user-initiated entries, so show a back button. + const headerMode: CardHeaderMode = flow === 'onboarding' ? 'none' : 'back'; + const headerHandlers = useCardHeaderHandlers(headerMode); // Arrow bounce animation for swipe indicator useEffect(() => { @@ -366,9 +375,16 @@ const ChooseYourCard = () => { return ( + {headerMode !== 'none' && ( + + )} { const { TouchableOpacity } = jest.requireActual('react-native'); return { + HeaderStandard: () => null, Box: ({ children, ...props diff --git a/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx b/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx index 850c4457003..f2300ec9c7b 100644 --- a/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx +++ b/app/components/UI/Card/Views/OrderCompleted/OrderCompleted.tsx @@ -11,7 +11,9 @@ import { Button, ButtonVariant, ButtonSize, + HeaderStandard, } from '@metamask/design-system-react-native'; +import { useCardHeaderHandlers } from '../../hooks/useCardHeaderHandlers'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; @@ -31,6 +33,7 @@ const OrderCompleted: React.FC = () => { const { trackEvent, createEventBuilder } = useAnalytics(); const navigation = useNavigation(); const tw = useTailwind(); + const headerHandlers = useCardHeaderHandlers('back'); const { fromUpgrade } = useParams(); useEffect(() => { @@ -75,6 +78,11 @@ const OrderCompleted: React.FC = () => { edges={['bottom']} testID={OrderCompletedSelectors.CONTAINER} > + { const { TouchableOpacity } = jest.requireActual('react-native'); return { + HeaderStandard: () => null, Box: ({ children, ...props diff --git a/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.tsx b/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.tsx index 54d6cbc7cbc..eace24e2648 100644 --- a/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.tsx +++ b/app/components/UI/Card/Views/ReviewOrder/ReviewOrder.tsx @@ -11,7 +11,9 @@ import { Button, ButtonVariant, ButtonSize, + HeaderStandard, } from '@metamask/design-system-react-native'; +import { useCardHeaderHandlers } from '../../hooks/useCardHeaderHandlers'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; @@ -43,6 +45,7 @@ const ReviewOrder = () => { const { navigate } = useNavigation(); const { trackEvent, createEventBuilder } = useAnalytics(); const tw = useTailwind(); + const headerHandlers = useCardHeaderHandlers('back'); const { shippingAddress: routeShippingAddress, fromUpgrade } = useParams(); @@ -218,6 +221,11 @@ const ReviewOrder = () => { edges={['bottom']} testID={ReviewOrderSelectors.CONTAINER} > + = ({ route }) => { const flow = route?.params?.flow || 'manage'; const isOnboardingFlow = flow === 'onboarding'; + // Onboarding flow: linear sign-up, exit resets the stack to Card Home. + // Other flows: standard back navigation. + const headerMode: CardHeaderMode = isOnboardingFlow + ? 'close-reset-home' + : 'back'; + const headerHandlers = useCardHeaderHandlers(headerMode); const selectedTokenFromRoute = route?.params?.selectedToken; const { primaryToken, @@ -177,6 +188,11 @@ const SpendingLimit: React.FC = ({ route }) => { style={tw.style('flex-1 bg-background-default')} edges={['bottom']} > + = ({ route }) => { style={tw.style('flex-1 bg-background-default')} edges={['bottom']} > + = ({ route }) => { style={tw.style('flex-1 bg-background-default')} edges={['bottom']} > + { }; }); -jest.mock('../../hooks/useCardOnboardingNavigationHandlers', () => ({ - useCardOnboardingNavigationHandlers: jest.fn(() => ({})), +jest.mock('../../hooks/useCardHeaderHandlers', () => ({ + useCardHeaderHandlers: jest.fn(() => ({})), })); describe('OnboardingStep Component', () => { diff --git a/app/components/UI/Card/components/Onboarding/OnboardingStep.tsx b/app/components/UI/Card/components/Onboarding/OnboardingStep.tsx index a5fd78b9dc9..9285eb1df4f 100644 --- a/app/components/UI/Card/components/Onboarding/OnboardingStep.tsx +++ b/app/components/UI/Card/components/Onboarding/OnboardingStep.tsx @@ -10,9 +10,9 @@ import { import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; import { SafeAreaView } from 'react-native-safe-area-context'; import { - useCardOnboardingNavigationHandlers, - type CardOnboardingHeaderMode, -} from '../../hooks/useCardOnboardingNavigationHandlers'; + useCardHeaderHandlers, + type CardHeaderMode, +} from '../../hooks/useCardHeaderHandlers'; interface OnboardingStepProps { title: string; @@ -29,7 +29,7 @@ interface OnboardingStepProps { * Controls the in-screen header rendered via HeaderStandard. * Navigator headers are hidden; onboarding screens own their header chrome. */ - headerMode?: CardOnboardingHeaderMode; + headerMode?: CardHeaderMode; } const OnboardingStep = ({ @@ -41,7 +41,7 @@ const OnboardingStep = ({ headerMode = 'none', }: OnboardingStepProps) => { const tw = useTailwind(); - const headerHandlers = useCardOnboardingNavigationHandlers(headerMode); + const headerHandlers = useCardHeaderHandlers(headerMode); const renderHeader = () => { if (headerMode === 'none') { diff --git a/app/components/UI/Card/hooks/useCardOnboardingNavigationHandlers.test.tsx b/app/components/UI/Card/hooks/useCardHeaderHandlers.test.tsx similarity index 78% rename from app/components/UI/Card/hooks/useCardOnboardingNavigationHandlers.test.tsx rename to app/components/UI/Card/hooks/useCardHeaderHandlers.test.tsx index 3dc0e308275..097318d0eff 100644 --- a/app/components/UI/Card/hooks/useCardOnboardingNavigationHandlers.test.tsx +++ b/app/components/UI/Card/hooks/useCardHeaderHandlers.test.tsx @@ -1,16 +1,18 @@ import React from 'react'; import { Alert, Pressable } from 'react-native'; import { render, fireEvent } from '@testing-library/react-native'; -import { useCardOnboardingNavigationHandlers } from './useCardOnboardingNavigationHandlers'; +import { useCardHeaderHandlers } from './useCardHeaderHandlers'; import Routes from '../../../../constants/navigation/Routes'; const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); +const mockReset = jest.fn(); jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: mockNavigate, goBack: mockGoBack, + reset: mockReset, }), })); @@ -22,10 +24,10 @@ const HookProbe = ({ headerMode, handlerKey, }: { - headerMode: Parameters[0]; + headerMode: Parameters[0]; handlerKey: 'onBack' | 'onClose'; }) => { - const handlers = useCardOnboardingNavigationHandlers(headerMode); + const handlers = useCardHeaderHandlers(headerMode); const handler = handlers[handlerKey]; return ( @@ -35,7 +37,7 @@ const HookProbe = ({ ); }; -describe('useCardOnboardingNavigationHandlers', () => { +describe('useCardHeaderHandlers', () => { beforeEach(() => { jest.clearAllMocks(); jest.spyOn(Alert, 'alert'); @@ -95,9 +97,24 @@ describe('useCardOnboardingNavigationHandlers', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME); }); + it('resets navigator to Card Home for close-reset-home header mode', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('handler-button')); + + expect(Alert.alert).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockReset).toHaveBeenCalledWith({ + index: 0, + routes: [{ name: Routes.CARD.HOME }], + }); + }); + it('returns no handlers for none header mode', () => { const NoneProbe = () => { - const handlers = useCardOnboardingNavigationHandlers('none'); + const handlers = useCardHeaderHandlers('none'); return ( Trigger @@ -112,5 +129,6 @@ describe('useCardOnboardingNavigationHandlers', () => { expect(Alert.alert).not.toHaveBeenCalled(); expect(mockNavigate).not.toHaveBeenCalled(); expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockReset).not.toHaveBeenCalled(); }); }); diff --git a/app/components/UI/Card/hooks/useCardHeaderHandlers.ts b/app/components/UI/Card/hooks/useCardHeaderHandlers.ts new file mode 100644 index 00000000000..cf9c47116bb --- /dev/null +++ b/app/components/UI/Card/hooks/useCardHeaderHandlers.ts @@ -0,0 +1,93 @@ +import { useCallback } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { Alert } from 'react-native'; +import Routes from '../../../../constants/navigation/Routes'; +import { strings } from '../../../../../locales/i18n'; + +/** + * Header modes shared across Card onboarding and main routes. + * + * `back` — back arrow that calls `navigation.goBack()`. Used for any screen + * the user can step back from. + * + * `close-with-confirmation` — close (X) icon that shows a "leave onboarding?" + * alert before navigating to `WALLET.HOME`. Used in the onboarding flow after + * email entry. + * + * `close-direct` — close (X) icon that navigates straight to `WALLET.HOME`. + * Used on KYC status screens. + * + * `close-reset-home` — close (X) icon that resets the navigator to + * `CARD.HOME`. Used by SpendingLimit's onboarding flow where the user must + * exit the linear flow without the ability to swipe back. + * + * `none` — no header chrome (caller renders something else, or the screen + * intentionally has no header). + * + * Returns props that spread directly into ``. + */ +export type CardHeaderMode = + | 'back' + | 'close-with-confirmation' + | 'close-direct' + | 'close-reset-home' + | 'none'; + +export const useCardHeaderHandlers = (mode: CardHeaderMode = 'none') => { + const navigation = useNavigation(); + + const handleBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleCloseDirect = useCallback(() => { + navigation.navigate(Routes.WALLET.HOME); + }, [navigation]); + + const handleCloseResetHome = useCallback(() => { + navigation.reset({ + index: 0, + routes: [{ name: Routes.CARD.HOME }], + }); + }, [navigation]); + + const handleCloseWithConfirmation = useCallback(() => { + Alert.alert( + strings('card.card_onboarding.exit_confirmation.title'), + strings('card.card_onboarding.exit_confirmation.message'), + [ + { + text: strings('card.card_onboarding.exit_confirmation.cancel_button'), + style: 'cancel', + }, + { + text: strings('card.card_onboarding.exit_confirmation.exit_button'), + onPress: () => navigation.navigate(Routes.WALLET.HOME), + style: 'destructive', + }, + ], + ); + }, [navigation]); + + switch (mode) { + case 'back': + return { onBack: handleBack }; + case 'close-with-confirmation': + return { + onClose: handleCloseWithConfirmation, + closeButtonProps: { testID: 'card-header-close-button' }, + }; + case 'close-direct': + return { + onClose: handleCloseDirect, + closeButtonProps: { testID: 'card-header-close-button' }, + }; + case 'close-reset-home': + return { + onClose: handleCloseResetHome, + closeButtonProps: { testID: 'card-header-close-button' }, + }; + default: + return {}; + } +}; diff --git a/app/components/UI/Card/hooks/useCardOnboardingNavigationHandlers.ts b/app/components/UI/Card/hooks/useCardOnboardingNavigationHandlers.ts deleted file mode 100644 index be4630a7f54..00000000000 --- a/app/components/UI/Card/hooks/useCardOnboardingNavigationHandlers.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useCallback } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { Alert } from 'react-native'; -import Routes from '../../../../constants/navigation/Routes'; -import { strings } from '../../../../../locales/i18n'; - -export type CardOnboardingHeaderMode = - | 'back' - | 'close-with-confirmation' - | 'close-direct' - | 'none'; - -export const useCardOnboardingNavigationHandlers = ( - headerMode: CardOnboardingHeaderMode = 'none', -) => { - const navigation = useNavigation(); - - const handleBack = useCallback(() => { - navigation.goBack(); - }, [navigation]); - - const handleCloseDirect = useCallback(() => { - navigation.navigate(Routes.WALLET.HOME); - }, [navigation]); - - const handleCloseWithConfirmation = useCallback(() => { - Alert.alert( - strings('card.card_onboarding.exit_confirmation.title'), - strings('card.card_onboarding.exit_confirmation.message'), - [ - { - text: strings('card.card_onboarding.exit_confirmation.cancel_button'), - style: 'cancel', - }, - { - text: strings('card.card_onboarding.exit_confirmation.exit_button'), - onPress: () => navigation.navigate(Routes.WALLET.HOME), - style: 'destructive', - }, - ], - ); - }, [navigation]); - - switch (headerMode) { - case 'back': - return { onBack: handleBack }; - case 'close-with-confirmation': - return { - onClose: handleCloseWithConfirmation, - closeButtonProps: { testID: 'exit-onboarding-button' }, - }; - case 'close-direct': - return { - onClose: handleCloseDirect, - closeButtonProps: { testID: 'exit-onboarding-button' }, - }; - default: - return {}; - } -}; diff --git a/app/components/UI/Card/routes/index.tsx b/app/components/UI/Card/routes/index.tsx index 355f765de24..4455b7fa3d5 100644 --- a/app/components/UI/Card/routes/index.tsx +++ b/app/components/UI/Card/routes/index.tsx @@ -6,8 +6,6 @@ import { import Routes from '../../../../constants/navigation/Routes'; import CardHome from '../Views/CardHome/CardHome'; import CardWelcome from '../Views/CardWelcome/CardWelcome'; -import { NavigationProp, ParamListBase } from '@react-navigation/native'; -import { StyleSheet, View } from 'react-native'; import CardAuthentication from '../Views/CardAuthentication/CardAuthentication'; import SpendingLimit from '../Views/SpendingLimit/SpendingLimit'; import ChooseYourCard from '../Views/ChooseYourCard/ChooseYourCard'; @@ -22,7 +20,6 @@ import { withCardSDK } from '../sdk'; import AddFundsBottomSheet from '../components/AddFundsBottomSheet/AddFundsBottomSheet'; import AssetSelectionBottomSheet from '../components/AssetSelectionBottomSheet/AssetSelectionBottomSheet'; import PasswordBottomSheet from '../components/PasswordBottomSheet'; -import { colors } from '../../../../styles/common'; import RegionSelectorModal from '../components/Onboarding/RegionSelectorModal'; import ConfirmModal from '../components/Onboarding/ConfirmModal'; import RecurringFeeModal from '../components/RecurringFeeModal/RecurringFeeModal'; @@ -32,108 +29,25 @@ import SpendingLimitOptionsSheet from '../Views/SpendingLimit/components/Spendin import WaitlistFormModal from '../components/WaitlistFormModal/WaitlistFormModal'; import OrderCompleted from '../Views/OrderCompleted/OrderCompleted'; import Cashback from '../Views/Cashback/Cashback'; -import { - ButtonIcon, - ButtonIconSize, - IconName, -} from '@metamask/design-system-react-native'; import { clearStackNavigatorOptions } from '../../../../constants/navigation/clearStackNavigatorOptions'; const Stack = createStackNavigator(); const ModalsStack = createStackNavigator(); -export const headerStyle = StyleSheet.create({ - icon: { marginHorizontal: 16 }, - title: { alignSelf: 'center' }, -}); - -// Default navigation has only back button on the left -export const cardDefaultNavigationOptions = ({ - navigation, -}: { - navigation: NavigationProp; -}): StackNavigationOptions => ({ - headerLeft: () => ( - navigation.goBack()} - /> - ), - headerTitle: () => , - headerRight: () => , -}); +// All Card main screens render their own header via HeaderStandard, so hide +// the navigator chrome by default. +const mainScreenOptions: StackNavigationOptions = { headerShown: false }; -export const cardSpendingLimitNavigationOptions = ({ - navigation, +// SpendingLimit's onboarding flow renders a close (X) header and must not be +// swipe-dismissable; all other flows keep the default gesture behavior. +const spendingLimitScreenOptions = ({ route, }: { - navigation: NavigationProp; route: { params?: { flow?: 'manage' | 'enable' | 'onboarding' } }; -}): StackNavigationOptions => { - const flow = route.params?.flow || 'manage'; - const isOnboardingFlow = flow === 'onboarding'; - - return { - headerLeft: () => - isOnboardingFlow ? ( - - ) : ( - navigation.goBack()} - /> - ), - headerTitle: () => , - headerRight: () => - isOnboardingFlow ? ( - - navigation.reset({ - index: 0, - routes: [{ name: Routes.CARD.HOME }], - }) - } - /> - ) : ( - - ), - gestureEnabled: !isOnboardingFlow, - }; -}; - -export const cardChooseYourCardNavigationOptions = ({ - navigation, - route, -}: { - navigation: NavigationProp; - route: { params?: { flow?: 'onboarding' | 'upgrade' | 'home' } }; -}): StackNavigationOptions => { - const flow = route.params?.flow || 'onboarding'; - const showBackButton = flow === 'upgrade' || flow === 'home'; - - return { - headerLeft: () => - showBackButton ? ( - navigation.goBack()} - /> - ) : ( - - ), - headerTitle: () => , - headerRight: () => , - }; -}; +}): StackNavigationOptions => ({ + headerShown: false, + gestureEnabled: route.params?.flow !== 'onboarding', +}); const MainRoutes = () => { const isAuthenticated = useSelector(selectIsCardAuthenticated); @@ -146,51 +60,34 @@ const MainRoutes = () => { ); return ( - - - + + + - + - + ); From 7602affea61758385ae10785fd5b2cc6612c773e Mon Sep 17 00:00:00 2001 From: Gaurav Goel Date: Fri, 29 May 2026 01:41:55 +0530 Subject: [PATCH 4/7] fix: android crash for new social login users (#30765) ## **Description** * This PR removes the onboarding success animation for Android social-login users to prevent a crash observed shortly after wallet creation ## **Changelog** CHANGELOG entry: Fixed a crash affecting some Android social-login users by removing the onboarding success animation in that flow. ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/30737 ## **Manual testing steps** ```gherkin Feature: Android social onboarding success stability Scenario: Android social user reaches onboarding success Given user is on Android and signs up via social login When user completes wallet creation and lands on onboarding success Then the onboarding success animation should not render And the app should not crash ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/e13a6a99-88c7-4a02-a5c4-cc8856141d21 ### **After** https://github.com/user-attachments/assets/7885dfb0-28e9-4193-8fc4-59ab25034c02 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] 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** > Targeted UI guard on an existing onboarding screen; no auth, key material, or navigation reset logic changes beyond skipping one animation. > > **Overview** > Skips the onboarding success end animation on **Android** when navigation passes `showPasswordHint: true` (the social-login path from `ChoosePassword`). The screen still shows the success title and actions; only `OnboardingSuccessEndAnimation` is omitted for that platform/flag combination. > > A new optional route param and component prop wire `showPasswordHint` from the navigator into `OnboardingSuccessComponent`, with `Platform.OS` used to gate the animation. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e352fa2f6217c8a6801acbc28afb57c8fb3f32dd. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Cursor --- .../Views/ChoosePassword/index.test.tsx | 9 ++++++-- app/components/Views/ChoosePassword/index.tsx | 5 +++- .../Views/OnboardingSuccess/index.test.tsx | 23 +++++++++++++++++++ .../Views/OnboardingSuccess/index.tsx | 16 +++++++++---- app/constants/onboarding.ts | 1 + ...lletHomeOnboardingStepsEligibility.test.ts | 1 + .../walletHomeOnboardingStepsEligibility.ts | 3 ++- 7 files changed, 49 insertions(+), 9 deletions(-) diff --git a/app/components/Views/ChoosePassword/index.test.tsx b/app/components/Views/ChoosePassword/index.test.tsx index af2fc849e76..64f97a88367 100644 --- a/app/components/Views/ChoosePassword/index.test.tsx +++ b/app/components/Views/ChoosePassword/index.test.tsx @@ -54,7 +54,10 @@ jest.mock('@metamask/key-tree', () => ({ import ChoosePassword from './index.tsx'; import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding'; -import { AccountType } from '../../../constants/onboarding'; +import { + AccountType, + ONBOARDING_SUCCESS_FLOW, +} from '../../../constants/onboarding'; import { TraceName, TraceOperation, @@ -700,7 +703,9 @@ describe('ChoosePassword', () => { routes: [ { name: 'OnboardingSuccess', - params: { showPasswordHint: true }, + params: { + successFlow: ONBOARDING_SUCCESS_FLOW.SEEDLESS_ONBOARDING, + }, }, ], }); diff --git a/app/components/Views/ChoosePassword/index.tsx b/app/components/Views/ChoosePassword/index.tsx index dfb7b7616b6..6014541d70f 100644 --- a/app/components/Views/ChoosePassword/index.tsx +++ b/app/components/Views/ChoosePassword/index.tsx @@ -60,6 +60,7 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import { AccountType, getSocialAccountType, + ONBOARDING_SUCCESS_FLOW, } from '../../../constants/onboarding'; import type { IMetaMetricsEvent, @@ -362,7 +363,9 @@ const ChoosePassword = () => { routes: [ { name: Routes.ONBOARDING.SUCCESS, - params: { showPasswordHint: true }, + params: { + successFlow: ONBOARDING_SUCCESS_FLOW.SEEDLESS_ONBOARDING, + }, }, ], }); diff --git a/app/components/Views/OnboardingSuccess/index.test.tsx b/app/components/Views/OnboardingSuccess/index.test.tsx index 2105a9f6f05..431d19e3091 100644 --- a/app/components/Views/OnboardingSuccess/index.test.tsx +++ b/app/components/Views/OnboardingSuccess/index.test.tsx @@ -15,6 +15,7 @@ import Engine from '../../../core/Engine/Engine'; import { strings } from '../../../../locales/i18n'; import { useSelector } from 'react-redux'; import Logger from '../../../util/Logger'; +import { Platform } from 'react-native'; import { SET_WALLET_HOME_ONBOARDING_STEPS_ELIGIBLE, setWalletHomeOnboardingStepsEligible, @@ -254,6 +255,28 @@ describe('OnboardingSuccessComponent', () => { expect(getByTestId('onboarding-success-end-animation')).toBeOnTheScreen(); }); + it('hides OnboardingSuccessEndAnimation on Android for seedless onboarding flow', () => { + const originalPlatform = Platform.OS; + Object.defineProperty(Platform, 'OS', { + writable: true, + value: 'android', + }); + + const { queryByTestId } = renderWithProvider( + , + ); + + expect(queryByTestId('onboarding-success-end-animation')).toBeNull(); + + Object.defineProperty(Platform, 'OS', { + writable: true, + value: originalPlatform, + }); + }); + it('hides manage default settings button for SETTINGS_BACKUP flow', () => { const { queryByTestId } = renderWithProvider( = ({ } return strings('onboarding_success.wallet_ready'); }; + const shouldSkipSuccessAnimation = + Platform.OS === 'android' && + successFlow === ONBOARDING_SUCCESS_FLOW.SEEDLESS_ONBOARDING; const renderContent = () => ( <> - { - // No-op: Animation completion not needed in success mode - }} - /> + {!shouldSkipSuccessAnimation && ( + { + // No-op: Animation completion not needed in success mode + }} + /> + )} { ONBOARDING_SUCCESS_FLOW.BACKED_UP_SRP, ONBOARDING_SUCCESS_FLOW.NO_BACKED_UP_SRP, ONBOARDING_SUCCESS_FLOW.IMPORT_FROM_SEED_PHRASE, + ONBOARDING_SUCCESS_FLOW.SEEDLESS_ONBOARDING, ])('returns true for first-time onboarding flow %s', (flow) => { expect(shouldMarkWalletHomeOnboardingStepsEligible(flow)).toBe(true); }); diff --git a/app/util/onboarding/walletHomeOnboardingStepsEligibility.ts b/app/util/onboarding/walletHomeOnboardingStepsEligibility.ts index 8434ea377ca..69998031343 100644 --- a/app/util/onboarding/walletHomeOnboardingStepsEligibility.ts +++ b/app/util/onboarding/walletHomeOnboardingStepsEligibility.ts @@ -23,6 +23,7 @@ export function shouldMarkWalletHomeOnboardingStepsEligible( return ( successFlow === ONBOARDING_SUCCESS_FLOW.BACKED_UP_SRP || successFlow === ONBOARDING_SUCCESS_FLOW.NO_BACKED_UP_SRP || - successFlow === ONBOARDING_SUCCESS_FLOW.IMPORT_FROM_SEED_PHRASE + successFlow === ONBOARDING_SUCCESS_FLOW.IMPORT_FROM_SEED_PHRASE || + successFlow === ONBOARDING_SUCCESS_FLOW.SEEDLESS_ONBOARDING ); } From 611838952e3d87bfd4b94284b6fa5a10005efbe3 Mon Sep 17 00:00:00 2001 From: samiracle <12882259+samir-acle@users.noreply.github.com> Date: Thu, 28 May 2026 16:51:42 -0400 Subject: [PATCH 5/7] feat: add push pre-prompt integration (part 3) cp-7.80.0 (#30476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This is PR 3 of the GE-217 push pre-prompt rollout. This PR mounts the push notification onboarding pre-prompt from `Nav/Main` and wires the production flow for the two currently supported startup variants: - `push_permission`: shown to eligible users who have not granted OS push permission or do not yet have notification preferences. - `marketing_consent`: shown to eligible existing users with push notifications enabled but marketing notification preferences disabled. The prompt resolver now gates display on notification runtime eligibility, completed onboarding, the default-on feature flag, one-time “shown” storage, OS push permission status, Authenticated User Storage notification preferences, and pending social-login marketing-consent backfill. AUS preference reads stay behind the runtime gate, and resetting the developer “push pre prompt shown” flag does not immediately reopen the prompt until the app is restarted. This PR also adds shared notification preference helpers used by Settings and startup onboarding, background enablement for the push pre-prompt path, marketing notification preference updates, toast copy, and MetaMetrics/identify instrumentation for pre-prompt actions, OS prompt responses, marketing consent, and push notification enablement. ## **Changelog** CHANGELOG entry: Added a startup prompt for eligible users to enable push notifications. ## **Related issues** Fixes: GE-217 ## **Manual testing steps** ```gherkin Feature: Push notification startup pre-prompt Scenario: eligible user sees the push permission pre-prompt Given notifications are enabled for the build And the user is eligible for the push permission pre-prompt And the push pre-prompt has not already been shown When the user launches the app Then the push notification onboarding sheet is shown When the user taps "Yes" Then the native push permission prompt is requested And the startup surface is completed And notifications are enabled in the background Scenario: eligible user dismisses the push permission pre-prompt Given notifications are enabled for the build And the user is eligible for the push permission pre-prompt And the push pre-prompt has not already been shown When the user launches the app And the user taps "Not now" Then the push notification onboarding sheet closes And the startup surface is completed Scenario: existing user sees the marketing consent pre-prompt Given notifications are enabled for the build And the user already has notification preferences And the user has not enabled marketing consent And the push pre-prompt has not already been shown When the user launches the app Then the marketing consent onboarding sheet is shown When the user confirms marketing consent Then marketing consent is enabled And the startup surface is completed ``` ## Manual Testing Steps ### Preconditions - Use a build with the notifications feature flag enabled. - Use a device/simulator where OS notification permissions can be reset. - After seeing either pre-prompt once, go to **Settings > Developer options** and tap **Reset push pre prompt shown**, then fully kill and reopen the app before testing another prompt. - To force a clean first-run state, delete the app from the device and install it again. - To test update behavior, do not delete the app. Install/update the PR build over an existing installed build so app data is preserved. --- ## Flow 1: New User / Push Permission Pre-Prompt ### Setup 1. Delete the app from the device. 2. Install the PR build. 3. If needed, reset OS notification permission for MetaMask from device settings. 4. Open the app and complete onboarding with a new wallet or imported wallet. 5. Keep marketing consent disabled during onboarding if the option is shown. 6. Finish onboarding and land in the wallet. ### Steps 1. Confirm the push notification pre-prompt appears after onboarding. 2. Confirm the sheet explains push notifications and personalized updates. 3. Tap **Not now**. 4. Confirm the sheet closes. 5. Confirm a toast appears saying notifications can be enabled later in **Settings > Notifications**. 6. Go to **Settings > Developer options** and tap **Reset push pre prompt shown**. 7. Fully kill and reopen the app. 8. Confirm the push notification pre-prompt appears again. 9. Tap **Yes**. 10. Confirm the native OS push notification prompt appears. 11. Tap **Allow**. 12. Confirm the sheet closes. 13. Confirm a success toast appears. 14. Go to **Settings > Notifications**. 15. Confirm notifications are enabled. 16. Confirm the relevant notification categories, including marketing/updates, are enabled where available. ### Also Verify - Repeat the **Yes** path and tap **Don’t Allow** on the OS prompt. - Confirm the app does not crash. - Confirm the sheet closes and the user sees guidance to enable notifications later in settings. --- ## Flow 2: Existing User / App Update With Push Enabled But Marketing Consent Disabled ### Setup 1. Start from an existing installed app with a completed wallet. 2. Do not delete the app. 3. Enable push notifications from **Settings > Notifications**. 4. Disable marketing consent from **Settings > Security & Privacy > Data collection for marketing**. 5. Install/update to the PR build over the existing app. 6. Open the app and unlock the wallet. 7. If the prompt was already seen, use **Settings > Developer options > Reset push pre prompt shown**, then fully kill and reopen the app. ### Steps 1. Confirm the existing-user marketing consent pre-prompt appears. 2. Confirm the prompt asks for personalized/marketing updates. 3. Tap **Not now**. 4. Confirm the sheet closes. 5. Confirm marketing consent remains disabled. 6. Confirm push notifications remain enabled. 7. Reset **push pre prompt shown** in Developer options. 8. Fully kill and reopen the app. 9. Confirm the existing-user marketing consent prompt appears again. 10. Tap **Confirm**. 11. Confirm the sheet closes. 12. Confirm a success toast appears. 13. Go to **Settings > Security & Privacy**. 14. Confirm **Data collection for marketing** is enabled. 15. Go to **Settings > Notifications**. 16. Confirm **Updates and Rewards** / marketing notification preferences are enabled. --- ## Flow 3: Existing User / App Update With Marketing Consent Enabled But Push Disabled ### Setup 1. Start from an existing installed app with a completed wallet but push not enabled (also not denied) 2. Do not delete the app. 3. Enable marketing consent from **Settings > Security & Privacy > Data collection for marketing**. 4. Disable push notifications from device OS settings or **Settings > Notifications**. 5. Install/update to the PR build over the existing app. 6. Open the app and unlock the wallet. 7. Reset **push pre prompt shown** in Developer options if needed, then fully kill and reopen. ### Steps 1. Confirm the push permission pre-prompt appears. 2. Tap **Yes**. 3. Confirm the native OS notification prompt appears. 4. Tap **Allow**. 5. Confirm notifications are enabled. 6. Confirm marketing consent remains enabled. --- ## Regression Checks - The pre-prompt should only show once per install/session unless reset from Developer options. - Resetting **push pre prompt shown** should not immediately reopen the prompt until the app is restarted. - Deleting and reinstalling the app should allow the new-user flow to be tested again. - Updating the app without deleting it should preserve user state and trigger the correct existing-user flow. - Users with both push notifications and marketing consent already enabled should not see either pre-prompt. ## **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** - [ ] 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. --------- Co-authored-by: Cursor --- .../notification/helpers/index.test.tsx | 63 +++ app/actions/notification/helpers/index.ts | 34 +- app/components/Nav/Main/index.js | 2 + app/components/Nav/Main/index.test.tsx | 9 + .../NewUserSheet/NewUserSheet.test.tsx | 14 +- .../PushNotificationOnboardingRoot.test.tsx | 151 ++++++ .../PushNotificationOnboardingRoot.tsx | 69 +++ .../PushNotificationOnboarding/index.test.tsx | 451 ++++++++++++++++++ .../PushNotificationOnboarding/index.tsx | 284 +++++++++++ app/core/Analytics/MetaMetrics.events.ts | 12 + app/selectors/engagement.test.ts | 21 + app/selectors/engagement.ts | 4 + ...ackfillSocialLoginMarketingConsent.test.ts | 40 +- .../backfillSocialLoginMarketingConsent.ts | 14 +- .../UserProfileAnalyticsMetaData.types.ts | 2 + .../constants/notification-storage-keys.ts | 2 - app/util/notifications/hooks/types.ts | 48 -- .../hooks/useEnableMarketingConsent.test.ts | 226 +++++++++ .../hooks/useEnableMarketingConsent.ts | 103 ++++ .../hooks/useNotifications.test.tsx | 50 +- .../notifications/hooks/useNotifications.ts | 8 +- ...usePushPermissionNotificationSetup.test.ts | 195 ++++++++ .../usePushPermissionNotificationSetup.ts | 72 +++ .../hooks/usePushPrePromptAnalytics.test.ts | 151 ++++++ .../hooks/usePushPrePromptAnalytics.ts | 145 ++++++ .../hooks/usePushPrePromptVariant.test.ts | 398 ++++++++++------ .../hooks/usePushPrePromptVariant.ts | 192 ++++---- .../useStartupNotificationsEffect.test.ts | 17 + .../hooks/useStartupNotificationsEffect.ts | 4 +- .../services/NotificationService.test.ts | 119 ++++- .../services/NotificationService.ts | 53 ++ .../utils/push-notification-status.test.ts | 67 ++- .../utils/push-notification-status.ts | 50 +- app/util/test/testSetup.js | 2 + locales/languages/en.json | 26 +- ...ble-notifications-after-onboarding.spec.ts | 11 +- 36 files changed, 2767 insertions(+), 342 deletions(-) create mode 100644 app/components/Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot.test.tsx create mode 100644 app/components/Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot.tsx create mode 100644 app/components/Views/Notifications/PushNotificationOnboarding/index.test.tsx create mode 100644 app/components/Views/Notifications/PushNotificationOnboarding/index.tsx create mode 100644 app/selectors/engagement.test.ts create mode 100644 app/selectors/engagement.ts delete mode 100644 app/util/notifications/hooks/types.ts create mode 100644 app/util/notifications/hooks/useEnableMarketingConsent.test.ts create mode 100644 app/util/notifications/hooks/useEnableMarketingConsent.ts create mode 100644 app/util/notifications/hooks/usePushPermissionNotificationSetup.test.ts create mode 100644 app/util/notifications/hooks/usePushPermissionNotificationSetup.ts create mode 100644 app/util/notifications/hooks/usePushPrePromptAnalytics.test.ts create mode 100644 app/util/notifications/hooks/usePushPrePromptAnalytics.ts diff --git a/app/actions/notification/helpers/index.test.tsx b/app/actions/notification/helpers/index.test.tsx index 16ed2137ed8..03f27f21daa 100644 --- a/app/actions/notification/helpers/index.test.tsx +++ b/app/actions/notification/helpers/index.test.tsx @@ -11,6 +11,7 @@ import { enablePushNotifications, disablePushNotifications, hasNotificationPreferences, + setMarketingNotificationPreferencesEnabled, type setContentPreviewToken as setContentPreviewTokenFn, type getContentPreviewToken as getContentPreviewTokenFn, type subscribeToContentPreviewToken as subscribeToContentPreviewTokenFn, @@ -64,6 +65,13 @@ describe('helpers - enableNotificationServices()', () => { Engine.context.NotificationServicesController.enableMetamaskNotifications, ).toHaveBeenCalledWith(options); }); + + it('forwards enable notification options', async () => { + await enableNotifications({ registerPushNotifications: false }); + expect( + Engine.context.NotificationServicesController.enableMetamaskNotifications, + ).toHaveBeenCalledWith({ registerPushNotifications: false }); + }); }); describe('helpers - hasNotificationPreferences()', () => { @@ -89,6 +97,61 @@ describe('helpers - hasNotificationPreferences()', () => { }); }); +describe('helpers - setMarketingNotificationPreferencesEnabled()', () => { + it('updates marketing notification preferences when AUS preferences exist', async () => { + const preferences = { + walletActivity: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + accounts: [], + }, + marketing: { + inAppNotificationsEnabled: false, + pushNotificationsEnabled: false, + }, + perps: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + }, + socialAI: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + txAmountLimit: 500, + mutedTraderProfileIds: [], + }, + }; + jest.mocked(Engine.controllerMessenger.call).mockResolvedValue(preferences); + + await setMarketingNotificationPreferencesEnabled(true); + + expect(Engine.controllerMessenger.call).toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + expect(Engine.controllerMessenger.call).toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:putNotificationPreferences', + { + ...preferences, + marketing: { + inAppNotificationsEnabled: true, + pushNotificationsEnabled: true, + }, + }, + 'mobile', + ); + }); + + it('does not persist when AUS preferences are missing', async () => { + jest.mocked(Engine.controllerMessenger.call).mockResolvedValue(null); + + await setMarketingNotificationPreferencesEnabled(true); + + expect(Engine.controllerMessenger.call).toHaveBeenCalledTimes(1); + expect(Engine.controllerMessenger.call).toHaveBeenCalledWith( + 'AuthenticatedUserStorageService:getNotificationPreferences', + ); + }); +}); + describe('helpers - disableNotificationServices()', () => { it('invoke notification services method', async () => { await disableNotifications(); diff --git a/app/actions/notification/helpers/index.ts b/app/actions/notification/helpers/index.ts index e9bf8678741..ff970f48348 100644 --- a/app/actions/notification/helpers/index.ts +++ b/app/actions/notification/helpers/index.ts @@ -6,6 +6,12 @@ import type { import Engine from '../../../core/Engine'; import { isNotificationsFeatureEnabled } from '../../../util/notifications'; +const CLIENT_TYPE = 'mobile' as const; +const GET_NOTIFICATION_PREFERENCES_ACTION = + 'AuthenticatedUserStorageService:getNotificationPreferences' as const; +const PUT_NOTIFICATION_PREFERENCES_ACTION = + 'AuthenticatedUserStorageService:putNotificationPreferences' as const; + const previewTokenEventEmitter = new EventEmitter2(); const PREVIEW_TOKEN_UPDATE_EVENT = 'previewTokenUpdate'; let previewToken: string | undefined; @@ -57,11 +63,37 @@ export const enableNotifications = async ( export const hasNotificationPreferences = async () => { assertIsFeatureEnabled(); const preferences = await Engine.controllerMessenger.call( - 'AuthenticatedUserStorageService:getNotificationPreferences', + GET_NOTIFICATION_PREFERENCES_ACTION, ); return preferences != null; }; +export const setMarketingNotificationPreferencesEnabled = async ( + isEnabled: boolean, +) => { + assertIsFeatureEnabled(); + const preferences = await Engine.controllerMessenger.call( + GET_NOTIFICATION_PREFERENCES_ACTION, + ); + + if (!preferences) { + return; + } + + await Engine.controllerMessenger.call( + PUT_NOTIFICATION_PREFERENCES_ACTION, + { + ...preferences, + marketing: { + ...preferences.marketing, + inAppNotificationsEnabled: isEnabled, + pushNotificationsEnabled: isEnabled, + }, + }, + CLIENT_TYPE, + ); +}; + /** * Disable Notifications Switch * - Disables wallet notifications, feature announcements, and push notifications diff --git a/app/components/Nav/Main/index.js b/app/components/Nav/Main/index.js index 848c9532954..0453b21e4bf 100644 --- a/app/components/Nav/Main/index.js +++ b/app/components/Nav/Main/index.js @@ -34,6 +34,7 @@ import { } from '../../../actions/notification'; import ProtectYourWalletModal from '../../UI/ProtectYourWalletModal'; +import PushNotificationOnboardingRoot from '../../Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot'; import MainNavigator from './MainNavigator'; import { query } from '@metamask/controller-utils'; import EarnTransactionMonitor from '../../UI/Earn/components/EarnTransactionMonitor'; @@ -435,6 +436,7 @@ const Main = (props) => { + ); diff --git a/app/components/Nav/Main/index.test.tsx b/app/components/Nav/Main/index.test.tsx index 9b8d208b42f..7e858613f95 100644 --- a/app/components/Nav/Main/index.test.tsx +++ b/app/components/Nav/Main/index.test.tsx @@ -89,6 +89,15 @@ jest.mock( }), ); +jest.mock( + '../../Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot', + () => ({ + __esModule: true, + default: () => + mockReact.createElement('PushNotificationOnboardingRootMock'), + }), +); + jest.mock('../../UI/ReviewModal', () => ({ __esModule: true, default: () => mockReact.createElement('ReviewModalMock'), diff --git a/app/components/Views/Notifications/PushNotificationOnboarding/NewUserSheet/NewUserSheet.test.tsx b/app/components/Views/Notifications/PushNotificationOnboarding/NewUserSheet/NewUserSheet.test.tsx index e758c730b2a..91e0b8fc0b5 100644 --- a/app/components/Views/Notifications/PushNotificationOnboarding/NewUserSheet/NewUserSheet.test.tsx +++ b/app/components/Views/Notifications/PushNotificationOnboarding/NewUserSheet/NewUserSheet.test.tsx @@ -5,6 +5,8 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { strings } from '../../../../../../locales/i18n'; import { NewUserSheetSelectorsIDs } from './NewUserSheet.testIds'; +const mockOnCloseBottomSheet = jest.fn((callback?: () => void) => callback?.()); + jest.mock( '../../../../../component-library/components/BottomSheets/BottomSheet', () => { @@ -13,7 +15,7 @@ jest.mock( // eslint-disable-next-line @typescript-eslint/no-explicit-any ({ children }: any, ref: any) => { MockReact.useImperativeHandle(ref, () => ({ - onCloseBottomSheet: (callback?: () => void) => callback?.(), + onCloseBottomSheet: mockOnCloseBottomSheet, })); return children; }, @@ -82,13 +84,17 @@ describe('NewUserSheet', () => { ).toBeOnTheScreen(); }); - it('calls onYes when Yes is pressed', () => { + it('closes the sheet before calling onYes when Yes is pressed', () => { const mockOnYes = jest.fn(); const { getByTestId } = renderWithProvider( , ); fireEvent.press(getByTestId(NewUserSheetSelectorsIDs.BUTTON_YES)); expect(mockOnYes).toHaveBeenCalledTimes(1); + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + expect(mockOnCloseBottomSheet.mock.invocationCallOrder[0]).toBeLessThan( + mockOnYes.mock.invocationCallOrder[0], + ); }); it('calls onNotNow when Not now is pressed', () => { @@ -98,5 +104,9 @@ describe('NewUserSheet', () => { ); fireEvent.press(getByTestId(NewUserSheetSelectorsIDs.BUTTON_NOT_NOW)); expect(mockOnNotNow).toHaveBeenCalledTimes(1); + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + expect(mockOnCloseBottomSheet.mock.invocationCallOrder[0]).toBeLessThan( + mockOnNotNow.mock.invocationCallOrder[0], + ); }); }); diff --git a/app/components/Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot.test.tsx b/app/components/Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot.test.tsx new file mode 100644 index 00000000000..26394997d8d --- /dev/null +++ b/app/components/Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot.test.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { act, render } from '@testing-library/react-native'; +import PushNotificationOnboardingRoot from './PushNotificationOnboardingRoot'; +import PushNotificationOnboarding, { + type PushPrePromptCompletionReason, +} from '.'; +import { usePushPrePromptVariant } from '../../../../util/notifications/hooks/usePushPrePromptVariant'; + +jest.mock( + '../../../../util/notifications/hooks/usePushPrePromptVariant', + () => ({ + usePushPrePromptVariant: jest.fn(), + }), +); + +jest.mock('.', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +const mockUsePushPrePromptVariant = jest.mocked(usePushPrePromptVariant); +const mockPushNotificationOnboarding = jest.mocked(PushNotificationOnboarding); + +const mockDismissPrePrompt = jest.fn(); +const mockMarkPrePromptShown = jest.fn(); +let mockIsE2EValue = false; + +jest.mock('../../../../util/test/utils', () => ({ + get isE2E() { + return mockIsE2EValue; + }, +})); + +const mockPrePromptState = ({ + nativeOsPermissionEnabled = null, + variant = null, +}: Partial> = {}) => { + mockUsePushPrePromptVariant.mockReturnValue({ + dismiss: mockDismissPrePrompt, + isResolving: false, + markShown: mockMarkPrePromptShown, + nativeOsPermissionEnabled, + variant, + }); +}; + +const getLatestProps = () => + mockPushNotificationOnboarding.mock.calls[ + mockPushNotificationOnboarding.mock.calls.length - 1 + ][0]; + +describe('PushNotificationOnboardingRoot', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsE2EValue = false; + mockPrePromptState(); + }); + + afterEach(() => { + mockIsE2EValue = false; + }); + + it('does not render or resolve the pre-prompt during e2e runs', () => { + mockIsE2EValue = true; + mockPrePromptState({ + nativeOsPermissionEnabled: false, + variant: 'push_permission', + }); + + render(); + + expect(mockUsePushPrePromptVariant).not.toHaveBeenCalled(); + expect(mockPushNotificationOnboarding).not.toHaveBeenCalled(); + }); + + it('does not render the sheet when no variant is available', () => { + mockPrePromptState({ variant: null }); + + render(); + + expect(mockPushNotificationOnboarding).not.toHaveBeenCalled(); + }); + + it('renders the resolved pre-prompt variant', () => { + mockPrePromptState({ + nativeOsPermissionEnabled: false, + variant: 'push_permission', + }); + + render(); + + expect(getLatestProps()).toEqual( + expect.objectContaining({ + dismissPrePrompt: mockDismissPrePrompt, + isVisible: true, + markPrePromptShown: mockMarkPrePromptShown, + nativeOsPermissionEnabled: false, + prePromptVariant: 'push_permission', + }), + ); + }); + + it('keeps a visible variant latched until the pre-prompt completes', () => { + mockPrePromptState({ + nativeOsPermissionEnabled: true, + variant: 'push_permission', + }); + const { rerender } = render(); + + mockPrePromptState({ variant: null }); + rerender(); + + expect(getLatestProps()).toEqual( + expect.objectContaining({ + prePromptVariant: 'push_permission', + nativeOsPermissionEnabled: true, + }), + ); + + act(() => { + getLatestProps().onComplete('dismiss' as PushPrePromptCompletionReason); + }); + + mockPrePromptState({ variant: null }); + mockPushNotificationOnboarding.mockClear(); + rerender(); + + expect(mockPushNotificationOnboarding).not.toHaveBeenCalled(); + }); + + it('latches the native OS permission status for the visible pre-prompt', () => { + mockPrePromptState({ + nativeOsPermissionEnabled: true, + variant: 'push_permission', + }); + const { rerender } = render(); + + mockPrePromptState({ + nativeOsPermissionEnabled: false, + variant: 'marketing_consent', + }); + rerender(); + + expect(getLatestProps()).toEqual( + expect.objectContaining({ + nativeOsPermissionEnabled: true, + prePromptVariant: 'push_permission', + }), + ); + }); +}); diff --git a/app/components/Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot.tsx b/app/components/Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot.tsx new file mode 100644 index 00000000000..d6a98767399 --- /dev/null +++ b/app/components/Views/Notifications/PushNotificationOnboarding/PushNotificationOnboardingRoot.tsx @@ -0,0 +1,69 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import PushNotificationOnboarding, { + type PushPrePromptCompletionReason, +} from '.'; +import { + usePushPrePromptVariant, + type PushPrePromptVariant, +} from '../../../../util/notifications/hooks/usePushPrePromptVariant'; +import { isE2E } from '../../../../util/test/utils'; + +type VisibleVariant = Exclude; +interface VisiblePrePrompt { + nativeOsPermissionEnabled: boolean | null; + variant: VisibleVariant; +} + +const PushNotificationOnboardingRootContent = () => { + const { + dismiss: dismissPrePrompt, + markShown: markPrePromptShown, + nativeOsPermissionEnabled, + variant, + } = usePushPrePromptVariant(); + + const [visiblePrePrompt, setVisiblePrePrompt] = + useState(null); + + useEffect(() => { + if (variant && !visiblePrePrompt) { + setVisiblePrePrompt({ nativeOsPermissionEnabled, variant }); + } + }, [nativeOsPermissionEnabled, variant, visiblePrePrompt]); + + const currentPrePrompt = + visiblePrePrompt ?? + (variant ? { nativeOsPermissionEnabled, variant } : null); + + const handleComplete = useCallback( + (_reason: PushPrePromptCompletionReason) => { + setVisiblePrePrompt(null); + }, + [], + ); + + if (!currentPrePrompt) { + return null; + } + + return ( + + ); +}; + +const PushNotificationOnboardingRoot = () => { + if (isE2E) { + return null; + } + + return ; +}; + +export default PushNotificationOnboardingRoot; diff --git a/app/components/Views/Notifications/PushNotificationOnboarding/index.test.tsx b/app/components/Views/Notifications/PushNotificationOnboarding/index.test.tsx new file mode 100644 index 00000000000..a6b992c9b0f --- /dev/null +++ b/app/components/Views/Notifications/PushNotificationOnboarding/index.test.tsx @@ -0,0 +1,451 @@ +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import { Platform } from 'react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import { + ToastContext, + ToastVariants, +} from '../../../../component-library/components/Toast'; +import PushNotificationOnboarding from '.'; +import type { PushPrePromptVariant } from '../../../../util/notifications/hooks/usePushPrePromptVariant'; + +const mockMarkPrePromptShown = jest.fn().mockResolvedValue(undefined); +const mockDismissPrePrompt = jest.fn(); +const mockRequestPushPermission = jest.fn(); +const mockEnableNotificationsInBackground = jest.fn(); +const mockEnableMarketingConsent = jest.fn(); +const mockShowToast = jest.fn(); +const mockTrackPrePromptViewed = jest.fn(); +const mockTrackPrePromptDismissed = jest.fn(); +const mockTrackPrePromptButtonClicked = jest.fn(); +const mockTrackOsPromptShown = jest.fn(); +const mockTrackOsPromptResponse = jest.fn(); +const mockIdentifyMarketingConsent = jest.fn(); +const mockIdentifyPushNotificationsEnabled = jest.fn(); +const mockOnComplete = jest.fn(); + +jest.mock( + '../../../../util/notifications/hooks/usePushPermissionNotificationSetup', + () => ({ + usePushPermissionNotificationSetup: () => ({ + enableNotificationsInBackground: mockEnableNotificationsInBackground, + requestPushPermission: mockRequestPushPermission, + }), + }), +); + +jest.mock( + '../../../../util/notifications/hooks/useEnableMarketingConsent', + () => ({ + useEnableMarketingConsent: () => ({ + enableMarketingConsent: mockEnableMarketingConsent, + }), + }), +); + +jest.mock( + '../../../../util/notifications/hooks/usePushPrePromptAnalytics', + () => ({ + usePushPrePromptAnalytics: () => ({ + trackPrePromptViewed: mockTrackPrePromptViewed, + trackPrePromptDismissed: mockTrackPrePromptDismissed, + trackPrePromptButtonClicked: mockTrackPrePromptButtonClicked, + trackOsPromptShown: mockTrackOsPromptShown, + trackOsPromptResponse: mockTrackOsPromptResponse, + identifyMarketingConsent: mockIdentifyMarketingConsent, + identifyPushNotificationsEnabled: mockIdentifyPushNotificationsEnabled, + }), + }), +); + +jest.mock( + '../../../../util/notifications/services/NotificationService', + () => ({ + __esModule: true, + isPushPermissionPromptable: jest.fn(), + }), +); +const mockNotificationService = jest.requireMock( + '../../../../util/notifications/services/NotificationService', +); +const mockIsPushPermissionPromptable = + mockNotificationService.isPushPermissionPromptable as jest.Mock; + +jest.mock('./NewUserSheet', () => ({ + __esModule: true, + default: (props: { + isVisible: boolean; + onClose: (hasPendingAction?: boolean) => void; + onYes: () => void; + onNotNow: () => void; + }) => { + const MockReact = jest.requireActual('react'); + const { Pressable: MockPressable, View: MockView } = + jest.requireActual('react-native'); + + return props.isVisible + ? MockReact.createElement( + MockView, + { testID: 'mock-push-permission-sheet' }, + MockReact.createElement(MockPressable, { + testID: 'mock-push-permission-dismiss', + onPress: props.onClose, + }), + MockReact.createElement(MockPressable, { + testID: 'mock-push-permission-action-close', + onPress: () => props.onClose(true), + }), + MockReact.createElement(MockPressable, { + testID: 'mock-push-permission-yes', + onPress: props.onYes, + }), + MockReact.createElement(MockPressable, { + testID: 'mock-push-permission-not-now', + onPress: props.onNotNow, + }), + ) + : null; + }, +})); + +jest.mock('./ExistingUserSheet', () => ({ + __esModule: true, + default: (props: { + isVisible: boolean; + onConfirm: () => void; + onNotNow: () => void; + }) => { + const MockReact = jest.requireActual('react'); + const { Pressable: MockPressable, View: MockView } = + jest.requireActual('react-native'); + + return props.isVisible + ? MockReact.createElement( + MockView, + { testID: 'mock-marketing-consent-sheet' }, + MockReact.createElement(MockPressable, { + testID: 'mock-marketing-consent-confirm', + onPress: props.onConfirm, + }), + MockReact.createElement(MockPressable, { + testID: 'mock-marketing-consent-not-now', + onPress: props.onNotNow, + }), + ) + : null; + }, +})); + +const renderPushNotificationOnboarding = ({ + isVisible = true, + nativeOsPermissionEnabled = false, + prePromptVariant = 'push_permission', +}: { + isVisible?: boolean; + nativeOsPermissionEnabled?: boolean | null; + prePromptVariant?: PushPrePromptVariant; +} = {}) => + renderWithProvider( + + + , + { + state: { + security: { + dataCollectionForMarketing: false, + }, + }, + }, + ); + +const expectNotificationsOnToast = () => { + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Plain, + labelOptions: [{ label: 'Notifications are on', isBold: true }], + descriptionOptions: { + description: "We'll send you transactions, price alerts, and updates.", + }, + startAccessory: expect.any(Object), + customBottomOffset: expect.any(Number), + hasNoTimeout: false, + }), + ); +}; + +const expectNotificationsOffToast = () => { + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Plain, + labelOptions: [{ label: 'Notifications are off', isBold: true }], + descriptionOptions: { + description: 'Turn them on anytime in Settings → Notifications.', + }, + startAccessory: expect.any(Object), + customBottomOffset: expect.any(Number), + hasNoTimeout: false, + }), + ); +}; + +const expectPersonalizedAlertsOnToast = () => { + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Plain, + labelOptions: [{ label: 'Personalized alerts is on', isBold: true }], + descriptionOptions: { + description: 'Manage this anytime in Settings.', + }, + startAccessory: expect.any(Object), + customBottomOffset: expect.any(Number), + hasNoTimeout: false, + }), + ); +}; + +const expectPersonalizedAlertsOffToast = () => { + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Plain, + labelOptions: [{ label: 'Personalized alerts is off', isBold: true }], + descriptionOptions: { + description: 'Turn it on anytime in Settings.', + }, + startAccessory: expect.any(Object), + customBottomOffset: expect.any(Number), + hasNoTimeout: false, + }), + ); +}; + +describe('PushNotificationOnboarding', () => { + beforeEach(() => { + jest.clearAllMocks(); + Platform.OS = 'ios'; + mockEnableMarketingConsent.mockResolvedValue(undefined); + mockRequestPushPermission.mockResolvedValue(false); + mockIsPushPermissionPromptable.mockResolvedValue(true); + mockIdentifyMarketingConsent.mockResolvedValue(undefined); + mockIdentifyPushNotificationsEnabled.mockResolvedValue(undefined); + }); + + it('marks the prompt as shown when the push permission sheet renders', async () => { + renderPushNotificationOnboarding(); + + await waitFor(() => { + expect(mockMarkPrePromptShown).toHaveBeenCalledTimes(1); + }); + expect(mockTrackPrePromptViewed).toHaveBeenCalledWith('push_permission'); + }); + + it('does not render or mark shown when not visible', () => { + const { queryByTestId } = renderPushNotificationOnboarding({ + isVisible: false, + }); + + expect(queryByTestId('mock-push-permission-sheet')).toBeNull(); + expect(mockMarkPrePromptShown).not.toHaveBeenCalled(); + }); + + it('requests OS permission, grants marketing consent, and starts background setup with push when Yes is pressed', async () => { + mockRequestPushPermission.mockResolvedValue(true); + const { getByTestId } = renderPushNotificationOnboarding(); + + fireEvent.press(getByTestId('mock-push-permission-yes')); + + await waitFor(() => { + expect(mockOnComplete).toHaveBeenCalledWith('engage'); + }); + expect(mockRequestPushPermission).toHaveBeenCalledTimes(1); + expect(mockEnableMarketingConsent).toHaveBeenCalledTimes(1); + expect(mockEnableNotificationsInBackground).toHaveBeenCalledWith(true); + expect(mockDismissPrePrompt).toHaveBeenCalledTimes(1); + expect(mockTrackPrePromptButtonClicked).toHaveBeenCalledWith( + 'push_permission', + 'yes', + ); + expect(mockTrackOsPromptShown).toHaveBeenCalledWith('push_permission'); + expect(mockTrackOsPromptResponse).toHaveBeenCalledWith( + 'push_permission', + 'allowed', + ); + expect(mockIdentifyPushNotificationsEnabled).toHaveBeenCalledWith(true); + expectNotificationsOnToast(); + expect(mockOnComplete.mock.invocationCallOrder[0]).toBeLessThan( + mockEnableNotificationsInBackground.mock.invocationCallOrder[0], + ); + }); + + it('starts background setup without push when OS permission is denied', async () => { + const { getByTestId } = renderPushNotificationOnboarding(); + + fireEvent.press(getByTestId('mock-push-permission-yes')); + + await waitFor(() => { + expect(mockOnComplete).toHaveBeenCalledWith('engage'); + }); + expect(mockRequestPushPermission).toHaveBeenCalledTimes(1); + expect(mockEnableMarketingConsent).toHaveBeenCalledTimes(1); + expect(mockEnableNotificationsInBackground).toHaveBeenCalledWith(false); + expect(mockTrackOsPromptResponse).toHaveBeenCalledWith( + 'push_permission', + 'denied', + ); + expect(mockIdentifyPushNotificationsEnabled).toHaveBeenCalledWith(false); + expectNotificationsOffToast(); + }); + + it.each(['ios', 'android'] as const)( + 'skips the OS prompt and treats permission as denied on %s when native permission was previously denied', + async (platform) => { + Platform.OS = platform; + mockIsPushPermissionPromptable.mockResolvedValue(false); + const { getByTestId } = renderPushNotificationOnboarding(); + + fireEvent.press(getByTestId('mock-push-permission-yes')); + + await waitFor(() => { + expect(mockOnComplete).toHaveBeenCalledWith('engage'); + }); + expect(mockEnableMarketingConsent).toHaveBeenCalledTimes(1); + expect(mockRequestPushPermission).not.toHaveBeenCalled(); + expect(mockTrackOsPromptShown).not.toHaveBeenCalled(); + expect(mockTrackOsPromptResponse).not.toHaveBeenCalled(); + expect(mockIdentifyPushNotificationsEnabled).toHaveBeenCalledWith(false); + expect(mockEnableNotificationsInBackground).toHaveBeenCalledWith(false); + expectNotificationsOffToast(); + }, + ); + + it('keeps the pre-prompt pending until the OS prompt result resolves', async () => { + let resolveRequestPushPermission: (isEnabled: boolean) => void = jest.fn(); + mockRequestPushPermission.mockReturnValue( + new Promise((resolve) => { + resolveRequestPushPermission = resolve; + }), + ); + const { getByTestId } = renderPushNotificationOnboarding(); + + fireEvent.press(getByTestId('mock-push-permission-yes')); + + await waitFor(() => { + expect(mockRequestPushPermission).toHaveBeenCalledTimes(1); + }); + expect(mockOnComplete).not.toHaveBeenCalled(); + expect(mockDismissPrePrompt).not.toHaveBeenCalled(); + expect(mockEnableNotificationsInBackground).not.toHaveBeenCalled(); + + resolveRequestPushPermission(true); + + await waitFor(() => { + expect(mockOnComplete).toHaveBeenCalledWith('engage'); + }); + expect(mockDismissPrePrompt).toHaveBeenCalledTimes(1); + expect(mockEnableNotificationsInBackground).toHaveBeenCalledWith(true); + }); + + it('skips the OS permission request when native push is already enabled', async () => { + const { getByTestId } = renderPushNotificationOnboarding({ + nativeOsPermissionEnabled: true, + }); + + fireEvent.press(getByTestId('mock-push-permission-yes')); + + await waitFor(() => { + expect(mockOnComplete).toHaveBeenCalledWith('engage'); + }); + expect(mockRequestPushPermission).not.toHaveBeenCalled(); + expect(mockTrackOsPromptShown).not.toHaveBeenCalled(); + expect(mockTrackOsPromptResponse).not.toHaveBeenCalled(); + expect(mockIdentifyPushNotificationsEnabled).toHaveBeenCalledWith(true); + expect(mockEnableMarketingConsent).toHaveBeenCalledTimes(1); + expect(mockEnableNotificationsInBackground).toHaveBeenCalledWith(true); + expectNotificationsOnToast(); + }); + + it('does not request notifications when Not now is pressed', () => { + const { getByTestId } = renderPushNotificationOnboarding(); + + fireEvent.press(getByTestId('mock-push-permission-not-now')); + + expect(mockRequestPushPermission).not.toHaveBeenCalled(); + expect(mockEnableNotificationsInBackground).not.toHaveBeenCalled(); + expect(mockDismissPrePrompt).toHaveBeenCalledTimes(1); + expect(mockOnComplete).toHaveBeenCalledWith('dismiss'); + expect(mockTrackPrePromptButtonClicked).toHaveBeenCalledWith( + 'push_permission', + 'not_now', + ); + expectNotificationsOffToast(); + }); + + it('sets marketing consent when the marketing prompt is confirmed', () => { + const { getByTestId } = renderPushNotificationOnboarding({ + prePromptVariant: 'marketing_consent', + }); + + fireEvent.press(getByTestId('mock-marketing-consent-confirm')); + + expect(mockOnComplete).toHaveBeenCalledWith('engage'); + expect(mockRequestPushPermission).not.toHaveBeenCalled(); + expect(mockEnableNotificationsInBackground).not.toHaveBeenCalled(); + expect(mockEnableMarketingConsent).toHaveBeenCalledTimes(1); + expect(mockTrackPrePromptButtonClicked).toHaveBeenCalledWith( + 'marketing_consent', + 'confirm', + ); + expectPersonalizedAlertsOnToast(); + }); + + it('does not enable marketing notifications when the marketing prompt is skipped', () => { + const { getByTestId } = renderPushNotificationOnboarding({ + prePromptVariant: 'marketing_consent', + }); + + fireEvent.press(getByTestId('mock-marketing-consent-not-now')); + + expect(mockOnComplete).toHaveBeenCalledWith('dismiss'); + expect(mockEnableMarketingConsent).not.toHaveBeenCalled(); + expect(mockTrackPrePromptButtonClicked).toHaveBeenCalledWith( + 'marketing_consent', + 'not_now', + ); + expect(mockIdentifyMarketingConsent).toHaveBeenCalledWith(false); + expectPersonalizedAlertsOffToast(); + }); + + it('does not dismiss when the sheet closes for a pending button action', () => { + const { getByTestId } = renderPushNotificationOnboarding(); + + fireEvent.press(getByTestId('mock-push-permission-action-close')); + + expect(mockDismissPrePrompt).not.toHaveBeenCalled(); + expect(mockTrackPrePromptDismissed).not.toHaveBeenCalled(); + }); + + it('dismisses when the sheet is closed directly', () => { + const { getByTestId } = renderPushNotificationOnboarding(); + + fireEvent.press(getByTestId('mock-push-permission-dismiss')); + + expect(mockDismissPrePrompt).toHaveBeenCalledTimes(1); + expect(mockOnComplete).toHaveBeenCalledWith('dismiss'); + expect(mockTrackPrePromptDismissed).toHaveBeenCalledWith('push_permission'); + }); +}); diff --git a/app/components/Views/Notifications/PushNotificationOnboarding/index.tsx b/app/components/Views/Notifications/PushNotificationOnboarding/index.tsx new file mode 100644 index 00000000000..bcd19de133b --- /dev/null +++ b/app/components/Views/Notifications/PushNotificationOnboarding/index.tsx @@ -0,0 +1,284 @@ +import React, { useCallback, useContext, useEffect, useRef } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { strings } from '../../../../../locales/i18n'; +import { + ToastContext, + ToastVariants, +} from '../../../../component-library/components/Toast'; +import Icon, { + IconColor, + IconName, + IconSize, +} from '../../../../component-library/components/Icons/Icon'; +import { useEnableMarketingConsent } from '../../../../util/notifications/hooks/useEnableMarketingConsent'; +import { usePushPermissionNotificationSetup } from '../../../../util/notifications/hooks/usePushPermissionNotificationSetup'; +import { PushPrePromptVariant } from '../../../../util/notifications/hooks/usePushPrePromptVariant'; +import { usePushPrePromptAnalytics } from '../../../../util/notifications/hooks/usePushPrePromptAnalytics'; +import { isPushPermissionPromptable } from '../../../../util/notifications/services/NotificationService'; +import { TAB_BAR_HEIGHT } from '../../../../component-library/components/Navigation/TabBar/TabBar.constants'; +import ExistingUserSheet from './ExistingUserSheet'; +import NewUserSheet from './NewUserSheet'; + +export type PushPrePromptCompletionReason = 'complete' | 'dismiss' | 'engage'; + +interface PushNotificationOnboardingProps { + dismissPrePrompt: () => void; + isVisible: boolean; + markPrePromptShown: () => Promise; + nativeOsPermissionEnabled: boolean | null; + onComplete: (reason: PushPrePromptCompletionReason) => void; + prePromptVariant: PushPrePromptVariant; +} + +const styles = StyleSheet.create({ + toastAccessory: { + alignSelf: 'flex-start', + marginRight: 12, + paddingTop: 4, + }, +}); + +const METRICS_OPT_IN_LOCATION = 'push_pre_prompt'; + +const PushNotificationOnboarding = ({ + dismissPrePrompt, + isVisible, + markPrePromptShown, + nativeOsPermissionEnabled, + onComplete, + prePromptVariant, +}: PushNotificationOnboardingProps) => { + // Helpers to request OS push permission and finish wiring up notifications once granted. + const { enableNotificationsInBackground, requestPushPermission } = + usePushPermissionNotificationSetup(); + + const { toastRef } = useContext(ToastContext); + const viewedPrePromptVariant = useRef(null); + + // Analytics emitters for every stage of the pre-prompt → OS prompt funnel. + const { + trackPrePromptViewed, + trackPrePromptDismissed, + trackPrePromptButtonClicked, + trackOsPromptShown, + trackOsPromptResponse, + identifyMarketingConsent, + identifyPushNotificationsEnabled, + } = usePushPrePromptAnalytics(); + + // Opt the user into marketing consent (and MetaMetrics if needed) when they accept the prompt. + const { enableMarketingConsent } = useEnableMarketingConsent({ + metricsOptInLocation: METRICS_OPT_IN_LOCATION, + }); + + // Mark each variant as shown once, when its sheet first becomes visible. + useEffect(() => { + if ( + !isVisible || + !prePromptVariant || + viewedPrePromptVariant.current === prePromptVariant + ) { + return; + } + + viewedPrePromptVariant.current = prePromptVariant; + markPrePromptShown().catch(() => undefined); + trackPrePromptViewed(prePromptVariant); + }, [isVisible, markPrePromptShown, prePromptVariant, trackPrePromptViewed]); + + const showNotificationStatusToast = useCallback( + ({ + isEnabled, + title, + description, + }: { + isEnabled: boolean; + title: string; + description: string; + }) => { + const iconColor = isEnabled ? IconColor.Success : IconColor.Alternative; + + toastRef?.current?.showToast({ + variant: ToastVariants.Plain, + labelOptions: [ + { + label: title, + isBold: true, + }, + ], + descriptionOptions: { + description, + }, + startAccessory: ( + + + + ), + customBottomOffset: TAB_BAR_HEIGHT, + hasNoTimeout: false, + }); + }, + [toastRef], + ); + + const showPushPermissionToast = useCallback( + (areNotificationsEnabled: boolean) => { + showNotificationStatusToast({ + isEnabled: areNotificationsEnabled, + title: strings( + areNotificationsEnabled + ? 'notifications.push_onboarding.new_user.toast.notifications_on.title' + : 'notifications.push_onboarding.new_user.toast.notifications_off.title', + ), + description: strings( + areNotificationsEnabled + ? 'notifications.push_onboarding.new_user.toast.notifications_on.description' + : 'notifications.push_onboarding.new_user.toast.notifications_off.description', + ), + }); + }, + [showNotificationStatusToast], + ); + + const showMarketingConsentToast = useCallback( + (arePersonalizedAlertsEnabled: boolean) => { + showNotificationStatusToast({ + isEnabled: arePersonalizedAlertsEnabled, + title: strings( + arePersonalizedAlertsEnabled + ? 'notifications.push_onboarding.existing_user.toast.personalized_alerts_on.title' + : 'notifications.push_onboarding.existing_user.toast.personalized_alerts_off.title', + ), + description: strings( + arePersonalizedAlertsEnabled + ? 'notifications.push_onboarding.existing_user.toast.personalized_alerts_on.description' + : 'notifications.push_onboarding.existing_user.toast.personalized_alerts_off.description', + ), + }); + }, + [showNotificationStatusToast], + ); + + const handlePrePromptDismissed = useCallback( + (hasPendingAction?: boolean) => { + // BottomSheet onClose can fire while a CTA action is still running. + if (hasPendingAction) { + return; + } + if (prePromptVariant) { + trackPrePromptDismissed(prePromptVariant); + } + dismissPrePrompt(); + onComplete('dismiss'); + }, + [dismissPrePrompt, onComplete, prePromptVariant, trackPrePromptDismissed], + ); + + const handlePushPermissionYes = useCallback(async () => { + let nativePermissionEnabled = nativeOsPermissionEnabled === true; + trackPrePromptButtonClicked('push_permission', 'yes'); + try { + // Accepting push notifications also opts the user into marketing consent. + await enableMarketingConsent(); + + if (!nativePermissionEnabled) { + // A "denied" OS state means the dialog will not be shown again + // (iOS after any denial; Android 13+ after permanent denial; + // Android <13 when the user disabled notifications in Settings). + // Skip the request and treat it as denied in all those cases. + const isPromptable = await isPushPermissionPromptable(); + if (isPromptable) { + trackOsPromptShown('push_permission'); + nativePermissionEnabled = await requestPushPermission(); + trackOsPromptResponse( + 'push_permission', + nativePermissionEnabled ? 'allowed' : 'denied', + ); + } + } + identifyPushNotificationsEnabled(nativePermissionEnabled).catch( + () => undefined, + ); + showPushPermissionToast(nativePermissionEnabled); + } finally { + dismissPrePrompt(); + onComplete('engage'); + enableNotificationsInBackground(nativePermissionEnabled); + } + }, [ + dismissPrePrompt, + enableMarketingConsent, + enableNotificationsInBackground, + identifyPushNotificationsEnabled, + nativeOsPermissionEnabled, + onComplete, + requestPushPermission, + showPushPermissionToast, + trackOsPromptResponse, + trackOsPromptShown, + trackPrePromptButtonClicked, + ]); + + const handlePushPermissionNotNow = useCallback(() => { + dismissPrePrompt(); + onComplete('dismiss'); + trackPrePromptButtonClicked('push_permission', 'not_now'); + showPushPermissionToast(false); + }, [ + dismissPrePrompt, + onComplete, + showPushPermissionToast, + trackPrePromptButtonClicked, + ]); + + const handleMarketingConsentConfirm = useCallback(() => { + dismissPrePrompt(); + onComplete('engage'); + trackPrePromptButtonClicked('marketing_consent', 'confirm'); + enableMarketingConsent().catch(() => undefined); + showMarketingConsentToast(true); + }, [ + dismissPrePrompt, + enableMarketingConsent, + onComplete, + showMarketingConsentToast, + trackPrePromptButtonClicked, + ]); + + const handleMarketingConsentNotNow = useCallback(() => { + dismissPrePrompt(); + onComplete('dismiss'); + trackPrePromptButtonClicked('marketing_consent', 'not_now'); + identifyMarketingConsent(false).catch(() => undefined); + showMarketingConsentToast(false); + }, [ + dismissPrePrompt, + identifyMarketingConsent, + onComplete, + showMarketingConsentToast, + trackPrePromptButtonClicked, + ]); + + return ( + <> + + + + ); +}; + +export default PushNotificationOnboarding; diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 1246b431d7a..2fbfc482ef9 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -553,6 +553,9 @@ enum EVENT_NAME { NOTIFICATION_DETAIL_CLICKED = 'Notification Detail Clicked', // Push Notifications + PUSH_NOTIFICATION_PRE_PROMPT_VIEWED = 'Push Notification Pre-prompt Viewed', + PUSH_NOTIFICATION_PRE_PROMPT_BUTTON_CLICKED = 'Push Notification Pre-prompt Button Clicked', + OS_PUSH_NOTIFICATION_BUTTON_CLICKED = 'OS Push Notification Button Clicked', PUSH_NOTIFICATION_RECEIVED = 'Push Notification Received', PUSH_NOTIFICATION_CLICKED = 'Push Notification Clicked', @@ -1465,6 +1468,15 @@ const events = { ), // Push Notifications Flow + PUSH_NOTIFICATION_PRE_PROMPT_VIEWED: generateOpt( + EVENT_NAME.PUSH_NOTIFICATION_PRE_PROMPT_VIEWED, + ), + PUSH_NOTIFICATION_PRE_PROMPT_BUTTON_CLICKED: generateOpt( + EVENT_NAME.PUSH_NOTIFICATION_PRE_PROMPT_BUTTON_CLICKED, + ), + OS_PUSH_NOTIFICATION_BUTTON_CLICKED: generateOpt( + EVENT_NAME.OS_PUSH_NOTIFICATION_BUTTON_CLICKED, + ), PUSH_NOTIFICATION_RECEIVED: generateOpt( EVENT_NAME.PUSH_NOTIFICATION_RECEIVED, ), diff --git a/app/selectors/engagement.test.ts b/app/selectors/engagement.test.ts new file mode 100644 index 00000000000..0caca0ce7b7 --- /dev/null +++ b/app/selectors/engagement.test.ts @@ -0,0 +1,21 @@ +import { selectDataCollectionForMarketingEnabled } from './engagement'; +import type { RootState } from '../reducers'; + +describe('engagement selectors', () => { + it.each([ + [true, true], + [false, false], + [false, null], + ])( + 'returns %s when dataCollectionForMarketing is %s', + (expected, dataCollectionForMarketing) => { + const state = { + security: { + dataCollectionForMarketing, + }, + } as RootState; + + expect(selectDataCollectionForMarketingEnabled(state)).toBe(expected); + }, + ); +}); diff --git a/app/selectors/engagement.ts b/app/selectors/engagement.ts new file mode 100644 index 00000000000..a55ec0ea942 --- /dev/null +++ b/app/selectors/engagement.ts @@ -0,0 +1,4 @@ +import type { RootState } from '../reducers'; + +export const selectDataCollectionForMarketingEnabled = (state: RootState) => + state.security?.dataCollectionForMarketing === true; diff --git a/app/store/sagas/backfillSocialLoginMarketingConsent.test.ts b/app/store/sagas/backfillSocialLoginMarketingConsent.test.ts index 43c3ccca503..f88111d8f70 100644 --- a/app/store/sagas/backfillSocialLoginMarketingConsent.test.ts +++ b/app/store/sagas/backfillSocialLoginMarketingConsent.test.ts @@ -86,8 +86,8 @@ describe('backfillSocialLoginMarketingConsent', () => { await expectSaga(backfillSocialLoginMarketingConsentSaga) .withState(state) .dispatch(loginAction) - .put(setPendingSocialLoginMarketingConsentBackfill(null)) .put(setDataCollectionForMarketing(true)) + .put(setPendingSocialLoginMarketingConsentBackfill(null)) .run(); expect(mockedGetMarketingOptInStatus).not.toHaveBeenCalled(); @@ -125,8 +125,8 @@ describe('backfillSocialLoginMarketingConsent', () => { await expectSaga(backfillSocialLoginMarketingConsentSaga) .withState(state) .dispatch(loginAction) - .put(setPendingSocialLoginMarketingConsentBackfill(null)) .put(setDataCollectionForMarketing(false)) + .put(setPendingSocialLoginMarketingConsentBackfill(null)) .run(); expect(mockedGetMarketingOptInStatus).toHaveBeenCalled(); @@ -166,8 +166,8 @@ describe('backfillSocialLoginMarketingConsent', () => { await expectSaga(backfillSocialLoginMarketingConsentSaga) .withState(state) .dispatch(loginAction) - .put(setPendingSocialLoginMarketingConsentBackfill(null)) .put(setDataCollectionForMarketing(true)) + .put(setPendingSocialLoginMarketingConsentBackfill(null)) .run(); expect(mockedGetMarketingOptInStatus).toHaveBeenCalled(); @@ -183,7 +183,7 @@ describe('backfillSocialLoginMarketingConsent', () => { ); }); - it('does not clear the marker when getMarketingOptInStatus rejects', async () => { + it('clears the marker when getMarketingOptInStatus rejects', async () => { mockedGetMarketingOptInStatus.mockRejectedValueOnce( new Error('no access token'), ); @@ -203,7 +203,7 @@ describe('backfillSocialLoginMarketingConsent', () => { await expectSaga(backfillSocialLoginMarketingConsentSaga) .withState(state) .dispatch(loginAction) - .not.put(setPendingSocialLoginMarketingConsentBackfill(null)) + .put(setPendingSocialLoginMarketingConsentBackfill(null)) .run(); expect(mockedLoggerError).toHaveBeenCalledWith( @@ -215,7 +215,7 @@ describe('backfillSocialLoginMarketingConsent', () => { expect(updateDataRecordingFlag).not.toHaveBeenCalled(); }); - it('does not clear the marker when trackEvent throws', async () => { + it('clears the marker when trackEvent throws', async () => { const state = { ...initialRootState, security: { @@ -235,7 +235,7 @@ describe('backfillSocialLoginMarketingConsent', () => { await expectSaga(backfillSocialLoginMarketingConsentSaga) .withState(state) .dispatch(loginAction) - .not.put(setPendingSocialLoginMarketingConsentBackfill(null)) + .put(setPendingSocialLoginMarketingConsentBackfill(null)) .run(); expect(mockedIdentify).toHaveBeenCalledWith({ @@ -243,4 +243,30 @@ describe('backfillSocialLoginMarketingConsent', () => { }); expect(updateDataRecordingFlag).not.toHaveBeenCalled(); }); + + it('persists fetched OAuth marketing consent before clearing the marker when analytics fails', async () => { + const state = { + ...initialRootState, + security: { + ...initialRootState.security, + dataCollectionForMarketing: false, + }, + onboarding: { + ...initialRootState.onboarding, + pendingSocialLoginMarketingConsentBackfill: 'google', + }, + }; + + mockedGetMarketingOptInStatus.mockResolvedValueOnce({ is_opt_in: true }); + mockedTrackEvent.mockImplementation(() => { + throw new Error('track failed'); + }); + + await expectSaga(backfillSocialLoginMarketingConsentSaga) + .withState(state) + .dispatch(loginAction) + .put(setDataCollectionForMarketing(true)) + .put(setPendingSocialLoginMarketingConsentBackfill(null)) + .run(); + }); }); diff --git a/app/store/sagas/backfillSocialLoginMarketingConsent.ts b/app/store/sagas/backfillSocialLoginMarketingConsent.ts index cc8f65aa1bf..3805e8ddb45 100644 --- a/app/store/sagas/backfillSocialLoginMarketingConsent.ts +++ b/app/store/sagas/backfillSocialLoginMarketingConsent.ts @@ -27,6 +27,7 @@ export function* backfillSocialLoginMarketingConsentSaga() { yield select( (state: RootState) => state.security?.dataCollectionForMarketing, ); + let fetchedMarketingConsent = false; try { if (marketingConsent !== true) { @@ -34,17 +35,20 @@ export function* backfillSocialLoginMarketingConsentSaga() { ReturnType > = yield call([OAuthService, OAuthService.getMarketingOptInStatus]); marketingConsent = marketingOptIn.is_opt_in; + fetchedMarketingConsent = true; } + const resolvedMarketingConsent = Boolean(marketingConsent); + yield call([analytics, analytics.identify], { - [UserProfileProperty.HAS_MARKETING_CONSENT]: Boolean(marketingConsent), + [UserProfileProperty.HAS_MARKETING_CONSENT]: resolvedMarketingConsent, }); const event = AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED, ) .setSaveDataRecording(true) .addProperties({ - [UserProfileProperty.HAS_MARKETING_CONSENT]: Boolean(marketingConsent), + [UserProfileProperty.HAS_MARKETING_CONSENT]: resolvedMarketingConsent, is_metrics_opted_in: true, location: 'saga_backfill_marketing_consent', updated_after_onboarding: true, @@ -55,12 +59,16 @@ export function* backfillSocialLoginMarketingConsentSaga() { yield call([analytics, analytics.trackEvent], event); yield call(updateDataRecordingFlag, true); + yield put(setDataCollectionForMarketing(resolvedMarketingConsent)); yield put(setPendingSocialLoginMarketingConsentBackfill(null)); - yield put(setDataCollectionForMarketing(marketingConsent)); } catch (error) { Logger.error( error as Error, 'Failed to backfill social login marketing consent analytics', ); + if (fetchedMarketingConsent) { + yield put(setDataCollectionForMarketing(Boolean(marketingConsent))); + } + yield put(setPendingSocialLoginMarketingConsentBackfill(null)); } } diff --git a/app/util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types.ts b/app/util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types.ts index fdb080eec34..ac18db954e0 100644 --- a/app/util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types.ts +++ b/app/util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types.ts @@ -14,6 +14,7 @@ export enum UserProfileProperty { PRIMARY_CURRENCY = 'primary_currency', CURRENT_CURRENCY = 'current_currency', HAS_MARKETING_CONSENT = 'has_marketing_consent', + PUSH_NOTIFICATIONS_ENABLED = 'push_notifications_enabled', NUMBER_OF_HD_ENTROPIES = 'number_of_hd_entropies', NUMBER_OF_ACCOUNT_GROUPS = 'number_of_account_groups', NUMBER_OF_IMPORTED_ACCOUNTS = 'number_of_imported_accounts', @@ -40,6 +41,7 @@ export interface UserProfileMetaData { [UserProfileProperty.PRIMARY_CURRENCY]?: string; [UserProfileProperty.CURRENT_CURRENCY]?: string; [UserProfileProperty.HAS_MARKETING_CONSENT]: boolean; + [UserProfileProperty.PUSH_NOTIFICATIONS_ENABLED]?: boolean; [UserProfileProperty.NUMBER_OF_HD_ENTROPIES]: number; [UserProfileProperty.NUMBER_OF_ACCOUNT_GROUPS]: number; [UserProfileProperty.NUMBER_OF_IMPORTED_ACCOUNTS]: number; diff --git a/app/util/notifications/constants/notification-storage-keys.ts b/app/util/notifications/constants/notification-storage-keys.ts index edad4b1926c..f132022432b 100644 --- a/app/util/notifications/constants/notification-storage-keys.ts +++ b/app/util/notifications/constants/notification-storage-keys.ts @@ -6,8 +6,6 @@ import { } from '../../../constants/storage'; import storageWrapper from '../../../store/storage-wrapper'; -export { PUSH_PRE_PROMPT_SHOWN }; - /** * Used to track when/how often we should re-subscribe users to notifications. * It ensures that users notification subscriptions are kept up to date (in case our backend adds new support for certian notifications) diff --git a/app/util/notifications/hooks/types.ts b/app/util/notifications/hooks/types.ts deleted file mode 100644 index 63ea82ed186..00000000000 --- a/app/util/notifications/hooks/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { KeyringTypes } from '@metamask/keyring-controller'; - -export interface EnableMetametricsReturn { - enableMetametrics: () => Promise; - loading: boolean; - error?: string; -} -export interface DisableMetametricsReturn { - disableMetametrics: () => Promise; - loading: boolean; - error?: string; -} - -export type AccountType = InternalAccount & { - balance: string; - keyring: KeyringTypes; - label: string; -}; - -export interface SwitchSnapNotificationsChangeReturn { - onChange: (state: boolean) => void; - error?: string; -} -export interface SwitchFeatureAnnouncementsChangeReturn { - onChange: (state: boolean) => void; - error?: string; -} - -export interface SwitchPushNotificationsReturn { - onChange: (UUIDS: string[], state: boolean) => void; - error?: string; -} - -export interface UseSwitchAccountNotificationsData { - [address: string]: boolean; -} - -export interface SwitchAccountNotificationsReturn { - switchAccountNotifications: () => Promise; - isLoading: boolean; - error?: string; -} - -export interface SwitchAccountNotificationsChangeReturn { - onChange: (addresses: string[], state: boolean) => void; - error?: string; -} diff --git a/app/util/notifications/hooks/useEnableMarketingConsent.test.ts b/app/util/notifications/hooks/useEnableMarketingConsent.test.ts new file mode 100644 index 00000000000..c14ad0caffb --- /dev/null +++ b/app/util/notifications/hooks/useEnableMarketingConsent.test.ts @@ -0,0 +1,226 @@ +import { act, waitFor } from '@testing-library/react-native'; + +import OAuthService from '../../../core/OAuthService/OAuthService'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { AccountType } from '../../../constants/onboarding'; +import { UserProfileProperty } from '../../metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; +import { renderHookWithProvider } from '../../test/renderWithProvider'; +import { analytics } from '../../analytics/analytics'; +import generateDeviceAnalyticsMetaData, { + UserSettingsAnalyticsMetaData as generateUserSettingsAnalyticsMetaData, +} from '../../metrics'; +import { updateCachedConsent } from '../../trace'; +import Logger from '../../Logger'; +import { useEnableMarketingConsent } from './useEnableMarketingConsent'; + +jest.mock('../../analytics/analytics', () => ({ + analytics: { + identify: jest.fn(), + isEnabled: jest.fn(), + optIn: jest.fn(), + trackEvent: jest.fn(), + }, +})); + +jest.mock('../../metrics', () => ({ + __esModule: true, + default: jest.fn(), + UserSettingsAnalyticsMetaData: jest.fn(), +})); + +jest.mock('../../trace', () => ({ + updateCachedConsent: jest.fn(), +})); + +jest.mock('../../../core/OAuthService/OAuthService', () => ({ + __esModule: true, + default: { + updateMarketingOptInStatus: jest.fn(), + }, +})); + +jest.mock('../../Logger', () => ({ + error: jest.fn(), +})); + +const deviceTraits = { device_trait: 'device' }; +const userSettingsTraits = { user_settings_trait: 'settings' }; + +const renderUseEnableMarketingConsent = ({ + accountType = AccountType.MetamaskGoogle, + hasMarketingConsent = false, + isSeedlessOnboardingLoginFlow = false, +}: { + accountType?: AccountType; + hasMarketingConsent?: boolean; + isSeedlessOnboardingLoginFlow?: boolean; +} = {}) => + renderHookWithProvider( + () => + useEnableMarketingConsent({ + metricsOptInLocation: 'push_pre_prompt', + }), + { + state: { + engine: { + backgroundState: { + SeedlessOnboardingController: { + vault: isSeedlessOnboardingLoginFlow ? 'vault' : undefined, + }, + }, + }, + onboarding: { + accountType, + }, + security: { + dataCollectionForMarketing: hasMarketingConsent, + }, + }, + }, + ); + +describe('useEnableMarketingConsent', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(analytics.isEnabled).mockReturnValue(false); + jest.mocked(analytics.optIn).mockResolvedValue(undefined); + jest.mocked(generateDeviceAnalyticsMetaData).mockReturnValue(deviceTraits); + jest + .mocked(generateUserSettingsAnalyticsMetaData) + .mockReturnValue(userSettingsTraits); + jest + .mocked(OAuthService.updateMarketingOptInStatus) + .mockResolvedValue(undefined); + }); + + it('opts into metrics, dispatches marketing consent, and identifies consent when analytics is disabled', async () => { + const { result, store } = renderUseEnableMarketingConsent(); + + await act(async () => { + await result.current.enableMarketingConsent(); + }); + + expect(analytics.optIn).toHaveBeenCalledTimes(1); + expect(updateCachedConsent).toHaveBeenCalledWith(true); + expect(analytics.identify).toHaveBeenCalledWith({ + ...deviceTraits, + ...userSettingsTraits, + [UserProfileProperty.HAS_MARKETING_CONSENT]: true, + }); + expect(analytics.identify).toHaveBeenCalledTimes(1); + expect( + jest.mocked(analytics.identify).mock.invocationCallOrder[0], + ).toBeLessThan( + jest.mocked(analytics.trackEvent).mock.invocationCallOrder[0], + ); + expect(analytics.trackEvent).toHaveBeenCalledTimes(2); + expect(analytics.trackEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_IN.category, + properties: expect.objectContaining({ + account_type: AccountType.MetamaskGoogle, + location: 'push_pre_prompt', + updated_after_onboarding: true, + }), + }), + ); + expect(analytics.trackEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, + properties: expect.objectContaining({ + [UserProfileProperty.HAS_MARKETING_CONSENT]: true, + is_metrics_opted_in: true, + account_type: AccountType.MetamaskGoogle, + location: 'push_pre_prompt', + updated_after_onboarding: true, + }), + }), + ); + expect(store.getState().security.dataCollectionForMarketing).toBe(true); + }); + + it('dispatches and identifies marketing consent without metrics opt-in when analytics is already enabled', async () => { + jest.mocked(analytics.isEnabled).mockReturnValue(true); + const { result, store } = renderUseEnableMarketingConsent(); + + await act(async () => { + await result.current.enableMarketingConsent(); + }); + + expect(analytics.optIn).not.toHaveBeenCalled(); + expect(updateCachedConsent).not.toHaveBeenCalled(); + expect(analytics.identify).toHaveBeenCalledWith({ + [UserProfileProperty.HAS_MARKETING_CONSENT]: true, + }); + expect( + jest.mocked(analytics.identify).mock.invocationCallOrder[0], + ).toBeLessThan( + jest.mocked(analytics.trackEvent).mock.invocationCallOrder[0], + ); + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + expect(analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED.category, + properties: expect.objectContaining({ + [UserProfileProperty.HAS_MARKETING_CONSENT]: true, + is_metrics_opted_in: true, + account_type: AccountType.MetamaskGoogle, + location: 'push_pre_prompt', + updated_after_onboarding: true, + }), + }), + ); + expect(store.getState().security.dataCollectionForMarketing).toBe(true); + }); + + it('syncs marketing consent to OAuth for seedless users', async () => { + jest.mocked(analytics.isEnabled).mockReturnValue(true); + const { result, store } = renderUseEnableMarketingConsent({ + isSeedlessOnboardingLoginFlow: true, + }); + + await act(async () => { + await result.current.enableMarketingConsent(); + }); + + expect(store.getState().security.dataCollectionForMarketing).toBe(true); + expect(OAuthService.updateMarketingOptInStatus).toHaveBeenCalledWith(true); + }); + + it('reverts Redux marketing consent when the seedless OAuth sync fails', async () => { + jest.mocked(analytics.isEnabled).mockReturnValue(true); + jest + .mocked(OAuthService.updateMarketingOptInStatus) + .mockRejectedValue(new Error('oauth failed')); + const { result, store } = renderUseEnableMarketingConsent({ + isSeedlessOnboardingLoginFlow: true, + }); + + await act(async () => { + await result.current.enableMarketingConsent(); + }); + + await waitFor(() => { + expect(store.getState().security.dataCollectionForMarketing).toBe(false); + }); + expect(Logger.error).toHaveBeenCalled(); + }); + + it('does nothing when marketing consent is already enabled', async () => { + const { result } = renderUseEnableMarketingConsent({ + hasMarketingConsent: true, + }); + + await act(async () => { + await result.current.enableMarketingConsent(); + }); + + expect(analytics.optIn).not.toHaveBeenCalled(); + expect(updateCachedConsent).not.toHaveBeenCalled(); + expect(analytics.identify).not.toHaveBeenCalled(); + expect(analytics.trackEvent).not.toHaveBeenCalled(); + expect(OAuthService.updateMarketingOptInStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/app/util/notifications/hooks/useEnableMarketingConsent.ts b/app/util/notifications/hooks/useEnableMarketingConsent.ts new file mode 100644 index 00000000000..2b09b576b28 --- /dev/null +++ b/app/util/notifications/hooks/useEnableMarketingConsent.ts @@ -0,0 +1,103 @@ +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { setDataCollectionForMarketing } from '../../../actions/security'; +import { selectOnboardingAccountType } from '../../../selectors/onboarding'; +import { selectSeedlessOnboardingLoginFlow } from '../../../selectors/seedlessOnboardingController'; +import { selectDataCollectionForMarketingEnabled } from '../../../selectors/engagement'; +import OAuthService from '../../../core/OAuthService/OAuthService'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { AnalyticsEventBuilder } from '../../analytics/AnalyticsEventBuilder'; +import { analytics } from '../../analytics/analytics'; +import { UserProfileProperty } from '../../metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; +import generateDeviceAnalyticsMetaData, { + UserSettingsAnalyticsMetaData as generateUserSettingsAnalyticsMetaData, +} from '../../metrics'; +import { updateCachedConsent } from '../../trace'; +import Logger from '../../Logger'; + +interface UseEnableMarketingConsentOptions { + metricsOptInLocation: string; +} + +export function useEnableMarketingConsent({ + metricsOptInLocation, +}: UseEnableMarketingConsentOptions) { + const dispatch = useDispatch(); + const hasMarketingConsent = useSelector( + selectDataCollectionForMarketingEnabled, + ); + const isSeedlessOnboardingLoginFlow = useSelector( + selectSeedlessOnboardingLoginFlow, + ); + const accountType = useSelector(selectOnboardingAccountType); + + const enableMarketingConsent = useCallback(async () => { + const marketingConsentTraits = { + [UserProfileProperty.HAS_MARKETING_CONSENT]: true, + }; + + if (hasMarketingConsent) { + return; + } + + const shouldOptInToMetrics = !analytics.isEnabled(); + + if (shouldOptInToMetrics) { + await analytics.optIn(); + updateCachedConsent(true); + } + + dispatch(setDataCollectionForMarketing(true)); + if (shouldOptInToMetrics) { + analytics.identify({ + ...generateDeviceAnalyticsMetaData(), + ...generateUserSettingsAnalyticsMetaData(), + ...marketingConsentTraits, + }); + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( + MetaMetricsEvents.METRICS_OPT_IN, + ) + .addProperties({ + updated_after_onboarding: true, + location: metricsOptInLocation, + ...(accountType && { account_type: accountType }), + }) + .build(), + ); + } else { + analytics.identify(marketingConsentTraits); + } + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( + MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED, + ) + .addProperties({ + ...marketingConsentTraits, + is_metrics_opted_in: true, + updated_after_onboarding: true, + location: metricsOptInLocation, + ...(accountType && { account_type: accountType }), + }) + .build(), + ); + + if (isSeedlessOnboardingLoginFlow) { + // Social-login wallets also store marketing opt-in server-side so the + // setting survives OAuth rehydration. Match settings behavior and revert + // the optimistic Redux update if that sync fails. + OAuthService.updateMarketingOptInStatus(true).catch((error) => { + Logger.error(error as Error); + dispatch(setDataCollectionForMarketing(false)); + }); + } + }, [ + accountType, + dispatch, + hasMarketingConsent, + isSeedlessOnboardingLoginFlow, + metricsOptInLocation, + ]); + + return { enableMarketingConsent }; +} diff --git a/app/util/notifications/hooks/useNotifications.test.tsx b/app/util/notifications/hooks/useNotifications.test.tsx index eb61cf8f8cc..790d73abc6f 100644 --- a/app/util/notifications/hooks/useNotifications.test.tsx +++ b/app/util/notifications/hooks/useNotifications.test.tsx @@ -1,5 +1,4 @@ -import { act, renderHook } from '@testing-library/react-hooks'; -import { waitFor } from '@testing-library/react-native'; +import { act, renderHook, waitFor } from '@testing-library/react-native'; // eslint-disable-next-line import-x/no-namespace import * as Actions from '../../../actions/notification/helpers'; @@ -116,7 +115,9 @@ describe('useNotifications - useEnableNotifications()', () => { // Act const hook = renderHookWithProvider(() => useEnableNotifications()); - await act(() => hook.result.current.enableNotifications()); + await act(async () => { + await hook.result.current.enableNotifications(); + }); await waitFor(() => expect(mocks.mockEnableNotifications).toHaveBeenCalled(), ); @@ -133,6 +134,7 @@ describe('useNotifications - useEnableNotifications()', () => { expect(mocks.mockEnableNotifications).toHaveBeenCalledWith({ hasMarketingConsent: false, productAnnouncementEnabled: true, + registerPushNotifications: true, }); }); @@ -150,7 +152,45 @@ describe('useNotifications - useEnableNotifications()', () => { expect(mocks.mockEnableNotifications).toHaveBeenCalledWith({ hasMarketingConsent: true, productAnnouncementEnabled: true, + registerPushNotifications: true, + }); + }); + + it('keeps shared notification setup before push toggling', async () => { + const { mocks } = await arrangeAct(); + + expect( + mocks.mockEnableNotifications.mock.invocationCallOrder[0], + ).toBeLessThan( + mocks.mockTogglePushNotification.mock.invocationCallOrder[0], + ); + }); + + it('does not register push while enabling notifications without a push nudge', async () => { + const mocks = arrangeMocks(); + + const hook = renderHookWithProvider(() => + useEnableNotifications({ nudgeEnablePush: false }), + ); + await act(async () => { + await hook.result.current.enableNotifications(); + }); + + expect(mocks.mockEnableNotifications).toHaveBeenCalledWith({ + hasMarketingConsent: false, + productAnnouncementEnabled: true, + registerPushNotifications: false, }); + expect(mocks.mockTogglePushNotification).toHaveBeenCalled(); + }); + + it('continues when push enablement fails', async () => { + const { mocks } = await arrangeAct((m) => { + m.mockTogglePushNotification.mockResolvedValue(false); + }); + + expect(mocks.mockEnableNotifications).toHaveBeenCalled(); + expect(mocks.mockTogglePushNotification).toHaveBeenCalled(); }); it('creates an error when fails', async () => { @@ -262,6 +302,10 @@ describe('useNotifications - useContiguousLoading()', () => { jest.useFakeTimers(); }); + afterEach(() => { + jest.useRealTimers(); + }); + const arrangeHook = (loading1: boolean, loading2: boolean) => renderHook( ({ loadingParam1, loadingParam2 }) => diff --git a/app/util/notifications/hooks/useNotifications.ts b/app/util/notifications/hooks/useNotifications.ts index ebc64bf193b..fbfb5b4fbe6 100644 --- a/app/util/notifications/hooks/useNotifications.ts +++ b/app/util/notifications/hooks/useNotifications.ts @@ -124,12 +124,18 @@ export function useEnableNotifications(props = { nudgeEnablePush: true }) { await enableNotificationsHelper({ hasMarketingConsent, productAnnouncementEnabled, + registerPushNotifications: Boolean(props.nudgeEnablePush), }).catch((e) => setError(e)); await togglePushNotification(true).catch(() => { /* Do Nothing */ }); await updateNotificationSubscriptionExpiration(); - }, [hasMarketingConsent, productAnnouncementEnabled, togglePushNotification]); + }, [ + props.nudgeEnablePush, + hasMarketingConsent, + productAnnouncementEnabled, + togglePushNotification, + ]); const contiguousLoading = useContiguousLoading(loading, pushLoading); diff --git a/app/util/notifications/hooks/usePushPermissionNotificationSetup.test.ts b/app/util/notifications/hooks/usePushPermissionNotificationSetup.test.ts new file mode 100644 index 00000000000..87796b77371 --- /dev/null +++ b/app/util/notifications/hooks/usePushPermissionNotificationSetup.test.ts @@ -0,0 +1,195 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; + +import { + assertIsFeatureEnabled, + enableNotifications, + hasNotificationPreferences, + setMarketingNotificationPreferencesEnabled, +} from '../../../actions/notification/helpers'; +import { updateNotificationSubscriptionExpiration } from '../constants/notification-storage-keys'; +import { requestPushPermissions } from '../services/NotificationService'; +import Logger from '../../Logger'; +import { usePushPermissionNotificationSetup } from './usePushPermissionNotificationSetup'; + +jest.mock('../../../actions/notification/helpers', () => ({ + assertIsFeatureEnabled: jest.fn(), + enableNotifications: jest.fn(), + hasNotificationPreferences: jest.fn(), + setMarketingNotificationPreferencesEnabled: jest.fn(), +})); + +jest.mock('../constants/notification-storage-keys', () => ({ + updateNotificationSubscriptionExpiration: jest.fn(), +})); + +jest.mock('../services/NotificationService', () => ({ + requestPushPermissions: jest.fn(), +})); + +jest.mock('../../Logger', () => ({ + error: jest.fn(), +})); + +describe('usePushPermissionNotificationSetup', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(assertIsFeatureEnabled).mockImplementation(() => undefined); + jest.mocked(requestPushPermissions).mockResolvedValue(true); + jest.mocked(enableNotifications).mockResolvedValue(undefined); + jest.mocked(hasNotificationPreferences).mockResolvedValue(false); + jest + .mocked(setMarketingNotificationPreferencesEnabled) + .mockResolvedValue(undefined); + jest + .mocked(updateNotificationSubscriptionExpiration) + .mockResolvedValue(undefined); + }); + + it('requests native push permission before MetaMask notification setup', async () => { + const { result } = renderHook(() => usePushPermissionNotificationSetup()); + + let nativePermissionEnabled = false; + await act(async () => { + nativePermissionEnabled = await result.current.requestPushPermission(); + }); + + expect(nativePermissionEnabled).toBe(true); + expect(requestPushPermissions).toHaveBeenCalledTimes(1); + expect(hasNotificationPreferences).not.toHaveBeenCalled(); + expect(enableNotifications).not.toHaveBeenCalled(); + + act(() => { + result.current.enableNotificationsInBackground(nativePermissionEnabled); + }); + + await waitFor(() => { + expect(enableNotifications).toHaveBeenCalledWith({ + hasMarketingConsent: true, + productAnnouncementEnabled: true, + registerPushNotifications: true, + }); + }); + expect( + jest.mocked(requestPushPermissions).mock.invocationCallOrder[0], + ).toBeLessThan( + jest.mocked(hasNotificationPreferences).mock.invocationCallOrder[0], + ); + expect( + jest.mocked(hasNotificationPreferences).mock.invocationCallOrder[0], + ).toBeLessThan( + jest.mocked(enableNotifications).mock.invocationCallOrder[0], + ); + expect(updateNotificationSubscriptionExpiration).toHaveBeenCalledTimes(1); + }); + + it('passes marketing options when initializing notification preferences from the prompt', async () => { + const { result } = renderHook(() => usePushPermissionNotificationSetup()); + + act(() => { + result.current.enableNotificationsInBackground(true); + }); + + await waitFor(() => { + expect(enableNotifications).toHaveBeenCalledWith({ + hasMarketingConsent: true, + productAnnouncementEnabled: true, + registerPushNotifications: true, + }); + }); + expect(setMarketingNotificationPreferencesEnabled).not.toHaveBeenCalled(); + expect(updateNotificationSubscriptionExpiration).toHaveBeenCalledTimes(1); + }); + + it('updates existing marketing preferences when enabling notifications from the prompt', async () => { + jest.mocked(hasNotificationPreferences).mockResolvedValue(true); + const { result } = renderHook(() => usePushPermissionNotificationSetup()); + + act(() => { + result.current.enableNotificationsInBackground(true); + }); + + await waitFor(() => { + expect(enableNotifications).toHaveBeenCalledWith({ + registerPushNotifications: true, + }); + }); + await waitFor(() => + expect(setMarketingNotificationPreferencesEnabled).toHaveBeenCalledWith( + true, + ), + ); + expect(updateNotificationSubscriptionExpiration).toHaveBeenCalledTimes(1); + }); + + it('enables in-app notifications without push registration when native permission is denied', async () => { + jest.mocked(requestPushPermissions).mockResolvedValue(false); + const { result } = renderHook(() => usePushPermissionNotificationSetup()); + + let nativePermissionEnabled = true; + await act(async () => { + nativePermissionEnabled = await result.current.requestPushPermission(); + }); + + expect(nativePermissionEnabled).toBe(false); + + act(() => { + result.current.enableNotificationsInBackground(nativePermissionEnabled); + }); + + await waitFor(() => { + expect(enableNotifications).toHaveBeenCalledWith({ + hasMarketingConsent: true, + productAnnouncementEnabled: true, + registerPushNotifications: false, + }); + }); + expect(updateNotificationSubscriptionExpiration).toHaveBeenCalledTimes(1); + }); + + it('treats native permission request errors as denied', async () => { + jest + .mocked(requestPushPermissions) + .mockRejectedValue(new Error('permission failed')); + const { result } = renderHook(() => usePushPermissionNotificationSetup()); + + let nativePermissionEnabled = true; + await act(async () => { + nativePermissionEnabled = await result.current.requestPushPermission(); + }); + + expect(nativePermissionEnabled).toBe(false); + expect(Logger.error).toHaveBeenCalled(); + }); + + it('treats feature gate assertion errors as denied', async () => { + jest.mocked(assertIsFeatureEnabled).mockImplementation(() => { + throw new Error('feature disabled'); + }); + const { result } = renderHook(() => usePushPermissionNotificationSetup()); + + let nativePermissionEnabled = true; + await act(async () => { + nativePermissionEnabled = await result.current.requestPushPermission(); + }); + + expect(nativePermissionEnabled).toBe(false); + expect(requestPushPermissions).not.toHaveBeenCalled(); + expect(Logger.error).toHaveBeenCalled(); + }); + + it('logs background setup failures without throwing', async () => { + jest + .mocked(enableNotifications) + .mockRejectedValue(new Error('setup failed')); + const { result } = renderHook(() => usePushPermissionNotificationSetup()); + + act(() => { + result.current.enableNotificationsInBackground(true); + }); + + await waitFor(() => { + expect(Logger.error).toHaveBeenCalled(); + }); + expect(updateNotificationSubscriptionExpiration).not.toHaveBeenCalled(); + }); +}); diff --git a/app/util/notifications/hooks/usePushPermissionNotificationSetup.ts b/app/util/notifications/hooks/usePushPermissionNotificationSetup.ts new file mode 100644 index 00000000000..54771a30f44 --- /dev/null +++ b/app/util/notifications/hooks/usePushPermissionNotificationSetup.ts @@ -0,0 +1,72 @@ +import { useCallback } from 'react'; + +import { + assertIsFeatureEnabled, + enableNotifications as enableNotificationsHelper, + hasNotificationPreferences as hasNotificationPreferencesHelper, + setMarketingNotificationPreferencesEnabled, +} from '../../../actions/notification/helpers'; +import { updateNotificationSubscriptionExpiration } from '../constants/notification-storage-keys'; +import { requestPushPermissions } from '../services/NotificationService'; +import Logger from '../../Logger'; + +export function usePushPermissionNotificationSetup() { + // Ask the OS for push permission while the pre-prompt is still in focus. + const requestPushPermission = useCallback(async () => { + try { + assertIsFeatureEnabled(); + return await requestPushPermissions(); + } catch (requestError) { + Logger.error( + requestError as Error, + 'Failed to request push permission from pre-prompt', + ); + return false; + } + }, []); + + // Finish MetaMask notification setup after the pre-prompt resolves. The OS + // permission result determines whether push registration should be attempted. + const enableNotificationsInBackground = useCallback( + (nativePermissionEnabled: boolean) => { + const registerPushNotifications = nativePermissionEnabled; + + const enableNotifications = async () => { + try { + const hasExistingNotificationPreferences = + await hasNotificationPreferencesHelper(); + + if (hasExistingNotificationPreferences) { + // Still run the enable flow for auth, trigger refresh, controller + // state, and push registration; existing AUS prefs are updated separately. + await enableNotificationsHelper({ + registerPushNotifications, + }); + await setMarketingNotificationPreferencesEnabled(true); + } else { + await enableNotificationsHelper({ + hasMarketingConsent: true, + productAnnouncementEnabled: true, + registerPushNotifications, + }); + } + + await updateNotificationSubscriptionExpiration(); + } catch (backgroundSetupError) { + Logger.error( + backgroundSetupError as Error, + 'Failed to enable notifications from push pre-prompt', + ); + } + }; + + void enableNotifications(); + }, + [], + ); + + return { + enableNotificationsInBackground, + requestPushPermission, + }; +} diff --git a/app/util/notifications/hooks/usePushPrePromptAnalytics.test.ts b/app/util/notifications/hooks/usePushPrePromptAnalytics.test.ts new file mode 100644 index 00000000000..223048ffd99 --- /dev/null +++ b/app/util/notifications/hooks/usePushPrePromptAnalytics.test.ts @@ -0,0 +1,151 @@ +import { renderHook } from '@testing-library/react-native'; + +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { AnalyticsEventBuilder } from '../../analytics/AnalyticsEventBuilder'; +import { createMockUseAnalyticsHook } from '../../test/analyticsMock'; +import { UserProfileProperty } from '../../metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; +import { usePushPrePromptAnalytics } from './usePushPrePromptAnalytics'; + +jest.mock('../../../components/hooks/useAnalytics/useAnalytics'); + +describe('usePushPrePromptAnalytics', () => { + const mockIdentify = jest.fn(); + const mockTrackEvent = jest.fn(); + const mockCreateEventBuilder = jest.fn( + AnalyticsEventBuilder.createEventBuilder, + ); + + const getLastTrackedEvent = () => + mockTrackEvent.mock.calls[mockTrackEvent.mock.calls.length - 1][0]; + + beforeEach(() => { + jest.clearAllMocks(); + mockIdentify.mockResolvedValue(undefined); + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + createEventBuilder: mockCreateEventBuilder, + identify: mockIdentify, + trackEvent: mockTrackEvent, + }), + ); + }); + + it('tracks the pre-prompt viewed event', () => { + const { result } = renderHook(() => usePushPrePromptAnalytics()); + + result.current.trackPrePromptViewed('push_permission'); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.PUSH_NOTIFICATION_PRE_PROMPT_VIEWED, + ); + expect(getLastTrackedEvent()).toEqual( + expect.objectContaining({ + name: MetaMetricsEvents.PUSH_NOTIFICATION_PRE_PROMPT_VIEWED.category, + properties: {}, + }), + ); + }); + + it.each([ + ['dismissed' as const, () => ['trackPrePromptDismissed'], 'dismiss'], + [ + 'allowed with the push permission CTA' as const, + () => ['trackPrePromptButtonClicked', 'push_permission', 'yes'], + 'allow', + ], + [ + 'allowed with the marketing consent CTA' as const, + () => ['trackPrePromptButtonClicked', 'marketing_consent', 'confirm'], + 'allow', + ], + [ + 'denied with the not now CTA' as const, + () => ['trackPrePromptButtonClicked', 'marketing_consent', 'not_now'], + 'deny', + ], + [ + 'denied with the push permission not now CTA' as const, + () => ['trackPrePromptButtonClicked', 'push_permission', 'not_now'], + 'deny', + ], + ])( + 'tracks the pre-prompt button when %s', + (_label, getAction, buttonType) => { + const { result } = renderHook(() => usePushPrePromptAnalytics()); + const [method, variant, button] = getAction(); + + if (method === 'trackPrePromptDismissed') { + result.current.trackPrePromptDismissed('marketing_consent'); + } else { + result.current.trackPrePromptButtonClicked( + variant as 'push_permission' | 'marketing_consent', + button as 'yes' | 'not_now' | 'confirm', + ); + } + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.PUSH_NOTIFICATION_PRE_PROMPT_BUTTON_CLICKED, + ); + expect(getLastTrackedEvent()).toEqual( + expect.objectContaining({ + name: MetaMetricsEvents.PUSH_NOTIFICATION_PRE_PROMPT_BUTTON_CLICKED + .category, + properties: { button_type: buttonType }, + }), + ); + }, + ); + + it.each([ + ['allowed' as const, 'allow'], + ['denied' as const, 'deny'], + ])( + 'tracks the OS prompt response when permission is %s', + (response, buttonType) => { + const { result } = renderHook(() => usePushPrePromptAnalytics()); + + result.current.trackOsPromptResponse('push_permission', response); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.OS_PUSH_NOTIFICATION_BUTTON_CLICKED, + ); + expect(getLastTrackedEvent()).toEqual( + expect.objectContaining({ + name: MetaMetricsEvents.OS_PUSH_NOTIFICATION_BUTTON_CLICKED.category, + properties: { button_type: buttonType }, + }), + ); + }, + ); + + it('keeps OS prompt shown as a noop because the schema has no shown event', () => { + const { result } = renderHook(() => usePushPrePromptAnalytics()); + + result.current.trackOsPromptShown('push_permission'); + + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('identifies push notifications enabled', async () => { + const { result } = renderHook(() => usePushPrePromptAnalytics()); + + await result.current.identifyPushNotificationsEnabled(true); + + expect(mockIdentify).toHaveBeenCalledWith({ + [UserProfileProperty.PUSH_NOTIFICATIONS_ENABLED]: true, + }); + }); + + it('identifies marketing consent', async () => { + const { result } = renderHook(() => usePushPrePromptAnalytics()); + + await result.current.identifyMarketingConsent(true); + + expect(mockIdentify).toHaveBeenCalledWith({ + [UserProfileProperty.HAS_MARKETING_CONSENT]: true, + }); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/app/util/notifications/hooks/usePushPrePromptAnalytics.ts b/app/util/notifications/hooks/usePushPrePromptAnalytics.ts new file mode 100644 index 00000000000..118665e8508 --- /dev/null +++ b/app/util/notifications/hooks/usePushPrePromptAnalytics.ts @@ -0,0 +1,145 @@ +import { useCallback, useMemo } from 'react'; + +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { UserProfileProperty } from '../../metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; +import { PushPrePromptVariant } from './usePushPrePromptVariant'; + +type PushPrePromptAnalyticsVariant = Exclude; +type PushPrePromptButton = 'yes' | 'not_now' | 'confirm'; +type PushOsPromptResponse = 'allowed' | 'denied'; +type PushPrePromptButtonType = 'allow' | 'deny' | 'dismiss'; + +interface PushPrePromptAnalytics { + trackPrePromptViewed: (variant: PushPrePromptAnalyticsVariant) => void; + trackPrePromptDismissed: (variant: PushPrePromptAnalyticsVariant) => void; + trackPrePromptButtonClicked: ( + variant: PushPrePromptAnalyticsVariant, + button: PushPrePromptButton, + ) => void; + trackOsPromptShown: (variant: PushPrePromptAnalyticsVariant) => void; + trackOsPromptResponse: ( + variant: PushPrePromptAnalyticsVariant, + response: PushOsPromptResponse, + ) => void; + identifyMarketingConsent: (enabled: boolean) => Promise; + identifyPushNotificationsEnabled: (enabled: boolean) => Promise; +} + +const noop = () => undefined; +const trackOsPromptShown: PushPrePromptAnalytics['trackOsPromptShown'] = noop; + +const pushPrePromptButtonTypeByButton: Record< + PushPrePromptButton, + PushPrePromptButtonType +> = { + yes: 'allow', + confirm: 'allow', + not_now: 'deny', +}; + +const osPromptButtonTypeByResponse: Record< + PushOsPromptResponse, + Exclude +> = { + allowed: 'allow', + denied: 'deny', +}; + +export function usePushPrePromptAnalytics() { + const { createEventBuilder, identify, trackEvent } = useAnalytics(); + + const trackPrePromptViewed = useCallback( + (_variant: PushPrePromptAnalyticsVariant) => { + trackEvent( + createEventBuilder( + MetaMetricsEvents.PUSH_NOTIFICATION_PRE_PROMPT_VIEWED, + ).build(), + ); + }, + [createEventBuilder, trackEvent], + ); + + const trackPrePromptButtonType = useCallback( + (buttonType: PushPrePromptButtonType) => { + trackEvent( + createEventBuilder( + MetaMetricsEvents.PUSH_NOTIFICATION_PRE_PROMPT_BUTTON_CLICKED, + ) + .addProperties({ button_type: buttonType }) + .build(), + ); + }, + [createEventBuilder, trackEvent], + ); + + const trackPrePromptDismissed = useCallback( + (_variant: PushPrePromptAnalyticsVariant) => { + trackPrePromptButtonType('dismiss'); + }, + [trackPrePromptButtonType], + ); + + const trackPrePromptButtonClicked = useCallback( + (_variant: PushPrePromptAnalyticsVariant, button: PushPrePromptButton) => { + trackPrePromptButtonType(pushPrePromptButtonTypeByButton[button]); + }, + [trackPrePromptButtonType], + ); + + const trackOsPromptResponse = useCallback( + ( + _variant: PushPrePromptAnalyticsVariant, + response: PushOsPromptResponse, + ) => { + trackEvent( + createEventBuilder( + MetaMetricsEvents.OS_PUSH_NOTIFICATION_BUTTON_CLICKED, + ) + .addProperties({ + button_type: osPromptButtonTypeByResponse[response], + }) + .build(), + ); + }, + [createEventBuilder, trackEvent], + ); + + const identifyMarketingConsent = useCallback( + async (enabled: boolean) => { + await identify({ + [UserProfileProperty.HAS_MARKETING_CONSENT]: enabled, + }); + }, + [identify], + ); + + const identifyPushNotificationsEnabled = useCallback( + async (enabled: boolean) => { + await identify({ + [UserProfileProperty.PUSH_NOTIFICATIONS_ENABLED]: enabled, + }); + }, + [identify], + ); + + return useMemo( + () => ({ + trackOsPromptShown, + trackPrePromptViewed, + trackPrePromptDismissed, + trackPrePromptButtonClicked, + trackOsPromptResponse, + identifyMarketingConsent, + identifyPushNotificationsEnabled, + }), + [ + identifyMarketingConsent, + identifyPushNotificationsEnabled, + trackOsPromptResponse, + trackPrePromptButtonClicked, + trackPrePromptDismissed, + trackPrePromptViewed, + ], + ); +} diff --git a/app/util/notifications/hooks/usePushPrePromptVariant.test.ts b/app/util/notifications/hooks/usePushPrePromptVariant.test.ts index 21f222566ab..10217c430cd 100644 --- a/app/util/notifications/hooks/usePushPrePromptVariant.test.ts +++ b/app/util/notifications/hooks/usePushPrePromptVariant.test.ts @@ -2,103 +2,70 @@ import { act, waitFor } from '@testing-library/react-native'; // eslint-disable-next-line import-x/no-namespace import * as NotificationSelectors from '../../../selectors/notifications'; // eslint-disable-next-line import-x/no-namespace -import * as KeyringSelectors from '../../../selectors/keyringController'; +import * as OnboardingSelectors from '../../../selectors/onboarding'; // eslint-disable-next-line import-x/no-namespace import * as SettingsSelectors from '../../../selectors/settings'; -// eslint-disable-next-line import-x/no-namespace -import * as OnboardingSelectors from '../../../selectors/onboarding'; -import { setDataCollectionForMarketing } from '../../../actions/security'; -import { TRUE } from '../../../constants/storage'; +import { setCompletedOnboarding } from '../../../actions/onboarding'; +import { PUSH_PRE_PROMPT_SHOWN, TRUE } from '../../../constants/storage'; import storageWrapper from '../../../store/storage-wrapper'; import { renderHookWithProvider } from '../../test/renderWithProvider'; -// eslint-disable-next-line import-x/no-namespace -import * as Constants from '../constants/config'; -import { PUSH_PRE_PROMPT_SHOWN } from '../constants/notification-storage-keys'; -import { resolvePushNotificationStatus } from '../utils/push-notification-status'; +import { isNotificationsFeatureEnabled } from '../constants'; +import { resolveNativePushPermissionStatus } from '../utils/push-notification-status'; import { usePushPrePromptVariant } from './usePushPrePromptVariant'; -jest.mock('../../../core/Engine', () => ({ - __esModule: true, - default: { - context: { - RemoteFeatureFlagController: { - state: { - remoteFeatureFlags: { - assetsNotificationsEnabled: true, - }, - }, - }, - }, - }, +jest.mock('../utils/push-notification-status', () => ({ + resolveNativePushPermissionStatus: jest.fn(), })); -jest.mock('../utils/push-notification-status', () => ({ - resolvePushNotificationStatus: jest.fn(), +jest.mock('../constants', () => ({ + isNotificationsFeatureEnabled: jest.fn(), })); -const mockResolvePushNotificationStatus = jest.mocked( - resolvePushNotificationStatus, +const mockResolveNativePushPermissionStatus = jest.mocked( + resolveNativePushPermissionStatus, +); +const mockIsNotificationsFeatureEnabled = jest.mocked( + isNotificationsFeatureEnabled, ); -type PushNotificationStatusResult = Awaited< - ReturnType ->; - -const createDeferred = () => { - let resolve!: (value: Value) => void; - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve; +const mockNativePushPermissionStatus = ({ + nativeOsPermissionEnabled = true, + nativeOsPermissionPromptable = false, +}: { + nativeOsPermissionEnabled?: boolean; + nativeOsPermissionPromptable?: boolean; +} = {}) => { + mockResolveNativePushPermissionStatus.mockResolvedValue({ + nativeOsPermissionEnabled, + nativeOsPermissionPromptable, }); - return { promise, resolve }; }; const arrangeStorage = ( values: Partial> = {}, ) => { - const storageWrapperWithSync = storageWrapper as typeof storageWrapper & { - getItemSync: (key: string) => string | null; - }; - - if (!storageWrapperWithSync.getItemSync) { - storageWrapperWithSync.getItemSync = jest.fn(); - } - - jest - .spyOn(storageWrapperWithSync, 'getItemSync') - .mockImplementation((key) => { - if (key in values) { - return values[key] ?? null; - } - return null; - }); + jest.spyOn(storageWrapper, 'getItemSync').mockImplementation((key) => { + if (key in values) { + return values[key] ?? null; + } + return null; + }); jest.spyOn(storageWrapper, 'setItem').mockResolvedValue(undefined); + jest.spyOn(storageWrapper, 'removeItem').mockResolvedValue(undefined); }; const arrangeSelectors = ({ completedOnboarding = true, - isBasicFunctionalityEnabled = true, - isNotificationsFeatureEnabled = true, - isPushEnabled = false, isFeatureFlagOn = true, - isUnlocked = true, + isBasicFunctionalityEnabled = true, }: { completedOnboarding?: boolean; - isBasicFunctionalityEnabled?: boolean; - isNotificationsFeatureEnabled?: boolean; - isPushEnabled?: boolean; isFeatureFlagOn?: boolean; - isUnlocked?: boolean; + isBasicFunctionalityEnabled?: boolean; } = {}) => { - jest.spyOn(KeyringSelectors, 'selectIsUnlocked').mockReturnValue(isUnlocked); - jest - .spyOn(SettingsSelectors, 'selectBasicFunctionalityEnabled') - .mockReturnValue(isBasicFunctionalityEnabled); jest .spyOn(OnboardingSelectors, 'selectCompletedOnboarding') .mockReturnValue(completedOnboarding); - jest - .spyOn(NotificationSelectors, 'selectIsMetaMaskPushNotificationsEnabled') - .mockReturnValue(isPushEnabled); jest .spyOn( NotificationSelectors, @@ -106,19 +73,27 @@ const arrangeSelectors = ({ ) .mockReturnValue(isFeatureFlagOn); jest - .spyOn(Constants, 'isNotificationsFeatureEnabled') - .mockReturnValue(isNotificationsFeatureEnabled); + .spyOn(SettingsSelectors, 'selectBasicFunctionalityEnabled') + .mockReturnValue(isBasicFunctionalityEnabled); }; const renderUsePushPrePromptVariant = ({ - dataCollectionForMarketing = false, + completedOnboarding = true, + hasMarketingConsent = false, + pendingSocialLoginMarketingConsentBackfill = null, }: { - dataCollectionForMarketing?: boolean | null; + completedOnboarding?: boolean; + hasMarketingConsent?: boolean; + pendingSocialLoginMarketingConsentBackfill?: string | null; } = {}) => renderHookWithProvider(() => usePushPrePromptVariant(), { state: { + onboarding: { + completedOnboarding, + pendingSocialLoginMarketingConsentBackfill, + }, security: { - dataCollectionForMarketing, + dataCollectionForMarketing: hasMarketingConsent, }, }, }); @@ -128,60 +103,143 @@ describe('usePushPrePromptVariant', () => { jest.clearAllMocks(); arrangeSelectors(); arrangeStorage(); - mockResolvePushNotificationStatus.mockResolvedValue({ - controllerIsPushEnabled: true, - effectivePushEnabled: true, - nativeOsPermissionEnabled: true, - }); + mockIsNotificationsFeatureEnabled.mockReturnValue(true); + mockNativePushPermissionStatus(); }); afterEach(() => { jest.restoreAllMocks(); }); - it('returns the push permission prompt when onboarding is complete and push is disabled', async () => { + it('returns the push permission prompt when onboarding is complete and native push is disabled', async () => { + mockNativePushPermissionStatus({ + nativeOsPermissionEnabled: false, + nativeOsPermissionPromptable: true, + }); + const { result } = renderUsePushPrePromptVariant(); await waitFor(() => { expect(result.current.variant).toBe('push_permission'); }); + expect(result.current.nativeOsPermissionEnabled).toBe(false); + expect(mockResolveNativePushPermissionStatus).toHaveBeenCalledTimes(1); + }); + + it('does not return a prompt when native push permission was previously denied', async () => { + mockNativePushPermissionStatus({ + nativeOsPermissionEnabled: false, + nativeOsPermissionPromptable: false, + }); + + const { result } = renderUsePushPrePromptVariant(); + + await waitFor(() => { + expect(result.current.isResolving).toBe(false); + }); + expect(result.current.variant).toBeNull(); + expect(result.current.nativeOsPermissionEnabled).toBe(false); + expect(mockResolveNativePushPermissionStatus).toHaveBeenCalledTimes(1); }); it('does not return a prompt before onboarding completes', async () => { arrangeSelectors({ completedOnboarding: false }); - const { result } = renderUsePushPrePromptVariant(); + const { result } = renderUsePushPrePromptVariant({ + completedOnboarding: false, + }); await waitFor(() => { - expect(result.current.variant).toBeNull(); + expect(result.current.isResolving).toBe(false); }); + expect(result.current.variant).toBeNull(); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); }); - it.each<[string, Parameters[0]]>([ - ['wallet is locked', { isUnlocked: false }], - ['basic functionality is disabled', { isBasicFunctionalityEnabled: false }], - [ - 'notifications enabled by default feature flag is off', - { isFeatureFlagOn: false }, - ], - [ - 'notifications feature flag is off', - { isNotificationsFeatureEnabled: false }, - ], - ])( - 'does not return a prompt when %s', - async (_caseName, selectorOverrides) => { - arrangeSelectors({ isPushEnabled: true, ...selectorOverrides }); - - const { result } = renderUsePushPrePromptVariant(); - - await waitFor(() => { - expect(result.current.isResolving).toBe(false); - expect(result.current.variant).toBeNull(); + it('stays resolving when onboarding completion changes the eligibility inputs', async () => { + arrangeSelectors({ completedOnboarding: false }); + let resolveNativePushPermission: + | (( + value: Awaited>, + ) => void) + | undefined; + mockResolveNativePushPermissionStatus.mockReturnValue( + new Promise((resolve) => { + resolveNativePushPermission = resolve; + }), + ); + jest + .spyOn(OnboardingSelectors, 'selectCompletedOnboarding') + .mockImplementation((state) => state.onboarding.completedOnboarding); + + const { result, store } = renderUsePushPrePromptVariant({ + completedOnboarding: false, + }); + + await waitFor(() => { + expect(result.current.isResolving).toBe(false); + }); + expect(result.current.variant).toBeNull(); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); + + act(() => { + store.dispatch(setCompletedOnboarding(true)); + }); + + expect(result.current.variant).toBeNull(); + expect(result.current.isResolving).toBe(true); + + await act(async () => { + resolveNativePushPermission?.({ + nativeOsPermissionEnabled: false, + nativeOsPermissionPromptable: true, }); - expect(mockResolvePushNotificationStatus).not.toHaveBeenCalled(); - }, - ); + }); + + await waitFor(() => { + expect(result.current.variant).toBe('push_permission'); + }); + }); + + it('does not return a prompt when basic functionality is disabled', async () => { + // When basicFunctionality is off, the prompt gate fails, so native push is + // never queried and no prompt is shown. + arrangeSelectors({ + isBasicFunctionalityEnabled: false, + }); + + const { result } = renderUsePushPrePromptVariant(); + + await waitFor(() => { + expect(result.current.isResolving).toBe(false); + }); + expect(result.current.variant).toBeNull(); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); + }); + + it('does not return a prompt when the default-on feature flag is disabled', async () => { + arrangeSelectors({ isFeatureFlagOn: false }); + + const { result } = renderUsePushPrePromptVariant(); + + await waitFor(() => { + expect(result.current.isResolving).toBe(false); + }); + expect(result.current.variant).toBeNull(); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); + }); + + it('does not return a prompt when the notifications feature is disabled', async () => { + mockIsNotificationsFeatureEnabled.mockReturnValue(false); + + const { result } = renderUsePushPrePromptVariant(); + + await waitFor(() => { + expect(result.current.isResolving).toBe(false); + }); + expect(result.current.variant).toBeNull(); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); + }); it('does not return a prompt when local storage says it was shown', async () => { arrangeStorage({ [PUSH_PRE_PROMPT_SHOWN]: TRUE }); @@ -190,92 +248,138 @@ describe('usePushPrePromptVariant', () => { await waitFor(() => { expect(result.current.isResolving).toBe(false); - expect(result.current.variant).toBeNull(); }); + expect(result.current.variant).toBeNull(); - expect(storageWrapper.getItemSync).toHaveBeenCalledWith( - PUSH_PRE_PROMPT_SHOWN, - ); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); expect(storageWrapper.setItem).not.toHaveBeenCalled(); }); - it('returns the marketing consent prompt when push is enabled and marketing consent is missing', async () => { - arrangeSelectors({ isPushEnabled: true }); + it('does not reopen in the same session when shown storage is reset', async () => { + let storedPrePromptShown: string | null = TRUE; + jest.spyOn(storageWrapper, 'getItemSync').mockImplementation((key) => { + if (key === PUSH_PRE_PROMPT_SHOWN) { + return storedPrePromptShown; + } + return null; + }); - const { result } = renderUsePushPrePromptVariant(); + const { result, rerender } = renderUsePushPrePromptVariant(); await waitFor(() => { - expect(result.current.variant).toBe('marketing_consent'); + expect(result.current.isResolving).toBe(false); }); - }); + expect(result.current.variant).toBeNull(); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); - it('returns the push permission prompt when native push permission is disabled', async () => { - arrangeSelectors({ isPushEnabled: true }); - mockResolvePushNotificationStatus.mockResolvedValue({ - controllerIsPushEnabled: true, - effectivePushEnabled: false, - nativeOsPermissionEnabled: false, + storedPrePromptShown = null; + rerender(undefined); + + await waitFor(() => { + expect(result.current.isResolving).toBe(false); }); + expect(result.current.variant).toBeNull(); + expect(mockResolveNativePushPermissionStatus).not.toHaveBeenCalled(); + }); + it('returns the marketing consent prompt when OS push is enabled and Redux marketing consent is missing', async () => { const { result } = renderUsePushPrePromptVariant(); await waitFor(() => { - expect(result.current.variant).toBe('push_permission'); + expect(result.current.variant).toBe('marketing_consent'); }); + expect(result.current.nativeOsPermissionEnabled).toBe(true); + expect(mockResolveNativePushPermissionStatus).toHaveBeenCalledTimes(1); }); - it('ignores stale async native permission results after eligibility changes', async () => { - arrangeSelectors({ isPushEnabled: true }); - const nativePermissionCheck = - createDeferred(); - mockResolvePushNotificationStatus - .mockReturnValueOnce(nativePermissionCheck.promise) - .mockResolvedValueOnce({ - controllerIsPushEnabled: true, - effectivePushEnabled: true, - nativeOsPermissionEnabled: true, - }); - - const { result, store } = renderUsePushPrePromptVariant(); + it('does not return a prompt when OS push and Redux marketing consent are enabled', async () => { + const { result } = renderUsePushPrePromptVariant({ + hasMarketingConsent: true, + }); await waitFor(() => { - expect(mockResolvePushNotificationStatus).toHaveBeenCalledTimes(1); + expect(result.current.isResolving).toBe(false); }); + expect(result.current.variant).toBeNull(); + expect(result.current.nativeOsPermissionEnabled).toBe(true); + }); - act(() => { - store.dispatch(setDataCollectionForMarketing(true)); + it('defers the marketing consent prompt while social login marketing consent backfill is pending', async () => { + const { result } = renderUsePushPrePromptVariant({ + pendingSocialLoginMarketingConsentBackfill: 'google', }); await waitFor(() => { expect(result.current.isResolving).toBe(false); - expect(result.current.variant).toBeNull(); }); + expect(result.current.variant).toBeNull(); + expect(result.current.nativeOsPermissionEnabled).toBe(true); + expect(mockResolveNativePushPermissionStatus).toHaveBeenCalledTimes(1); + }); + + it('does not defer the push permission prompt for social login marketing consent backfill', async () => { + mockNativePushPermissionStatus({ + nativeOsPermissionEnabled: false, + nativeOsPermissionPromptable: true, + }); + const { result } = renderUsePushPrePromptVariant({ + pendingSocialLoginMarketingConsentBackfill: 'google', + }); + + await waitFor(() => { + expect(result.current.variant).toBe('push_permission'); + }); + expect(result.current.nativeOsPermissionEnabled).toBe(false); + }); + + it('waits for native push permission before showing marketing consent', async () => { + let resolveNativePushPermission: + | (( + value: Awaited>, + ) => void) + | undefined; + mockResolveNativePushPermissionStatus.mockReturnValue( + new Promise((resolve) => { + resolveNativePushPermission = resolve; + }), + ); + + const { result } = renderUsePushPrePromptVariant(); + + expect(result.current.variant).toBeNull(); + expect(result.current.isResolving).toBe(true); await act(async () => { - nativePermissionCheck.resolve({ - controllerIsPushEnabled: true, - effectivePushEnabled: true, + resolveNativePushPermission?.({ nativeOsPermissionEnabled: true, + nativeOsPermissionPromptable: false, }); }); - expect(result.current.variant).toBeNull(); - expect(mockResolvePushNotificationStatus).toHaveBeenCalledTimes(2); + await waitFor(() => { + expect(result.current.variant).toBe('marketing_consent'); + }); }); - it('does not return a prompt when push and marketing consent are enabled', async () => { - arrangeSelectors({ isPushEnabled: true }); + it('returns null when the native push permission check fails', async () => { + mockResolveNativePushPermissionStatus.mockRejectedValue( + new Error('native permission failed'), + ); - const { result } = renderUsePushPrePromptVariant({ - dataCollectionForMarketing: true, - }); + const { result } = renderUsePushPrePromptVariant(); await waitFor(() => { - expect(result.current.variant).toBeNull(); + expect(result.current.isResolving).toBe(false); }); + expect(result.current.variant).toBeNull(); + expect(result.current.nativeOsPermissionEnabled).toBeNull(); }); it('marks the prompt as shown without hiding it until dismissed', async () => { + mockNativePushPermissionStatus({ + nativeOsPermissionEnabled: false, + nativeOsPermissionPromptable: true, + }); const { result } = renderUsePushPrePromptVariant(); await waitFor(() => { diff --git a/app/util/notifications/hooks/usePushPrePromptVariant.ts b/app/util/notifications/hooks/usePushPrePromptVariant.ts index cdcd4334fee..cd6c8dc85cb 100644 --- a/app/util/notifications/hooks/usePushPrePromptVariant.ts +++ b/app/util/notifications/hooks/usePushPrePromptVariant.ts @@ -1,23 +1,19 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; -import { selectIsUnlocked } from '../../../selectors/keyringController'; -import { - getIsNotificationEnabledByDefaultFeatureFlag, - selectIsMetaMaskPushNotificationsEnabled, -} from '../../../selectors/notifications'; -import { selectBasicFunctionalityEnabled } from '../../../selectors/settings'; +import { getIsNotificationEnabledByDefaultFeatureFlag } from '../../../selectors/notifications'; import { selectCompletedOnboarding, selectPendingSocialLoginMarketingConsentBackfill, } from '../../../selectors/onboarding'; -import { RootState } from '../../../reducers'; +import { selectDataCollectionForMarketingEnabled } from '../../../selectors/engagement'; +import { selectBasicFunctionalityEnabled } from '../../../selectors/settings'; import Logger from '../../Logger'; -import { isNotificationsFeatureEnabled } from '../constants'; import { hasPushPrePromptBeenShown, setPushPrePromptShown, } from '../constants/notification-storage-keys'; -import { resolvePushNotificationStatus } from '../utils/push-notification-status'; +import { isNotificationsFeatureEnabled } from '../constants'; +import { resolveNativePushPermissionStatus } from '../utils/push-notification-status'; export type PushPrePromptVariant = | 'push_permission' @@ -27,51 +23,27 @@ export type PushPrePromptVariant = interface PushPrePromptResolutionState { isResolving: boolean; key: string; + nativeOsPermissionEnabled: boolean | null; variant: PushPrePromptVariant; } interface PushPrePromptEligibility { - completedOnboarding: boolean; + canShowPrePrompt: boolean; + hasPrePromptBeenShown: boolean; hasMarketingConsent: boolean; - isBasicFunctionalityEnabled: boolean; - isNotificationFeatureFlagOn: boolean; - isPushEnabled: boolean; - isUnlocked: boolean; - notificationsFlagEnabled: boolean; pendingSocialLoginMarketingConsentBackfill: string | null; } -const isEligibleForPrePrompt = ({ - completedOnboarding, - isBasicFunctionalityEnabled, - isNotificationFeatureFlagOn, - isUnlocked, - notificationsFlagEnabled, -}: PushPrePromptEligibility): boolean => - isUnlocked && - isBasicFunctionalityEnabled && - completedOnboarding && - isNotificationFeatureFlagOn && - notificationsFlagEnabled; - const getResolutionKey = ({ - completedOnboarding, + canShowPrePrompt, + hasPrePromptBeenShown, hasMarketingConsent, - isBasicFunctionalityEnabled, - isNotificationFeatureFlagOn, - isPushEnabled, - isUnlocked, - notificationsFlagEnabled, pendingSocialLoginMarketingConsentBackfill, }: PushPrePromptEligibility) => [ - `completedOnboarding:${completedOnboarding}`, + `canShowPrePrompt:${canShowPrePrompt}`, + `hasPrePromptBeenShown:${hasPrePromptBeenShown}`, `hasMarketingConsent:${hasMarketingConsent}`, - `isBasicFunctionalityEnabled:${isBasicFunctionalityEnabled}`, - `isNotificationFeatureFlagOn:${isNotificationFeatureFlagOn}`, - `isPushEnabled:${isPushEnabled}`, - `isUnlocked:${isUnlocked}`, - `notificationsFlagEnabled:${notificationsFlagEnabled}`, `pendingSocialLoginMarketingConsentBackfill:${ pendingSocialLoginMarketingConsentBackfill ?? 'null' }`, @@ -80,94 +52,128 @@ const getResolutionKey = ({ const getResolvingState = (key: string): PushPrePromptResolutionState => ({ isResolving: true, key, + nativeOsPermissionEnabled: null, variant: null, }); +interface PushPrePromptResolutionResult { + nativeOsPermissionEnabled: boolean | null; + variant: PushPrePromptVariant; +} + const resolvePrePromptVariant = async ( eligibility: PushPrePromptEligibility, -): Promise => { - if (!isEligibleForPrePrompt(eligibility)) { - return null; - } - - if (hasPushPrePromptBeenShown()) { - return null; +): Promise => { + if (eligibility.hasPrePromptBeenShown) { + return { + nativeOsPermissionEnabled: null, + variant: null, + }; } - if (!eligibility.isPushEnabled) { - return 'push_permission'; + // The prompt is ineligible, so there is nothing to show. + if (!eligibility.canShowPrePrompt) { + return { + nativeOsPermissionEnabled: null, + variant: null, + }; } - const pushStatus = await resolvePushNotificationStatus({ - controllerIsPushEnabled: eligibility.isPushEnabled, - }); + const { nativeOsPermissionEnabled, nativeOsPermissionPromptable } = + await resolveNativePushPermissionStatus(); - if (!pushStatus.effectivePushEnabled) { - return 'push_permission'; + if (!nativeOsPermissionEnabled) { + return { + nativeOsPermissionEnabled, + variant: nativeOsPermissionPromptable ? 'push_permission' : null, + }; } if (eligibility.hasMarketingConsent) { - return null; + return { + nativeOsPermissionEnabled, + variant: null, + }; } if (eligibility.pendingSocialLoginMarketingConsentBackfill) { - return null; + return { + nativeOsPermissionEnabled, + variant: null, + }; } - return 'marketing_consent'; + return { + nativeOsPermissionEnabled, + variant: 'marketing_consent', + }; }; /** * Resolves whether the startup notification pre-prompt should be shown. * - * The startup surface coordinator uses this hook to decide between the push - * permission prompt, the marketing consent prompt, or no prompt. The hook keeps - * the UI in a resolving state while it checks local "already shown" storage and - * native push permission, then exposes helpers for marking the prompt as shown - * and hiding it after the user dismisses or completes the flow. + * The pre-prompt presenter uses this hook to decide between the push permission + * prompt, the marketing consent prompt, or no prompt. The hook keeps the UI in + * a resolving state while it checks local "already shown" storage and native + * push permission, then exposes helpers for marking the prompt as shown and + * hiding it after the user dismisses or completes the flow. + * + * Eligibility is shared by both prompts. Once eligible, native OS push + * permission decides whether to show the push-permission prompt; otherwise, + * Redux marketing consent decides whether to show the marketing-consent prompt. */ export function usePushPrePromptVariant(): { isResolving: boolean; + nativeOsPermissionEnabled: boolean | null; variant: PushPrePromptVariant; markShown: () => Promise; dismiss: () => void; } { - const isUnlocked = Boolean(useSelector(selectIsUnlocked)); - const isBasicFunctionalityEnabled = Boolean( - useSelector(selectBasicFunctionalityEnabled), + // Two independent gates: + // - `isNotificationsFeatureAvailable` gates the notifications feature itself + // (build flag + `assetsNotificationsEnabled` remote flag). + // - `isNotificationsByDefaultFlagOn` gates this post-onboarding nudge + // (`assetsEnableNotificationsByDefault` remote flag). + const isNotificationsFeatureAvailable = isNotificationsFeatureEnabled(); + const isNotificationsByDefaultFlagOn = useSelector( + getIsNotificationEnabledByDefaultFeatureFlag, ); const completedOnboarding = useSelector(selectCompletedOnboarding); - const isPushEnabled = useSelector(selectIsMetaMaskPushNotificationsEnabled); - const isNotificationFeatureFlagOn = useSelector( - getIsNotificationEnabledByDefaultFeatureFlag, + const isBasicFunctionalityEnabled = Boolean( + useSelector(selectBasicFunctionalityEnabled), ); - const notificationsFlagEnabled = isNotificationsFeatureEnabled(); const hasMarketingConsent = useSelector( - (state: RootState) => state.security?.dataCollectionForMarketing === true, + selectDataCollectionForMarketingEnabled, ); const pendingSocialLoginMarketingConsentBackfill = useSelector( selectPendingSocialLoginMarketingConsentBackfill, ); + const canShowPrePrompt = + Boolean(completedOnboarding) && + isNotificationsFeatureAvailable && + isNotificationsByDefaultFlagOn && + isBasicFunctionalityEnabled; + + // Storage resets should affect the next app session/remount, not reopen the + // pre-prompt while this root is already mounted. + const hasPrePromptBeenShownRef = useRef(null); + if (hasPrePromptBeenShownRef.current === null) { + hasPrePromptBeenShownRef.current = hasPushPrePromptBeenShown(); + } + const hasPrePromptBeenShown = hasPrePromptBeenShownRef.current; + const eligibility = useMemo( () => ({ - completedOnboarding: Boolean(completedOnboarding), + canShowPrePrompt, + hasPrePromptBeenShown, hasMarketingConsent, - isBasicFunctionalityEnabled, - isNotificationFeatureFlagOn, - isPushEnabled, - isUnlocked, - notificationsFlagEnabled, pendingSocialLoginMarketingConsentBackfill, }), [ - completedOnboarding, + canShowPrePrompt, + hasPrePromptBeenShown, hasMarketingConsent, - isBasicFunctionalityEnabled, - isNotificationFeatureFlagOn, - isPushEnabled, - isUnlocked, - notificationsFlagEnabled, pendingSocialLoginMarketingConsentBackfill, ], ); @@ -183,14 +189,15 @@ export function usePushPrePromptVariant(): { useState({ isResolving: true, key: resolutionKey, + nativeOsPermissionEnabled: null, variant: null, }); useEffect(() => { let cancelled = false; - // When eligibility inputs change, hold the startup surface in a resolving - // state until storage/native permission checks finish. + // When eligibility inputs change, hold the pre-prompt in a resolving state + // until storage/native permission checks finish. setResolutionState((currentState) => currentState.key === resolutionKey && currentState.isResolving && @@ -199,12 +206,13 @@ export function usePushPrePromptVariant(): { : getResolvingState(resolutionKey), ); - const applyResolvedVariant = (nextVariant: PushPrePromptVariant) => { + const applyResolvedVariant = (result: PushPrePromptResolutionResult) => { if (!cancelled) { setResolutionState({ isResolving: false, key: resolutionKey, - variant: nextVariant, + nativeOsPermissionEnabled: result.nativeOsPermissionEnabled, + variant: result.variant, }); } }; @@ -216,7 +224,10 @@ export function usePushPrePromptVariant(): { error instanceof Error ? error : new Error(String(error)), 'Failed to resolve push pre-prompt variant', ); - applyResolvedVariant(null); + applyResolvedVariant({ + nativeOsPermissionEnabled: null, + variant: null, + }); }); return () => { @@ -225,6 +236,7 @@ export function usePushPrePromptVariant(): { }, [eligibility, resolutionKey]); const markShown = useCallback(async () => { + hasPrePromptBeenShownRef.current = true; await setPushPrePromptShown(); }, []); @@ -234,6 +246,7 @@ export function usePushPrePromptVariant(): { ? { ...currentState, isResolving: false, + nativeOsPermissionEnabled: null, variant: null, } : currentState, @@ -246,6 +259,9 @@ export function usePushPrePromptVariant(): { dismiss, isResolving: isCurrentResolution ? resolutionState.isResolving : true, markShown, + nativeOsPermissionEnabled: isCurrentResolution + ? resolutionState.nativeOsPermissionEnabled + : null, variant: isCurrentResolution ? resolutionState.variant : null, }; } diff --git a/app/util/notifications/hooks/useStartupNotificationsEffect.test.ts b/app/util/notifications/hooks/useStartupNotificationsEffect.test.ts index 0e291f12128..725b2a5d69b 100644 --- a/app/util/notifications/hooks/useStartupNotificationsEffect.test.ts +++ b/app/util/notifications/hooks/useStartupNotificationsEffect.test.ts @@ -145,6 +145,23 @@ describe('useRegisterAndFetchNotifications', () => { }); }); + it('refreshes notification registrations without prompting for push permission', async () => { + const mocks = arrange(); + mocks.selectors.mockIsNotifsEnabled.mockReturnValue(true); + mocks.selectors.mockSelectBasicFunctionalityEnabled.mockReturnValue(true); + mocks.selectors.mockSelectIsUnlocked.mockReturnValue(true); + mocks.selectors.mockSelectIsSignedIn.mockReturnValue(true); + + renderHookWithProvider(() => useRegisterAndFetchNotifications(), {}); + + await waitFor(() => { + expect(mocks.hooks.mockUseEnableNotifications).toHaveBeenCalledWith({ + nudgeEnablePush: false, + }); + expect(mocks.hooks.enableNotifications).toHaveBeenCalled(); + }); + }); + it('does not enable notifications if resubscription has not expired', async () => { const mocks = arrange(); mocks.selectors.mockIsNotifsEnabled.mockReturnValue(true); diff --git a/app/util/notifications/hooks/useStartupNotificationsEffect.ts b/app/util/notifications/hooks/useStartupNotificationsEffect.ts index 1ba01343ff3..4c7740a21ab 100644 --- a/app/util/notifications/hooks/useStartupNotificationsEffect.ts +++ b/app/util/notifications/hooks/useStartupNotificationsEffect.ts @@ -24,10 +24,10 @@ import { } from '../constants/notification-storage-keys'; import { hasNotificationPreferences } from '../../../actions/notification/helpers'; -const showPushNush = { nudgeEnablePush: true }; +const silentPushCheck = { nudgeEnablePush: false }; const useEnableAndRefresh = () => { - const { enableNotifications } = useEnableNotifications(showPushNush); + const { enableNotifications } = useEnableNotifications(silentPushCheck); const { listNotifications } = useListNotifications(); return useCallback( async (shouldEnable = true) => { diff --git a/app/util/notifications/services/NotificationService.test.ts b/app/util/notifications/services/NotificationService.test.ts index 3902cd77a2d..5ae964983e1 100644 --- a/app/util/notifications/services/NotificationService.test.ts +++ b/app/util/notifications/services/NotificationService.test.ts @@ -10,7 +10,12 @@ import { ChannelId, notificationChannels, } from '../../../util/notifications/androidChannels'; -import NotificationService, { getPushPermission } from './NotificationService'; +import NotificationService, { + getPushPermission, + getPushPermissionStatus, + isPushPermissionGranted, + isPushPermissionPromptable, +} from './NotificationService'; import { store } from '../../../store'; jest.mock('@notifee/react-native', () => ({ @@ -146,6 +151,118 @@ describe('getPushPermission', () => { }); }); +describe('isPushPermissionGranted', () => { + const arrangeMocks = (status: string) => + jest.mocked(notifee.getNotificationSettings).mockResolvedValue({ + authorizationStatus: status, + } as unknown as NotificationSettings); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { status: AuthorizationStatus.AUTHORIZED, expected: true }, + { status: AuthorizationStatus.PROVISIONAL, expected: true }, + { status: AuthorizationStatus.DENIED, expected: false }, + { status: AuthorizationStatus.NOT_DETERMINED, expected: false }, + ])( + 'returns $expected when status is $status', + async ({ status, expected }) => { + arrangeMocks(status as unknown as string); + expect(await isPushPermissionGranted()).toBe(expected); + }, + ); + + it('returns false when getNotificationSettings throws', async () => { + jest + .mocked(notifee.getNotificationSettings) + .mockRejectedValue(new Error('TEST ERROR')); + expect(await isPushPermissionGranted()).toBe(false); + }); +}); + +describe('getPushPermissionStatus', () => { + const arrangeMocks = (status: string) => + jest.mocked(notifee.getNotificationSettings).mockResolvedValue({ + authorizationStatus: status, + } as unknown as NotificationSettings); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { status: AuthorizationStatus.AUTHORIZED, expected: 'granted' }, + { status: AuthorizationStatus.PROVISIONAL, expected: 'granted' }, + { status: AuthorizationStatus.NOT_DETERMINED, expected: 'promptable' }, + { status: AuthorizationStatus.DENIED, expected: 'denied' }, + ])( + 'returns $expected when status is $status', + async ({ status, expected }) => { + arrangeMocks(status as unknown as string); + + const result = await getPushPermissionStatus(); + + expect(result).toBe(expected); + }, + ); + + it('returns denied when getNotificationSettings throws', async () => { + jest + .mocked(notifee.getNotificationSettings) + .mockRejectedValue(new Error('TEST ERROR')); + + const result = await getPushPermissionStatus(); + + expect(result).toBe('denied'); + }); +}); + +describe('isPushPermissionPromptable', () => { + const arrangeMocks = (status: string) => + jest.mocked(notifee.getNotificationSettings).mockResolvedValue({ + authorizationStatus: status, + } as unknown as NotificationSettings); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(Platform).OS = 'ios'; + }); + + it.each([ + { status: AuthorizationStatus.AUTHORIZED, expected: false }, + { status: AuthorizationStatus.PROVISIONAL, expected: false }, + { status: AuthorizationStatus.NOT_DETERMINED, expected: true }, + { status: AuthorizationStatus.DENIED, expected: false }, + ])( + 'returns $expected when status is $status', + async ({ status, expected }) => { + arrangeMocks(status as unknown as string); + expect(await isPushPermissionPromptable()).toBe(expected); + }, + ); + + it.each([ + { status: AuthorizationStatus.AUTHORIZED, expected: false }, + { status: AuthorizationStatus.DENIED, expected: true }, + ])( + 'returns $expected on android when status is $status', + async ({ status, expected }) => { + jest.mocked(Platform).OS = 'android'; + arrangeMocks(status as unknown as string); + expect(await isPushPermissionPromptable()).toBe(expected); + }, + ); + + it('returns false when getNotificationSettings throws', async () => { + jest + .mocked(notifee.getNotificationSettings) + .mockRejectedValue(new Error('TEST ERROR')); + expect(await isPushPermissionPromptable()).toBe(false); + }); +}); + describe('NotificationService - getAllPermissions', () => { const arrangeMocks = () => { const mockCreateChannel = jest diff --git a/app/util/notifications/services/NotificationService.ts b/app/util/notifications/services/NotificationService.ts index 1a90ba79141..b2e6b0acaf9 100644 --- a/app/util/notifications/services/NotificationService.ts +++ b/app/util/notifications/services/NotificationService.ts @@ -313,6 +313,25 @@ const NotificationService = new NotificationsService(); export default NotificationService; +export type PushPermissionStatus = 'granted' | 'promptable' | 'denied'; + +const getPushPermissionStatusFromAuthorizationStatus = ( + authorizationStatus: AuthorizationStatus, +): PushPermissionStatus => { + if ( + authorizationStatus === AuthorizationStatus.AUTHORIZED || + authorizationStatus === AuthorizationStatus.PROVISIONAL + ) { + return 'granted'; + } + + if (authorizationStatus === AuthorizationStatus.NOT_DETERMINED) { + return 'promptable'; + } + + return 'denied'; +}; + export async function requestPushPermissions() { const result = await NotificationService.getAllPermissions(true); return result.permission === 'authorized'; @@ -327,3 +346,37 @@ export async function getPushPermission() { const result = await NotificationService.getAllPermissions(false); return result.permission; } + +export async function getPushPermissionStatus(): Promise { + try { + const settings = await notifee.getNotificationSettings(); + return getPushPermissionStatusFromAuthorizationStatus( + settings.authorizationStatus, + ); + } catch { + return 'denied'; + } +} + +/** + * Returns true when the OS has granted push permission (AUTHORIZED or PROVISIONAL). + * NOT_DETERMINED and DENIED both return false. + * Use this to gate registration, settings UI, and pre-prompt eligibility. + */ +export async function isPushPermissionGranted(): Promise { + return (await getPushPermissionStatus()) === 'granted'; +} + +/** + * Returns true when requesting push permission may show the OS dialog. + * iOS exposes a NOT_DETERMINED state, but Notifee only exposes AUTHORIZED/DENIED + * on Android. Treat Android's not-granted state as promptable and let + * requestPermission determine whether the OS can show a dialog. + */ +export async function isPushPermissionPromptable(): Promise { + if (Platform.OS === 'android') { + return !(await isPushPermissionGranted()); + } + + return (await getPushPermissionStatus()) === 'promptable'; +} diff --git a/app/util/notifications/utils/push-notification-status.test.ts b/app/util/notifications/utils/push-notification-status.test.ts index 12234e832bb..61ed6751cdd 100644 --- a/app/util/notifications/utils/push-notification-status.test.ts +++ b/app/util/notifications/utils/push-notification-status.test.ts @@ -1,37 +1,44 @@ -import FCMService from '../services/FCMService'; -import { resolvePushNotificationStatus } from './push-notification-status'; - -jest.mock('../services/FCMService', () => ({ - __esModule: true, - default: { - isPushNotificationsEnabled: jest.fn(), - }, +import { + isPushPermissionGranted, + isPushPermissionPromptable, +} from '../services/NotificationService'; +import { + resolveNativePushPermissionEnabled, + resolveNativePushPermissionStatus, + resolvePushNotificationStatus, +} from './push-notification-status'; + +jest.mock('../services/NotificationService', () => ({ + isPushPermissionGranted: jest.fn(), + isPushPermissionPromptable: jest.fn(), })); -const mockIsPushNotificationsEnabled = jest.mocked( - FCMService.isPushNotificationsEnabled, -); +const mockIsPushPermissionGranted = jest.mocked(isPushPermissionGranted); +const mockIsPushPermissionPromptable = jest.mocked(isPushPermissionPromptable); describe('push-notification-status', () => { beforeEach(() => { - mockIsPushNotificationsEnabled.mockReset(); + mockIsPushPermissionGranted.mockReset(); + mockIsPushPermissionPromptable.mockReset(); }); - it('does not check native permission when controller push is disabled', async () => { + it('checks native permission when controller push is disabled', async () => { + mockIsPushPermissionGranted.mockResolvedValue(true); + const status = await resolvePushNotificationStatus({ controllerIsPushEnabled: false, }); - expect(mockIsPushNotificationsEnabled).not.toHaveBeenCalled(); + expect(mockIsPushPermissionGranted).toHaveBeenCalledTimes(1); expect(status).toEqual({ controllerIsPushEnabled: false, effectivePushEnabled: false, - nativeOsPermissionEnabled: null, + nativeOsPermissionEnabled: true, }); }); it('checks native permission each time push is enabled', async () => { - mockIsPushNotificationsEnabled + mockIsPushPermissionGranted .mockResolvedValueOnce(true) .mockResolvedValueOnce(false); @@ -42,7 +49,7 @@ describe('push-notification-status', () => { controllerIsPushEnabled: true, }); - expect(mockIsPushNotificationsEnabled).toHaveBeenCalledTimes(2); + expect(mockIsPushPermissionGranted).toHaveBeenCalledTimes(2); expect(firstStatus).toEqual({ controllerIsPushEnabled: true, effectivePushEnabled: true, @@ -56,7 +63,7 @@ describe('push-notification-status', () => { }); it('treats native permission errors as disabled push', async () => { - mockIsPushNotificationsEnabled.mockRejectedValue(new Error('nope')); + mockIsPushPermissionGranted.mockRejectedValue(new Error('nope')); const status = await resolvePushNotificationStatus({ controllerIsPushEnabled: true, @@ -68,4 +75,28 @@ describe('push-notification-status', () => { nativeOsPermissionEnabled: false, }); }); + + it('resolves native push permission without controller state', async () => { + mockIsPushPermissionGranted.mockResolvedValue(true); + + const nativePushPermissionEnabled = + await resolveNativePushPermissionEnabled(); + + expect(nativePushPermissionEnabled).toBe(true); + expect(mockIsPushPermissionGranted).toHaveBeenCalledTimes(1); + expect(mockIsPushPermissionPromptable).not.toHaveBeenCalled(); + }); + + it('resolves promptable native push permission status', async () => { + mockIsPushPermissionGranted.mockResolvedValue(false); + mockIsPushPermissionPromptable.mockResolvedValue(true); + + const nativePushPermissionStatus = + await resolveNativePushPermissionStatus(); + + expect(nativePushPermissionStatus).toEqual({ + nativeOsPermissionEnabled: false, + nativeOsPermissionPromptable: true, + }); + }); }); diff --git a/app/util/notifications/utils/push-notification-status.ts b/app/util/notifications/utils/push-notification-status.ts index 45597c8f379..66fcaf801b0 100644 --- a/app/util/notifications/utils/push-notification-status.ts +++ b/app/util/notifications/utils/push-notification-status.ts @@ -1,4 +1,7 @@ -import FCMService from '../services/FCMService'; +import { + isPushPermissionGranted, + isPushPermissionPromptable, +} from '../services/NotificationService'; export interface PushNotificationStatus { controllerIsPushEnabled: boolean; @@ -6,29 +9,48 @@ export interface PushNotificationStatus { nativeOsPermissionEnabled: boolean | null; } +export interface NativePushPermissionStatus { + nativeOsPermissionEnabled: boolean; + nativeOsPermissionPromptable: boolean; +} + interface ResolvePushNotificationStatusOptions { controllerIsPushEnabled: boolean; } -export const resolvePushNotificationStatus = async ({ - controllerIsPushEnabled, -}: ResolvePushNotificationStatusOptions): Promise => { - if (!controllerIsPushEnabled) { +export const resolveNativePushPermissionStatus = + async (): Promise => { + const nativeOsPermissionEnabled = await isPushPermissionGranted().catch( + () => false, + ); + + if (nativeOsPermissionEnabled) { + return { + nativeOsPermissionEnabled, + nativeOsPermissionPromptable: false, + }; + } + + const nativeOsPermissionPromptable = + await isPushPermissionPromptable().catch(() => false); + return { - controllerIsPushEnabled, - effectivePushEnabled: false, - nativeOsPermissionEnabled: null, + nativeOsPermissionEnabled, + nativeOsPermissionPromptable, }; - } + }; + +export const resolveNativePushPermissionEnabled = async (): Promise => + await isPushPermissionGranted().catch(() => false); - const nativeOsPermissionEnabled = - await FCMService.isPushNotificationsEnabled() - .then(Boolean) - .catch(() => false); +export const resolvePushNotificationStatus = async ({ + controllerIsPushEnabled, +}: ResolvePushNotificationStatusOptions): Promise => { + const nativeOsPermissionEnabled = await resolveNativePushPermissionEnabled(); return { controllerIsPushEnabled, - effectivePushEnabled: nativeOsPermissionEnabled, + effectivePushEnabled: controllerIsPushEnabled && nativeOsPermissionEnabled, nativeOsPermissionEnabled, }; }; diff --git a/app/util/test/testSetup.js b/app/util/test/testSetup.js index 787acc38103..110c4507f8a 100644 --- a/app/util/test/testSetup.js +++ b/app/util/test/testSetup.js @@ -757,6 +757,8 @@ jest.mock('redux-persist', () => ({ jest.mock('../../store/storage-wrapper', () => ({ getItem: jest.fn(), + getItemSync: jest.fn(), + removeItem: jest.fn(), setItem: jest.fn(), })); diff --git a/locales/languages/en.json b/locales/languages/en.json index 94f7858ff8a..842faf30b40 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -5357,6 +5357,16 @@ "time": "1h ago", "title": "Received 0.25 ETH", "message": "From 0x9a21…4f8c · $640.29" + }, + "toast": { + "notifications_on": { + "title": "Notifications are on", + "description": "We'll send you transactions, price alerts, and updates." + }, + "notifications_off": { + "title": "Notifications are off", + "description": "Turn them on anytime in Settings → Notifications." + } } }, "existing_user": { @@ -5365,8 +5375,20 @@ "card_title": "What you'll get", "card_description": "Personalized alerts and updates tailored to your trading activity.", "button_confirm": "Confirm", - "button_not_now": "Not now" - } + "button_not_now": "Not now", + "toast": { + "personalized_alerts_on": { + "title": "Personalized alerts is on", + "description": "Manage this anytime in Settings." + }, + "personalized_alerts_off": { + "title": "Personalized alerts is off", + "description": "Turn it on anytime in Settings." + } + } + }, + "toast_enabled": "Notifications enabled", + "toast_settings_hint": "You can enable notifications any time in Settings > Notifications" } }, "protect_your_wallet_modal": { diff --git a/tests/smoke/notifications/enable-notifications-after-onboarding.spec.ts b/tests/smoke/notifications/enable-notifications-after-onboarding.spec.ts index 3768833f73b..adf39dd59b0 100644 --- a/tests/smoke/notifications/enable-notifications-after-onboarding.spec.ts +++ b/tests/smoke/notifications/enable-notifications-after-onboarding.spec.ts @@ -35,19 +35,24 @@ describe(SmokeNetworkAbstractions('Notification Onboarding'), () => { await TabBarComponent.tapAccountsMenu(); await AccountMenu.tapNotifications(); + const featureAnnouncementItemId = getMockFeatureAnnouncementItemId(); + await Assertions.expectElementToBeVisible(NotificationMenuView.title); + await NotificationMenuView.scrollToNotificationItem( + featureAnnouncementItemId, + ); await Assertions.expectElementToBeVisible( NotificationMenuView.selectNotificationItem( - getMockFeatureAnnouncementItemId(), + featureAnnouncementItemId, ), { description: 'Feature Announcement Item', }, ); - // Feature Annonucement Details + // Feature Announcement Details await NotificationMenuView.tapOnNotificationItem( - getMockFeatureAnnouncementItemId(), + featureAnnouncementItemId, ); await Assertions.expectElementToBeVisible( NotificationDetailsView.title, From 7921bf3548b1cbd946af113c05a1a5b9e5217e8d Mon Sep 17 00:00:00 2001 From: infiniteflower <139582705+infiniteflower@users.noreply.github.com> Date: Fri, 29 May 2026 07:09:30 +0900 Subject: [PATCH 6/7] feat: batch sell wire up quote fetching (#30284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Wires Batch Sell quote fetching and quote display into the Batch Sell review flow. This PR replaces the Batch Sell review placeholder state with real batch quote request and quote data handling. It builds per-token quote requests from selected source tokens, allocation amounts, destination stablecoin, slippage, gas-included settings, and wallet address, then sends the batch to `BridgeController`. It also updates the review UI so rows, totals, quote details, and final review consume real quote data. Rows now show batch-level loading states, quote-unavailable states, high-price-impact indicators, per-token slippage settings, and refreshed receive amounts. Allocation sliders update source amounts in Redux and quote requests are debounced to avoid excessive refetching. The destination stablecoin selector now updates the selected stablecoin immediately on row tap, and quote display is guarded so old-destination quotes are treated as stale instead of briefly showing mismatched token or fiat values after switching stablecoins. ## **Changelog** CHANGELOG entry: Added quote fetching and quote review details to Batch Sell. ## **Related issues** Related to: [SWAPS-4439](https://consensyssoftware.atlassian.net/browse/SWAPS-4439) ## **Manual testing steps** ```gherkin Feature: Batch Sell quotes Scenario: user reviews and updates a batch sell quote Given the user has multiple eligible tokens on the same network And the user opens Batch Sell and selects multiple source tokens When the user taps Continue Then the quote screen opens with each allocation defaulted to 100% And token rows and Total received show loading skeletons while quotes load And token rows and Total received show quote amounts after quotes load When the user changes one token allocation with the slider Then all token rows and Total received return to loading while quotes refetch And updated row and total amounts are shown after the refetch completes When the user opens settings for a token row and changes slippage Then quotes are refetched for the batch And updated row and total amounts are shown after the refetch completes When the user changes the destination stablecoin Then stale quotes for the previous stablecoin are hidden And the Review button remains disabled until matching destination quotes load When at least one token has a valid quote and the user taps Review Then the final review sheet opens with only valid quoted tokens reflected in totals ``` ## **Screenshots/Recordings** ### **Before** n/a ### **After** https://github.com/user-attachments/assets/c9193d86-5f53-40c8-a044-57c1e08640bc ## **Automated testing** ```bash yarn jest app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts --runInBand --watchman=false --forceExit yarn jest app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.test.tsx --runInBand --watchman=false --forceExit yarn eslint app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts ``` ## **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. [SWAPS-4439]: https://consensyssoftware.atlassian.net/browse/SWAPS-4439?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Medium Risk** > Touches swap/bridge quote flows, Redux amounts, and controller state; incorrect stale-quote or amount sync could show wrong receive totals before submit (Sell all still TODO). > > **Overview** > Replaces Batch Sell review placeholders with **live batch quote fetching and display** via new `useBatchSellQuoteData` and `useBatchSellQuoteRequest`, driving row amounts, totals, loading/unavailable states, high price impact, quote expiry (“Get new quote”), and gas checks on final review. > > **Allocation and Redux:** Sliders move from 25% snaps to **1% rounded** values with smoother panning; source token amounts, default slippages, and destination token are seeded on Continue and kept in sync when allocations change. **Stale quotes** after switching destination stablecoin are hidden until matching quotes load. > > **UI/navigation:** Quote details and final review read from Redux/controller state instead of route params; unmount clears **BridgeController** quote state but not full bridge Redux. Adds a price-impact info modal and improves stablecoin balance display (fiat vs token fallback). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 229dd4acf9bb8687411c2451bbf9a94a46d419a1. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../BatchSellPercentageSlider.test.tsx | 55 +- .../BatchSellPercentageSlider.tsx | 71 +- .../BatchSellReview/BatchSellReview.test.tsx | 506 ++++++++--- .../BatchSellReview.testIds.ts | 1 + .../Views/BatchSellReview/BatchSellReview.tsx | 272 ++++-- .../BatchSellReviewTokenRow.test.tsx | 166 +++- .../BatchSellReviewTokenRow.tsx | 97 +- .../BatchSellTokenSelect.test.tsx | 52 +- .../BatchSellTokenSelect.tsx | 78 +- .../BatchSellTokenSelect.utils.ts | 8 +- .../UI/Bridge/_mocks_/bridgeReducerState.ts | 1 + .../UI/Bridge/_mocks_/initialState.ts | 1 + ...SellDestinationTokenSelectorModal.test.tsx | 41 +- .../index.tsx | 42 +- .../BatchSellFinalReviewModal.test.tsx | 368 ++++++-- .../BatchSellFinalReviewModal.testIds.ts | 2 + .../BatchSellFinalReviewModal.types.ts | 15 - .../BatchSellFinalReviewModal/index.tsx | 271 ++++-- .../BatchSellNetworkFeeInfoModal.test.tsx | 2 +- .../BatchSellNetworkFeeInfoModal/index.tsx | 2 +- .../BatchSellPriceImpactInfoModal.test.tsx | 66 ++ .../BatchSellPriceImpactInfoModal.testIds.ts | 5 + .../BatchSellPriceImpactInfoModal.types.ts | 3 + .../BatchSellPriceImpactInfoModal/index.tsx | 58 ++ .../BatchSellQuoteDetails.tsx | 20 +- .../BatchSellQuoteDetailsModal.test.tsx | 215 ++++- .../BatchSellQuoteDetailsModal.types.ts | 15 +- .../BatchSellQuoteDetailsModal/index.tsx | 32 +- .../hooks/useBalancesByAssetId/index.test.ts | 117 ++- .../hooks/useBalancesByAssetId/index.ts | 15 +- .../useBatchSellHasSufficientGas/index.ts | 63 ++ .../useBatchSellHasSufficientGas.test.ts | 148 +++ .../hooks/useBatchSellQuoteData/index.ts | 506 +++++++++++ .../useBatchSellQuoteData.test.ts | 847 ++++++++++++++++++ .../hooks/useBatchSellQuoteRequest/index.ts | 223 +++++ .../useBatchSellQuoteRequest.test.ts | 382 ++++++++ .../hooks/useRewards/useRewards.test.ts | 2 +- app/components/UI/Bridge/routes.tsx | 5 + app/components/UI/Bridge/utils/tokenUtils.ts | 11 - .../TradeWalletActions.test.tsx | 28 + .../TradeWalletActions/TradeWalletActions.tsx | 14 +- app/constants/navigation/Routes.ts | 1 + .../bridge-controller-init.test.ts | 24 + .../bridge-controller-init.ts | 11 +- app/core/NavigationService/types.ts | 6 +- app/core/redux/slices/bridge/index.test.ts | 101 ++- app/core/redux/slices/bridge/index.ts | 97 +- app/selectors/bridge.ts | 19 + app/util/test/initial-background-state.json | 2 + locales/languages/en.json | 4 + package.json | 2 +- yarn.lock | 4 +- 52 files changed, 4519 insertions(+), 578 deletions(-) delete mode 100644 app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.types.ts create mode 100644 app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.test.tsx create mode 100644 app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.testIds.ts create mode 100644 app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.types.ts create mode 100644 app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/index.tsx create mode 100644 app/components/UI/Bridge/hooks/useBatchSellHasSufficientGas/index.ts create mode 100644 app/components/UI/Bridge/hooks/useBatchSellHasSufficientGas/useBatchSellHasSufficientGas.test.ts create mode 100644 app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts create mode 100644 app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts create mode 100644 app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/index.ts create mode 100644 app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.test.tsx b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.test.tsx index cdeb67cc914..dc42ef1504c 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.test.tsx +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.test.tsx @@ -3,8 +3,8 @@ import { fireEvent, render } from '@testing-library/react-native'; import { BatchSellPercentageSlider, - SNAP_POINTS, - snapToPercentageStep, + clampToPercentage, + MARKER_POINTS, } from './BatchSellPercentageSlider'; const SLIDER_TEST_ID = 'batch-sell-percentage-slider'; @@ -37,22 +37,22 @@ describe('BatchSellPercentageSlider', () => { it.each([ [-10, 0], [0, 0], - [12, 0], - [13, 25], - [37, 25], - [38, 50], - [62, 50], - [63, 75], - [87, 75], - [88, 100], + [0.4, 0], + [0.5, 1], + [12.4, 12], + [12.5, 13], + [24.4, 24], + [24.5, 25], + [99.4, 99], + [99.5, 100], [120, 100], - ])('snaps %s to %s', (value, expectedValue) => { - const result = snapToPercentageStep(value); + ])('clamps %s to %s', (value, expectedValue) => { + const result = clampToPercentage(value); expect(result).toBe(expectedValue); }); - it('increments accessibility value by one snap point', () => { + it('increments accessibility value by one percentage point', () => { const onValueChange = jest.fn(); const { getByTestId } = render( { nativeEvent: { actionName: 'increment' }, }); - expect(onValueChange).toHaveBeenCalledWith(75); + expect(onValueChange).toHaveBeenCalledWith(51); }); - it('decrements accessibility value by one snap point', () => { + it('decrements accessibility value by one percentage point', () => { const onValueChange = jest.fn(); const { getByTestId } = render( { nativeEvent: { actionName: 'decrement' }, }); - expect(onValueChange).toHaveBeenCalledWith(25); + expect(onValueChange).toHaveBeenCalledWith(49); }); - it('renders muted marker dots for each snap point', () => { + it('does not decrement below 0%', () => { + const onValueChange = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent(getByTestId(SLIDER_TEST_ID), 'accessibilityAction', { + nativeEvent: { actionName: 'decrement' }, + }); + + expect(onValueChange).toHaveBeenCalledWith(0); + }); + + it('renders muted marker dots for each marker point', () => { const { getByTestId } = render( { />, ); - SNAP_POINTS.forEach((snapPoint) => { + MARKER_POINTS.forEach((markerPoint) => { expect( - getByTestId(`${SLIDER_TEST_ID}-snap-point-${snapPoint}`), + getByTestId(`${SLIDER_TEST_ID}-marker-point-${markerPoint}`), ).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.tsx b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.tsx index 6551c17fae2..c221f27f791 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.tsx +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellPercentageSlider.tsx @@ -14,12 +14,13 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; const HANDLE_SIZE = 24; const MARKER_SIZE = 4; -const PERCENTAGE_STEP = 25; -export const SNAP_POINTS = [0, 25, 50, 75, 100]; +const ACCESSIBILITY_STEP = 1; +export const MARKER_POINTS = [25, 50, 75, 100]; +const MIN_PERCENTAGE = 0; +const MAX_PERCENTAGE = 100; -export function snapToPercentageStep(value: number): number { - const snappedValue = Math.round(value / PERCENTAGE_STEP) * PERCENTAGE_STEP; - return Math.max(0, Math.min(100, snappedValue)); +export function clampToPercentage(value: number): number { + return Math.max(MIN_PERCENTAGE, Math.min(MAX_PERCENTAGE, Math.round(value))); } interface BatchSellPercentageSliderProps { @@ -34,26 +35,29 @@ export function BatchSellPercentageSlider({ testID, }: BatchSellPercentageSliderProps) { const tw = useTailwind(); + const clampedValue = clampToPercentage(value); const sliderWidth = useSharedValue(0); const translateX = useSharedValue(0); const widthRef = useRef(0); const updatePosition = useCallback( (nextValue: number, width = widthRef.current) => { - const snappedValue = snapToPercentageStep(nextValue); - translateX.value = (snappedValue / 100) * width; + const nextClampedValue = clampToPercentage(nextValue); + translateX.value = (nextClampedValue / MAX_PERCENTAGE) * width; }, [translateX], ); - const updateValueFromPosition = useCallback( + const commitValueFromPosition = useCallback( (position: number, width: number) => { if (width === 0) { return; } const clampedPosition = Math.max(0, Math.min(position, width)); - const nextValue = snapToPercentageStep((clampedPosition / width) * 100); + const nextValue = clampToPercentage( + (clampedPosition / width) * MAX_PERCENTAGE, + ); updatePosition(nextValue, width); onValueChange(nextValue); @@ -66,14 +70,14 @@ export function BatchSellPercentageSlider({ const { width } = event.nativeEvent.layout; widthRef.current = width; sliderWidth.value = width; - updatePosition(value, width); + updatePosition(clampedValue, width); }, - [sliderWidth, updatePosition, value], + [sliderWidth, clampedValue, updatePosition], ); useEffect(() => { - updatePosition(value); - }, [updatePosition, value]); + updatePosition(clampedValue); + }, [clampedValue, updatePosition]); const progressStyle = useAnimatedStyle(() => ({ width: translateX.value, @@ -95,30 +99,45 @@ export function BatchSellPercentageSlider({ const gesture = Gesture.Simultaneous( Gesture.Tap().onEnd((event) => { - runOnJS(updateValueFromPosition)(event.x, sliderWidth.value); - }), - Gesture.Pan().onUpdate((event) => { - runOnJS(updateValueFromPosition)(event.x, sliderWidth.value); + runOnJS(commitValueFromPosition)(event.x, sliderWidth.value); }), + Gesture.Pan() + .onUpdate((event) => { + const width = sliderWidth.value; + + if (width === 0) { + return; + } + + translateX.value = Math.max(0, Math.min(event.x, width)); + }) + .onEnd((event) => { + runOnJS(commitValueFromPosition)(event.x, sliderWidth.value); + }), ); const handleAccessibilityAction = useCallback( (event: AccessibilityActionEvent) => { const nextValue = event.nativeEvent.actionName === 'increment' - ? snapToPercentageStep(value + PERCENTAGE_STEP) - : snapToPercentageStep(value - PERCENTAGE_STEP); + ? clampToPercentage(clampedValue + ACCESSIBILITY_STEP) + : clampToPercentage(clampedValue - ACCESSIBILITY_STEP); onValueChange(nextValue); }, - [onValueChange, value], + [onValueChange, clampedValue], ); return ( - {SNAP_POINTS.map((snapPoint) => ( + {MARKER_POINTS.map((markerPoint) => ( ; + totalReceived: { formatted: string; formattedFiat: string }; + minimumReceived: { formatted: string }; + isLoading: boolean; + isSummaryLoading: boolean; + hasAnyQuote: boolean; + hasPendingQuoteRows: boolean; + needsNewQuote: boolean; + networkFee: { formatted: string; formattedFiat: string }; + networkFeeIsLoading: boolean; +} + +const defaultQuoteData: MockBatchSellQuoteData = { + tokenData: { + [ethAssetId]: { + key: ethAssetId, + tokenSymbol: 'ETH', + slippage: '2%', + receivedAmount: '3,456.78 USDC', + receivedAmountFiat: '$3,456.78', + }, + [uniAssetId]: { + key: uniAssetId, + tokenSymbol: 'UNI', + slippage: '2%', + receivedAmount: '500 USDC', + receivedAmountFiat: '$500.00', + }, + }, + totalReceived: { + formatted: '3,956.78 USDC', + formattedFiat: '$3,956.78', + }, + minimumReceived: { formatted: '3,900 USDC' }, + isLoading: false, + isSummaryLoading: false, + hasAnyQuote: true, + hasPendingQuoteRows: false, + needsNewQuote: false, + networkFee: { + formatted: '1.20 USDC', + formattedFiat: '$1.20', + }, + networkFeeIsLoading: false, +}; +let mockBatchSellQuoteData = defaultQuoteData; const defaultSelectedTokens: BridgeToken[] = [ { address: '0x1111111111111111111111111111111111111111', @@ -51,6 +123,9 @@ let mockSelectedDestinationToken: BridgeToken | undefined = usdcToken; let mockDestinationTokens: BridgeToken[] = [usdcToken]; let mockBatchSellSlippages: Partial> = {}; +let mockBatchSellSourceTokenAmounts: Partial< + Record +> = {}; jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ @@ -60,6 +135,17 @@ jest.mock('@react-navigation/native', () => ({ }), })); +jest.mock('../../../../../core/Engine', () => ({ + __esModule: true, + default: { + context: { + BridgeController: { + resetState: jest.fn(), + }, + }, + }, +})); + jest.mock('../../../../../core/redux/slices/bridge', () => ({ resetBridgeState: jest.fn(() => ({ type: 'bridge/resetBridgeState', @@ -68,10 +154,31 @@ jest.mock('../../../../../core/redux/slices/bridge', () => ({ selectBatchSellDestStablecoins: jest.fn(() => mockDestinationTokens), selectBatchSellDestToken: jest.fn(() => mockSelectedDestinationToken), selectBatchSellSlippages: jest.fn(() => mockBatchSellSlippages), + selectBatchSellSourceTokenAmounts: jest.fn( + () => mockBatchSellSourceTokenAmounts, + ), setBatchSellDestToken: jest.fn((token: BridgeToken) => ({ type: 'bridge/setBatchSellDestToken', payload: token, })), + setBatchSellSourceTokenAmount: jest.fn( + ({ + assetId, + amount, + }: { + assetId: CaipAssetType; + amount: string | undefined; + }) => ({ + type: 'bridge/setBatchSellSourceTokenAmount', + payload: { assetId, amount }, + }), + ), + setBatchSellSourceTokenAmounts: jest.fn( + (amounts: Partial>) => ({ + type: 'bridge/setBatchSellSourceTokenAmounts', + payload: amounts, + }), + ), setBatchSellSourceTokens: jest.fn((tokens: BridgeToken[]) => ({ type: 'bridge/setBatchSellSourceTokens', payload: tokens, @@ -89,46 +196,27 @@ jest.mock('react-redux', () => ({ useSelector: (selector: (state: unknown) => unknown) => selector({}), })); -jest.mock('./BatchSellReviewTokenRow', () => { - const ReactActual = jest.requireActual('react'); - const { Pressable, Text, View } = jest.requireActual('react-native'); - - return { - BatchSellReviewTokenRow: ({ - isRemoveTokenDisabled, - onRemovePress, - onSlippagePress, - percent, - token, - tokenKey, - }: { - isRemoveTokenDisabled?: boolean; - onRemovePress: (token: BridgeToken) => void; - onSlippagePress: (token: BridgeToken) => void; - percent: number; - token: BridgeToken; - tokenKey: string; - }) => - ReactActual.createElement( - View, - { testID: `batch-sell-review-token-row-${tokenKey}` }, - ReactActual.createElement(Text, null, token.symbol), - ReactActual.createElement(Text, null, `${percent}%`), - ReactActual.createElement(Pressable, { - onPress: () => onSlippagePress(token), - testID: `batch-sell-review-customize-button-${tokenKey}`, - }), - ReactActual.createElement(Pressable, { - accessibilityState: { disabled: Boolean(isRemoveTokenDisabled) }, - disabled: isRemoveTokenDisabled, - onPress: isRemoveTokenDisabled - ? undefined - : () => onRemovePress(token), - testID: `batch-sell-review-remove-button-${tokenKey}`, - }), - ), - }; -}); +jest.mock('../../hooks/useBatchSellQuoteRequest', () => ({ + getBatchSellAtomicSourceAmount: jest.fn( + (token: { balance?: string }, amount?: string) => + token.balance && amount && Number(amount) > 0 ? '1' : undefined, + ), + getBatchSellSourceTokenAmount: jest.fn( + (token: { balance?: string }, percent: number) => { + if (percent <= 0) return '0'; + + return token.balance; + }, + ), + useBatchSellQuoteRequest: jest.fn(() => ({ + updateBatchSellQuoteParams: mockUpdateBatchSellQuoteParams, + getNewQuote: mockGetNewQuote, + })), +})); + +jest.mock('../../hooks/useBatchSellQuoteData', () => ({ + useBatchSellQuoteData: () => mockBatchSellQuoteData, +})); describe('BatchSellReview', () => { beforeEach(() => { @@ -137,33 +225,234 @@ describe('BatchSellReview', () => { mockSelectedDestinationToken = usdcToken; mockDestinationTokens = [usdcToken]; mockBatchSellSlippages = {}; + mockBatchSellSourceTokenAmounts = { + [ethAssetId]: '1.498', + [uniAssetId]: '154.297', + }; + mockBatchSellQuoteData = defaultQuoteData; + mockGetNewQuote.mockClear(); }); - it('renders the quote loading screen', () => { + it('renders the quote review screen', () => { const { getByTestId, getByText } = render(); expect( getByTestId(BatchSellReviewSelectorsIDs.CONTAINER), ).toBeOnTheScreen(); expect(getByText('Total received')).toBeOnTheScreen(); + expect(getByText('$3,956.78')).toBeOnTheScreen(); + expect( + getByTestId(BatchSellReviewSelectorsIDs.DESTINATION_TOKEN_PILL), + ).toBeOnTheScreen(); + expect(getByText('USDC')).toBeOnTheScreen(); + expect(getByText('1.498 ETH • 100%')).toBeOnTheScreen(); + expect(getByText('154.297 UNI • 100%')).toBeOnTheScreen(); + expect(getByText('$3,456.78')).toBeOnTheScreen(); + expect(getByText('$500.00')).toBeOnTheScreen(); + }); + + it('renders the quote loading screen', () => { + mockBatchSellQuoteData = { + ...defaultQuoteData, + isLoading: true, + isSummaryLoading: true, + hasPendingQuoteRows: true, + }; + const { getByTestId } = render(); + const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + expect( getByTestId(BatchSellReviewSelectorsIDs.TOTAL_RECEIVED_SKELETON), ).toBeOnTheScreen(); expect( - getByTestId(BatchSellReviewSelectorsIDs.DESTINATION_TOKEN_PILL), + getByTestId( + `${BatchSellReviewSelectorsIDs.TOKEN_AMOUNT_SKELETON}-0x1:0x1111111111111111111111111111111111111111`, + ), ).toBeOnTheScreen(); - expect(getByText('USDC')).toBeOnTheScreen(); - expect(getByText('ETH')).toBeOnTheScreen(); - expect(getByText('UNI')).toBeOnTheScreen(); + expect( + getByTestId( + `${BatchSellReviewSelectorsIDs.TOKEN_AMOUNT_SKELETON}-0x1:0x2222222222222222222222222222222222222222`, + ), + ).toBeOnTheScreen(); + expect(reviewButton.props.accessibilityState.disabled).toBe(true); + }); + + it('shows available row quotes and progressive total while other rows are still loading', () => { + mockBatchSellQuoteData = { + ...defaultQuoteData, + totalReceived: { + ...defaultQuoteData.totalReceived, + formattedFiat: '$3,456.78', + }, + isLoading: true, + isSummaryLoading: false, + hasPendingQuoteRows: true, + tokenData: { + ...defaultQuoteData.tokenData, + [ethAssetId]: { + ...defaultQuoteData.tokenData[ethAssetId], + isLoading: false, + }, + [uniAssetId]: { + ...defaultQuoteData.tokenData[uniAssetId], + isLoading: true, + }, + }, + }; + + const { getAllByText, getByTestId, queryByTestId } = render( + , + ); + const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + + expect(getAllByText('$3,456.78').length).toBeGreaterThan(0); + expect( + queryByTestId(BatchSellReviewSelectorsIDs.TOTAL_RECEIVED_SKELETON), + ).toBeNull(); + expect( + queryByTestId( + `${BatchSellReviewSelectorsIDs.TOKEN_AMOUNT_SKELETON}-0x1:0x1111111111111111111111111111111111111111`, + ), + ).toBeNull(); + expect( + getByTestId( + `${BatchSellReviewSelectorsIDs.TOKEN_AMOUNT_SKELETON}-0x1:0x2222222222222222222222222222222222222222`, + ), + ).toBeOnTheScreen(); + expect(reviewButton.props.accessibilityState.disabled).toBe(true); + }); + + it('renders no quote available for unavailable rows and allows review with multiple available quotes', () => { + mockSelectedTokens = [...defaultSelectedTokens, thirdSelectedToken]; + mockBatchSellSourceTokenAmounts = { + ...mockBatchSellSourceTokenAmounts, + [linkAssetId]: '42.123', + }; + mockBatchSellQuoteData = { + ...defaultQuoteData, + tokenData: { + ...defaultQuoteData.tokenData, + [linkAssetId]: { + key: linkAssetId, + tokenSymbol: 'LINK', + slippage: '2%', + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isQuoteUnavailable: true, + }, + }, + totalReceived: { + formatted: '3,956.78 USDC', + formattedFiat: '$3,956.78', + }, + isLoading: false, + isSummaryLoading: false, + hasAnyQuote: true, + hasPendingQuoteRows: false, + }; + + const { getByTestId, getByText } = render(); + const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + + expect(getByText('No quote available')).toBeOnTheScreen(); + expect(getByText('42.123 LINK • 100%')).toBeOnTheScreen(); + expect(reviewButton.props.accessibilityState.disabled).not.toBe(true); + }); + + it('opens final review without a quote snapshot when unavailable rows are present', () => { + mockSelectedTokens = [...defaultSelectedTokens, thirdSelectedToken]; + mockBatchSellSourceTokenAmounts = { + ...mockBatchSellSourceTokenAmounts, + [linkAssetId]: '42.123', + }; + mockBatchSellQuoteData = { + ...defaultQuoteData, + tokenData: { + ...defaultQuoteData.tokenData, + [linkAssetId]: { + key: linkAssetId, + tokenSymbol: 'LINK', + slippage: '2%', + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isLoading: false, + isQuoteUnavailable: true, + }, + }, + hasAnyQuote: true, + hasPendingQuoteRows: false, + }; + + const { getByTestId } = render(); + + fireEvent.press(getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, + }); + }); + + it('disables review when no rows have quotes', () => { + mockBatchSellQuoteData = { + ...defaultQuoteData, + tokenData: Object.entries(defaultQuoteData.tokenData).reduce< + MockBatchSellQuoteData['tokenData'] + >((tokenDataByAssetId, [assetId, tokenData]) => { + tokenDataByAssetId[assetId] = { + ...tokenData, + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isQuoteUnavailable: true, + }; + return tokenDataByAssetId; + }, {}), + totalReceived: { + formatted: '-- USDC', + formattedFiat: '-', + }, + minimumReceived: { formatted: '-- USDC' }, + isLoading: false, + isSummaryLoading: false, + hasAnyQuote: false, + hasPendingQuoteRows: false, + }; + const { getAllByText, getByTestId } = render(); + const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + + expect(getAllByText('No quote available')).toHaveLength(2); + expect(reviewButton.props.accessibilityState.disabled).toBe(true); }); it('sets selected token percents to 100% on entry', () => { - const { getAllByText } = render(); + const { getByText } = render(); + + expect(getByText('1.498 ETH • 100%')).toBeOnTheScreen(); + expect(getByText('154.297 UNI • 100%')).toBeOnTheScreen(); + }); + + it('does not dispatch source token amount updates when undefined values are unchanged', () => { + mockSelectedTokens = [ + { + ...defaultSelectedTokens[0], + balance: undefined, + }, + defaultSelectedTokens[1], + ]; + mockBatchSellSourceTokenAmounts = { + [ethAssetId]: undefined, + [uniAssetId]: '154.297', + }; + + render(); + + const sourceAmountUpdateCalls = mockDispatch.mock.calls.filter( + ([action]) => action?.type === 'bridge/setBatchSellSourceTokenAmounts', + ); - expect(getAllByText('100%')).toHaveLength(mockSelectedTokens.length); + expect(sourceAmountUpdateCalls).toHaveLength(0); }); - it('enables the review button while quote placeholders are available', () => { + it('enables the review button when quotes are available', () => { const { getByTestId, getByText } = render(); const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); @@ -175,11 +464,10 @@ describe('BatchSellReview', () => { mockSelectedDestinationToken = undefined; mockDestinationTokens = []; - const { getByTestId, getByText, queryByText } = render(); + const { getByTestId, getByText } = render(); const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); expect(getByText('UNKNOWN')).toBeOnTheScreen(); - expect(queryByText('USDC')).toBeNull(); expect(reviewButton.props.accessibilityState.disabled).not.toBe(true); }); @@ -204,25 +492,33 @@ describe('BatchSellReview', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { screen: Routes.BRIDGE.MODALS.BATCH_SELL_QUOTE_DETAILS_MODAL, - params: { - tokenData: [ - { - key: '0x1:0x1111111111111111111111111111111111111111', - tokenSymbol: 'ETH', - slippage: '2%', - receivedAmount: '-- USDC', - }, - { - key: '0x1:0x2222222222222222222222222222222222222222', - tokenSymbol: 'UNI', - slippage: '2%', - receivedAmount: '-- USDC', - }, - ], - totalReceived: '-- USDC', - minimumReceived: '-- USDC', - isLoading: false, + }); + }); + + it('opens the high price impact info modal from a token row tag', () => { + mockBatchSellQuoteData = { + ...defaultQuoteData, + tokenData: { + ...defaultQuoteData.tokenData, + [ethAssetId]: { + ...defaultQuoteData.tokenData[ethAssetId], + priceImpact: '0.06', + isHighPriceImpact: true, + }, }, + }; + const { getByTestId, getByText } = render(); + + expect(getByText('High price impact')).toBeOnTheScreen(); + fireEvent.press( + getByTestId( + `${BatchSellReviewSelectorsIDs.HIGH_PRICE_IMPACT_TAG}-0x1:0x1111111111111111111111111111111111111111`, + ), + ); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.BATCH_SELL_PRICE_IMPACT_INFO_MODAL, + params: { priceImpact: '0.06' }, }); }); @@ -233,38 +529,26 @@ describe('BatchSellReview', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, - params: { - tokenData: [ - { - key: '0x1:0x1111111111111111111111111111111111111111', - tokenSymbol: 'ETH', - slippage: '2%', - receivedAmount: '-- USDC', - }, - { - key: '0x1:0x2222222222222222222222222222222222222222', - tokenSymbol: 'UNI', - slippage: '2%', - receivedAmount: '-- USDC', - }, - ], - totalReceived: '-- USDC', - minimumReceived: '-- USDC', - isLoading: false, - sourceTokens: [ - { - key: '0x1:0x1111111111111111111111111111111111111111', - tokenSymbol: 'ETH', - }, - { - key: '0x1:0x2222222222222222222222222222222222222222', - tokenSymbol: 'UNI', - }, - ], - networkFee: '1.20 USDC', - networkFeeFiat: '$1.20', - metamaskFeePercent: '0.875', - }, + }); + }); + + it('shows Get new quote when max refresh expires and fetches fresh quotes', () => { + mockBatchSellQuoteData = { + ...defaultQuoteData, + needsNewQuote: true, + networkFeeIsLoading: true, + hasPendingQuoteRows: true, + }; + const { getByTestId, getByText } = render(); + const reviewButton = getByTestId(BatchSellReviewSelectorsIDs.REVIEW_BUTTON); + + fireEvent.press(reviewButton); + + expect(getByText('Get new quote')).toBeOnTheScreen(); + expect(reviewButton.props.accessibilityState.disabled).not.toBe(true); + expect(mockGetNewQuote).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, }); }); @@ -282,8 +566,7 @@ describe('BatchSellReview', () => { params: { sourceChainId: '0x1', destChainId: '0x1', - batchSellAssetId: - 'eip155:1/erc20:0x1111111111111111111111111111111111111111', + batchSellAssetId: ethAssetId, }, }); }); @@ -310,13 +593,28 @@ describe('BatchSellReview', () => { }); }); - it('resets bridge state on unmount', () => { + it('updates Batch Sell quote params when Redux inputs are present', () => { + render(); + + expect(mockUpdateBatchSellQuoteParams).toHaveBeenCalled(); + }); + + it('cancels the Batch Sell quote params update on unmount', () => { + const { unmount } = render(); + + unmount(); + + expect(mockCancelBatchSellQuoteParams).toHaveBeenCalled(); + }); + + it('resets controller quote state but leaves Redux bridge state intact on unmount', () => { const { unmount } = render(); mockDispatch.mockClear(); unmount(); - expect(mockDispatch).toHaveBeenCalledWith({ + expect(Engine.context.BridgeController.resetState).toHaveBeenCalledTimes(1); + expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'bridge/resetBridgeState', }); }); diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.testIds.ts b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.testIds.ts index 76623b19dfc..08a4e4cdbfc 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.testIds.ts +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.testIds.ts @@ -5,6 +5,7 @@ export const BatchSellReviewSelectorsIDs = { DESTINATION_TOKEN_PILL: 'batch-sell-review-destination-token-pill', TOKEN_ROW: 'batch-sell-review-token-row', TOKEN_AMOUNT_SKELETON: 'batch-sell-review-token-amount-skeleton', + HIGH_PRICE_IMPACT_TAG: 'batch-sell-review-high-price-impact-tag', TOKEN_SLIDER: 'batch-sell-review-token-slider', CUSTOMIZE_BUTTON: 'batch-sell-review-customize-button', REMOVE_BUTTON: 'batch-sell-review-remove-button', diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx index 0547acd716e..0fb0ad9f344 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReview.tsx @@ -1,8 +1,9 @@ import { useNavigation } from '@react-navigation/native'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ScrollView } from 'react-native-gesture-handler'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useDispatch, useSelector } from 'react-redux'; +import { formatAddressToAssetId } from '@metamask/bridge-controller'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { AvatarToken, @@ -29,67 +30,82 @@ import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; import { - resetBridgeState, selectBatchSellSlippages, selectBatchSellDestToken, selectBatchSellDestStablecoins, + selectBatchSellSourceTokenAmounts, selectBatchSellSourceTokens, setBatchSellDestToken, + setBatchSellSourceTokenAmount, + setBatchSellSourceTokenAmounts, setBatchSellSourceTokens, setBatchSellTokenSlippages, } from '../../../../../core/redux/slices/bridge'; import { RootState } from '../../../../../reducers'; +import Engine from '../../../../../core/Engine'; import { BridgeToken } from '../../types'; -import { getBridgeTokenAssetId } from '../../utils/tokenUtils'; -import { - DEFAULT_BATCH_SELL_SLIPPAGE, - getBatchSellSlippage, - getSlippageDisplayValue, -} from '../../components/SlippageModal/utils'; -import { BatchSellFinalReviewSourceTokenData } from '../../components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.types'; +import { getBatchSellSlippage } from '../../components/SlippageModal/utils'; import { BatchSellReviewSelectorsIDs } from './BatchSellReview.testIds'; import { BatchSellReviewTokenRow } from './BatchSellReviewTokenRow'; +import { + getBatchSellAtomicSourceAmount, + getBatchSellSourceTokenAmount, + useBatchSellQuoteRequest, +} from '../../hooks/useBatchSellQuoteRequest'; +import { useBatchSellQuoteData } from '../../hooks/useBatchSellQuoteData'; const DEFAULT_PERCENT = 100; const UNKNOWN_DESTINATION_TOKEN_SYMBOL = 'UNKNOWN'; -// TODO(SWAPS-4439): When Batch Sell quote fetching is wired, pass -// batchSellSlippages[assetId] into each token's BridgeController quote request. -const HAS_QUOTES = true; -const QUOTE_DETAILS_PLACEHOLDER_AMOUNT = '--'; -const NETWORK_FEE_PLACEHOLDER = '1.20 USDC'; -const NETWORK_FEE_FIAT_PLACEHOLDER = '$1.20'; -const METAMASK_FEE_PERCENT = '0.875'; +const TOTAL_RECEIVED_SKELETON_WIDTH = 195; +const TOTAL_RECEIVED_SKELETON_HEIGHT = 50; const getTokenKey = (token: BridgeToken) => `${token.chainId}:${token.address}`; -function getSourceTokenData( - token: BridgeToken, -): BatchSellFinalReviewSourceTokenData { - const sourceTokenData: BatchSellFinalReviewSourceTokenData = { - key: getTokenKey(token), - tokenSymbol: token.symbol, - }; +function TotalReceivedValue({ + totalReceived, + isLoading, +}: { + totalReceived: { formattedFiat: string }; + isLoading: boolean; +}) { + const tw = useTailwind(); - if (token.image) sourceTokenData.image = token.image; + if (isLoading) { + return ( + + ); + } - return sourceTokenData; + return ( + + {totalReceived.formattedFiat} + + ); } -function areBatchSellSlippageMapsEqual( +function areBatchSellValueMapsEqual( first: Record, second: Record, ) { const firstKeys = Object.keys(first); const secondKeys = Object.keys(second); - return ( - firstKeys.length === secondKeys.length && - firstKeys.every( - (assetId) => - Object.prototype.hasOwnProperty.call(second, assetId) && - first[assetId] === second[assetId], - ) - ); + if (firstKeys.length !== secondKeys.length) return false; + + return firstKeys.every((assetId) => { + if (!Object.prototype.hasOwnProperty.call(second, assetId)) return false; + + return Object.is(first[assetId], second[assetId]); + }); } export function BatchSellReview() { @@ -103,10 +119,31 @@ export function BatchSellReview() { ); const selectedDestinationToken = useSelector(selectBatchSellDestToken); const batchSellSlippages = useSelector(selectBatchSellSlippages); + const batchSellSourceTokenAmounts = useSelector( + selectBatchSellSourceTokenAmounts, + ); const isRemoveTokenDisabled = selectedTokens.length <= 2; const [percentsByTokenKey, setPercentsByTokenKey] = useState< Record >({}); + const { updateBatchSellQuoteParams, getNewQuote: handleGetNewQuote } = + useBatchSellQuoteRequest(); + const batchSellQuoteData = useBatchSellQuoteData(); + const hasValidBatchSellInputs = useMemo( + () => + Boolean(selectedDestinationToken) && + selectedTokens.some((token) => { + const assetId = formatAddressToAssetId(token.address, token.chainId); + return ( + assetId && + getBatchSellAtomicSourceAmount( + token, + batchSellSourceTokenAmounts[assetId], + ) + ); + }), + [batchSellSourceTokenAmounts, selectedDestinationToken, selectedTokens], + ); // Seed the selected destination token on entry so the pill always reads from Redux. useEffect(() => { @@ -126,20 +163,62 @@ export function BatchSellReview() { ); }, [selectedTokens]); - // Reset bridge state when component unmounts. + useEffect(() => { + if (hasValidBatchSellInputs) { + updateBatchSellQuoteParams(); + } + + return () => { + updateBatchSellQuoteParams.cancel(); + }; + }, [hasValidBatchSellInputs, updateBatchSellQuoteParams]); + useEffect( () => () => { - dispatch(resetBridgeState()); + // Clear controller quote state so returning to review does not show stale quotes. + Engine.context.BridgeController?.resetState?.(); }, - [dispatch], + [], ); + useEffect(() => { + const nextSourceTokenAmounts = selectedTokens.reduce< + Record + >((sourceAmountsByAssetId, token) => { + const assetId = formatAddressToAssetId(token.address, token.chainId); + + if (!assetId) return sourceAmountsByAssetId; + + sourceAmountsByAssetId[assetId] = + batchSellSourceTokenAmounts[assetId] ?? + getBatchSellSourceTokenAmount( + token, + percentsByTokenKey[getTokenKey(token)] ?? DEFAULT_PERCENT, + ); + return sourceAmountsByAssetId; + }, {}); + + if ( + !areBatchSellValueMapsEqual( + batchSellSourceTokenAmounts, + nextSourceTokenAmounts, + ) + ) { + dispatch(setBatchSellSourceTokenAmounts(nextSourceTokenAmounts)); + } + }, [ + batchSellSourceTokenAmounts, + dispatch, + percentsByTokenKey, + selectedTokens, + ]); + useEffect(() => { // Keep Redux slippages aligned with selected tokens when the user removes tokens. const nextSlippage = selectedTokens.reduce< Record >((slippageByAssetId, token) => { - const assetId = getBridgeTokenAssetId(token); + const assetId = formatAddressToAssetId(token.address, token.chainId); if (!assetId) return slippageByAssetId; @@ -150,7 +229,7 @@ export function BatchSellReview() { return slippageByAssetId; }, {}); - if (!areBatchSellSlippageMapsEqual(batchSellSlippages, nextSlippage)) { + if (!areBatchSellValueMapsEqual(batchSellSlippages, nextSlippage)) { dispatch(setBatchSellTokenSlippages(nextSlippage)); } }, [batchSellSlippages, dispatch, selectedTokens]); @@ -161,8 +240,24 @@ export function BatchSellReview() { ...currentPercents, [tokenKey]: percent, })); + + const token = selectedTokens.find( + (selectedToken) => getTokenKey(selectedToken) === tokenKey, + ); + const assetId = token + ? formatAddressToAssetId(token.address, token.chainId) + : undefined; + + if (!token || !assetId) return; + + dispatch( + setBatchSellSourceTokenAmount({ + assetId, + amount: getBatchSellSourceTokenAmount(token, percent), + }), + ); }, - [], + [dispatch, selectedTokens], ); const handleBackPress = useCallback(() => { @@ -175,54 +270,31 @@ export function BatchSellReview() { }); }, [navigation]); - const getQuoteDetailsParams = useCallback(() => { - const destinationTokenSymbol = - selectedDestinationToken?.symbol ?? UNKNOWN_DESTINATION_TOKEN_SYMBOL; - const placeholderAmount = `${QUOTE_DETAILS_PLACEHOLDER_AMOUNT} ${destinationTokenSymbol}`; - - return { - tokenData: selectedTokens.map((token) => { - const assetId = getBridgeTokenAssetId(token); - const slippage = assetId - ? getBatchSellSlippage(batchSellSlippages, assetId) - : DEFAULT_BATCH_SELL_SLIPPAGE; - - return { - key: getTokenKey(token), - tokenSymbol: token.symbol, - slippage: getSlippageDisplayValue(slippage), - receivedAmount: placeholderAmount, - }; - }), - totalReceived: placeholderAmount, - minimumReceived: placeholderAmount, - isLoading: !HAS_QUOTES, - }; - }, [batchSellSlippages, selectedDestinationToken?.symbol, selectedTokens]); - const handleOpenQuoteDetails = useCallback(() => { navigation.navigate(Routes.BRIDGE.MODALS.ROOT, { screen: Routes.BRIDGE.MODALS.BATCH_SELL_QUOTE_DETAILS_MODAL, - params: getQuoteDetailsParams(), }); - }, [getQuoteDetailsParams, navigation]); + }, [navigation]); + + const handleOpenHighPriceImpactInfo = useCallback( + (priceImpact: string) => { + navigation.navigate(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.BATCH_SELL_PRICE_IMPACT_INFO_MODAL, + params: { priceImpact }, + }); + }, + [navigation], + ); const handleOpenFinalReview = useCallback(() => { navigation.navigate(Routes.BRIDGE.MODALS.ROOT, { screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, - params: { - ...getQuoteDetailsParams(), - sourceTokens: selectedTokens.map(getSourceTokenData), - networkFee: NETWORK_FEE_PLACEHOLDER, - networkFeeFiat: NETWORK_FEE_FIAT_PLACEHOLDER, - metamaskFeePercent: METAMASK_FEE_PERCENT, - }, }); - }, [getQuoteDetailsParams, navigation, selectedTokens]); + }, [navigation]); const handleSlippagePress = useCallback( (token: BridgeToken) => { - const assetId = getBridgeTokenAssetId(token); + const assetId = formatAddressToAssetId(token.address, token.chainId); if (!assetId) return; @@ -262,7 +334,7 @@ export function BatchSellReview() { twClassName="flex-1 bg-default" > - + - diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.test.tsx b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.test.tsx index b1adef656e5..8dc60fae609 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.test.tsx +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.test.tsx @@ -22,55 +22,6 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ }), })); -jest.mock('@metamask/design-system-react-native', () => { - const ReactActual = jest.requireActual('react'); - const { - Pressable: RNPressable, - View: RNView, - Text: RNText, - } = jest.requireActual('react-native'); - - return { - AvatarToken: ({ testID }: { testID?: string }) => - ReactActual.createElement(RNView, { testID }), - AvatarTokenSize: { Lg: 'lg' }, - Box: ({ children, ...props }: { children?: React.ReactNode }) => - ReactActual.createElement(RNView, props, children), - BoxAlignItems: { Center: 'center' }, - BoxFlexDirection: { Row: 'row' }, - ButtonIcon: ({ - accessibilityLabel, - isDisabled, - onPress, - testID, - }: { - accessibilityLabel?: string; - isDisabled?: boolean; - onPress?: () => void; - testID?: string; - }) => - ReactActual.createElement( - RNPressable, - { - accessibilityLabel, - accessibilityState: { disabled: Boolean(isDisabled) }, - disabled: isDisabled, - onPress: isDisabled ? undefined : onPress, - testID, - }, - null, - ), - ButtonIconSize: { Md: 'md' }, - FontWeight: { Medium: 'medium' }, - IconColor: { IconAlternative: 'icon-alternative' }, - IconName: { Customize: 'customize', RemoveMinus: 'remove-minus' }, - Text: ({ children, ...props }: { children?: React.ReactNode }) => - ReactActual.createElement(RNText, props, children), - TextColor: { TextAlternative: 'text-alternative' }, - TextVariant: { BodySm: 'body-sm' }, - }; -}); - jest.mock('../../../../../component-library/components-temp/Skeleton', () => { const ReactActual = jest.requireActual('react'); const { View: RNView } = jest.requireActual('react-native'); @@ -119,6 +70,8 @@ describe('BatchSellReviewTokenRow', () => { token={mockToken} tokenKey={mockTokenKey} percent={100} + receivedAmount="123.45 USDC" + isLoading onPercentChange={mockOnPercentChange} onSlippagePress={mockOnSlippagePress} onRemovePress={mockOnRemovePress} @@ -136,12 +89,110 @@ describe('BatchSellReviewTokenRow', () => { expect(getByText('1.49812 ETH • 100%')).toBeOnTheScreen(); }); + it('renders the received amount when loaded', () => { + const { getByText, queryByTestId } = render( + , + ); + + expect(getByText('123.45 USDC')).toBeOnTheScreen(); + expect( + queryByTestId( + `${BatchSellReviewSelectorsIDs.TOKEN_AMOUNT_SKELETON}-${mockTokenKey}`, + ), + ).toBeNull(); + }); + + it('renders and forwards high price impact tag presses', () => { + const mockOnHighPriceImpactPress = jest.fn(); + const { getByTestId, getByText } = render( + , + ); + const tag = getByTestId( + `${BatchSellReviewSelectorsIDs.HIGH_PRICE_IMPACT_TAG}-${mockTokenKey}`, + ); + + expect(getByText('High price impact')).toBeOnTheScreen(); + fireEvent.press(tag); + + expect(mockOnHighPriceImpactPress).toHaveBeenCalledTimes(1); + }); + + it('does not render high price impact tag while loading or unavailable', () => { + const { queryByTestId, rerender } = render( + , + ); + const tagTestId = `${BatchSellReviewSelectorsIDs.HIGH_PRICE_IMPACT_TAG}-${mockTokenKey}`; + + expect(queryByTestId(tagTestId)).toBeNull(); + + rerender( + , + ); + + expect(queryByTestId(tagTestId)).toBeNull(); + }); + + it('renders a no quote available row state', () => { + const { getByText, queryByTestId } = render( + , + ); + + const noQuoteText = getByText('No quote available'); + + expect(noQuoteText).toBeOnTheScreen(); + expect( + queryByTestId( + `${BatchSellReviewSelectorsIDs.TOKEN_AMOUNT_SKELETON}-${mockTokenKey}`, + ), + ).toBeNull(); + }); + it('matches token picker balance formatting for tiny balances', () => { const { getByText } = render( , ); @@ -149,12 +200,27 @@ describe('BatchSellReviewTokenRow', () => { expect(getByText('< 0.00001 ETH • 100%')).toBeOnTheScreen(); }); + it('renders the source amount used in the quote request', () => { + const { getByText } = render( + , + ); + + expect(getByText('0.74906 ETH • 50%')).toBeOnTheScreen(); + }); + it('forwards slider percent changes', () => { const { getByTestId } = render( , ); @@ -174,6 +240,7 @@ describe('BatchSellReviewTokenRow', () => { token={mockToken} tokenKey={mockTokenKey} percent={100} + receivedAmount="123.45 USDC" onPercentChange={mockOnPercentChange} onSlippagePress={mockOnSlippagePress} onRemovePress={mockOnRemovePress} @@ -201,6 +268,7 @@ describe('BatchSellReviewTokenRow', () => { token={mockToken} tokenKey={mockTokenKey} percent={100} + receivedAmount="123.45 USDC" onPercentChange={mockOnPercentChange} onRemovePress={mockOnRemovePress} isRemoveTokenDisabled diff --git a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.tsx b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.tsx index c5a7be0ae71..b3d614c170e 100644 --- a/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.tsx +++ b/app/components/UI/Bridge/Views/BatchSellReview/BatchSellReviewTokenRow.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useMemo } from 'react'; +import { Pressable } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { AvatarToken, @@ -9,8 +10,10 @@ import { ButtonIcon, ButtonIconSize, FontWeight, + Icon, IconColor, IconName, + IconSize, Text, TextColor, TextVariant, @@ -22,11 +25,17 @@ import { formatTokenBalance } from '../../utils'; import { BridgeToken } from '../../types'; import { BatchSellReviewSelectorsIDs } from './BatchSellReview.testIds'; import { BatchSellPercentageSlider } from './BatchSellPercentageSlider'; +import { getBatchSellSourceTokenAmount } from '../../hooks/useBatchSellQuoteRequest'; interface BatchSellReviewTokenRowProps { token: BridgeToken; tokenKey: string; percent: number; + receivedAmount: string; + isLoading?: boolean; + isQuoteUnavailable?: boolean; + isHighPriceImpact?: boolean; + onHighPriceImpactPress?: () => void; onPercentChange: (tokenKey: string, percent: number) => void; onSlippagePress?: (token: BridgeToken) => void; onRemovePress?: (token: BridgeToken) => void; @@ -34,8 +43,9 @@ interface BatchSellReviewTokenRowProps { } function getTokenBalanceText(token: BridgeToken, percent: number) { - const balanceText = token.balance - ? `${formatTokenBalance(token.balance)} ${token.symbol}` + const sourceAmount = getBatchSellSourceTokenAmount(token, percent); + const balanceText = sourceAmount + ? `${formatTokenBalance(sourceAmount)} ${token.symbol}` : token.symbol; return `${balanceText} • ${percent}%`; @@ -45,6 +55,11 @@ export function BatchSellReviewTokenRow({ token, tokenKey, percent, + receivedAmount, + isLoading = false, + isQuoteUnavailable = false, + isHighPriceImpact = false, + onHighPriceImpactPress, onPercentChange, onSlippagePress, onRemovePress, @@ -55,6 +70,8 @@ export function BatchSellReviewTokenRow({ () => getTokenBalanceText(token, percent), [percent, token], ); + const shouldShowHighPriceImpactTag = + !isLoading && !isQuoteUnavailable && isHighPriceImpact; const handlePercentChange = useCallback( (nextPercent: number) => { @@ -85,12 +102,76 @@ export function BatchSellReviewTokenRow({ size={AvatarTokenSize.Lg} /> - + {isLoading ? ( + + ) : isQuoteUnavailable ? ( + + {strings('bridge.batch_sell_no_quote_available')} + + ) : ( + + + {receivedAmount} + + {shouldShowHighPriceImpactTag && ( + + tw.style( + 'rounded-md bg-warning-muted px-1.5 py-0.5', + pressed && 'opacity-70', + ) + } + > + + + + {strings('bridge.batch_sell_high_price_impact')} + + + + )} + + )} ({ type: 'bridge/setBatchSellSourceTokens', payload: tokens, })), + setBatchSellSourceTokenAmounts: jest.fn( + (amounts: Partial>) => ({ + type: 'bridge/setBatchSellSourceTokenAmounts', + payload: amounts, + }), + ), + setBatchSellDestToken: jest.fn((token: BridgeToken | undefined) => ({ + type: 'bridge/setBatchSellDestToken', + payload: token, + })), + setBatchSellTokenSlippages: jest.fn( + (slippages: Partial>) => ({ + type: 'bridge/setBatchSellTokenSlippages', + payload: slippages, + }), + ), })); jest.mock('../../components/TokenSelectorItem', () => { @@ -402,16 +419,17 @@ describe('BatchSellTokenSelect', () => { expect(queryByText('USDC')).not.toBeOnTheScreen(); }); - it('resets bridge state on unmount', () => { + it('resets bridge state on mount', () => { const { unmount } = render(); - expect(mockDispatch).not.toHaveBeenCalledWith({ + expect(mockDispatch).toHaveBeenCalledWith({ type: 'bridge/resetBridgeState', }); + mockDispatch.mockClear(); unmount(); - expect(mockDispatch).toHaveBeenCalledWith({ + expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'bridge/resetBridgeState', }); }); @@ -841,12 +859,15 @@ describe('BatchSellTokenSelect', () => { }); }); - it('dispatches selected source tokens for multi-token handoff', () => { + it('dispatches Batch Sell Redux handoff data for multi-token Continue', () => { + const stablecoinAssetId = + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType; const firstToken = createToken({ symbol: 'ONE' }); const secondToken = createToken({ symbol: 'TWO', address: '0x2222222222222222222222222222222222222222', }); + mockDestinationStablecoins = [BridgeTokenMetadata[stablecoinAssetId]]; mockWalletTokens = [firstToken, secondToken]; const { getByTestId, getByText } = render(); @@ -855,12 +876,33 @@ describe('BatchSellTokenSelect', () => { fireEvent.press(getByText('TWO')); expect(getByText('Continue with (2) tokens')).toBeOnTheScreen(); + mockDispatch.mockClear(); fireEvent.press(getByTestId(BatchSellTokenSelectSelectorsIDs.NEXT_BUTTON)); - expect(mockDispatch).toHaveBeenCalledWith({ + expect(mockDispatch).toHaveBeenNthCalledWith(1, { type: 'bridge/setBatchSellSourceTokens', payload: [firstToken, secondToken], }); + expect(mockDispatch).toHaveBeenNthCalledWith(2, { + type: 'bridge/setBatchSellSourceTokenAmounts', + payload: { + 'eip155:1/erc20:0x1111111111111111111111111111111111111111': '1', + 'eip155:1/erc20:0x2222222222222222222222222222222222222222': '1', + }, + }); + expect(mockDispatch).toHaveBeenNthCalledWith(3, { + type: 'bridge/setBatchSellDestToken', + payload: BridgeTokenMetadata[stablecoinAssetId], + }); + expect(mockDispatch).toHaveBeenNthCalledWith(4, { + type: 'bridge/setBatchSellTokenSlippages', + payload: { + 'eip155:1/erc20:0x1111111111111111111111111111111111111111': + DEFAULT_BATCH_SELL_SLIPPAGE, + 'eip155:1/erc20:0x2222222222222222222222222222222222222222': + DEFAULT_BATCH_SELL_SLIPPAGE, + }, + }); expect(mockNavigate).toHaveBeenCalledWith(Routes.BRIDGE.BATCH_SELL_REVIEW); }); }); diff --git a/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.tsx b/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.tsx index 92a9dbab495..aaedc61360e 100644 --- a/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.tsx +++ b/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.tsx @@ -4,6 +4,7 @@ import { ListRenderItemInfo, Pressable } from 'react-native'; import { FlatList, ScrollView } from 'react-native-gesture-handler'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useDispatch, useSelector } from 'react-redux'; +import BigNumber from 'bignumber.js'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { AvatarBaseShape, @@ -25,8 +26,11 @@ import { TextColor, TextVariant, } from '@metamask/design-system-react-native'; -import { formatChainIdToCaip } from '@metamask/bridge-controller'; -import { CaipChainId } from '@metamask/utils'; +import { + formatAddressToAssetId, + formatChainIdToCaip, +} from '@metamask/bridge-controller'; +import { CaipAssetType, CaipChainId } from '@metamask/utils'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; @@ -34,7 +38,10 @@ import { resetBridgeState, selectBatchSellDestStablecoins, selectBatchSellDestStablecoinsByChain, + setBatchSellDestToken, + setBatchSellSourceTokenAmounts, setBatchSellSourceTokens, + setBatchSellTokenSlippages, } from '../../../../../core/redux/slices/bridge'; import { RootState } from '../../../../../reducers'; import { BridgeToken } from '../../types'; @@ -54,10 +61,51 @@ import { import { BatchSellTokenSelectSelectorsIDs } from './BatchSellTokenSelect.testIds'; import { BatchSellTokenRow } from './BatchSellTokenRow'; import { BatchSellEmptyState } from './BatchSellEmptyState'; +import { DEFAULT_BATCH_SELL_SLIPPAGE } from '../../components/SlippageModal/utils'; const getTokenKey = (token: BridgeToken) => `${formatChainIdToCaip(token.chainId)}:${token.address}`; +function getBatchSellSourceTokenAmount(token: BridgeToken, percent: number) { + if (percent <= 0) return '0'; + if (!token.balance) return undefined; + + const sourceAmount = new BigNumber(token.balance).times(percent).div(100); + + return sourceAmount.isFinite() ? sourceAmount.toFixed() : undefined; +} + +function getDefaultBatchSellSlippages(selectedTokens: BridgeToken[]) { + return selectedTokens.reduce>>( + (slippagesByAssetId, token) => { + const assetId = formatAddressToAssetId(token.address, token.chainId); + + if (assetId) { + slippagesByAssetId[assetId] = DEFAULT_BATCH_SELL_SLIPPAGE; + } + + return slippagesByAssetId; + }, + {}, + ); +} + +function getDefaultBatchSellSourceTokenAmounts(selectedTokens: BridgeToken[]) { + return selectedTokens.reduce>>( + (sourceAmountsByAssetId, token) => { + const assetId = formatAddressToAssetId(token.address, token.chainId); + const amount = getBatchSellSourceTokenAmount(token, 100); + + if (assetId) { + sourceAmountsByAssetId[assetId] = amount; + } + + return sourceAmountsByAssetId; + }, + {}, + ); +} + export function BatchSellTokenSelect() { const navigation = useNavigation(); const dispatch = useDispatch(); @@ -85,13 +133,9 @@ export function BatchSellTokenSelect() { >(() => sortedEligibleChains[0]?.chainId); const [selectedTokens, setSelectedTokens] = useState([]); - // Reset bridge state when component unmounts. - useEffect( - () => () => { - dispatch(resetBridgeState()); - }, - [dispatch], - ); + useEffect(() => { + dispatch(resetBridgeState()); + }, [dispatch]); useEffect(() => { // Default to the highest-value chain once balances load, but preserve a @@ -222,6 +266,22 @@ export function BatchSellTokenSelect() { } dispatch(setBatchSellSourceTokens(selectedTokens)); + dispatch( + setBatchSellSourceTokenAmounts( + getDefaultBatchSellSourceTokenAmounts(selectedTokens), + ), + ); + dispatch( + setBatchSellDestToken( + getBatchSellDestinationToken( + selectedTokens[0].chainId, + destinationStablecoins, + ), + ), + ); + dispatch( + setBatchSellTokenSlippages(getDefaultBatchSellSlippages(selectedTokens)), + ); navigation.navigate(Routes.BRIDGE.BATCH_SELL_REVIEW); }, [destinationStablecoins, dispatch, navigation, selectedTokens]); diff --git a/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.utils.ts b/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.utils.ts index 1f886891245..6de56257880 100644 --- a/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.utils.ts +++ b/app/components/UI/Bridge/Views/BatchSellTokenSelect/BatchSellTokenSelect.utils.ts @@ -1,11 +1,11 @@ import { + formatAddressToAssetId, formatChainIdToCaip, formatChainIdToHex, } from '@metamask/bridge-controller'; import { CaipAssetType, CaipChainId } from '@metamask/utils'; import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../constants/bridge'; import { BridgeToken } from '../../types'; -import { getBridgeTokenAssetId } from '../../utils/tokenUtils'; export const MAX_BATCH_SELL_SOURCE_TOKENS = 5; // TODO: The fetching of 7702 chains needs to be dynamic so there's no need for @@ -55,7 +55,9 @@ export function removeStablecoinsFromSourceTokens({ chainId as CaipChainId, new Set( (stablecoins ?? []) - .map((stablecoin) => getBridgeTokenAssetId(stablecoin)) + .map((stablecoin) => + formatAddressToAssetId(stablecoin.address, stablecoin.chainId), + ) .filter((assetId): assetId is CaipAssetType => Boolean(assetId)), ), ]), @@ -69,7 +71,7 @@ export function removeStablecoinsFromSourceTokens({ return true; } - const assetId = getBridgeTokenAssetId(token); + const assetId = formatAddressToAssetId(token.address, token.chainId); if (!assetId) { return true; diff --git a/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts b/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts index 20099b12c4c..7824ba23aee 100644 --- a/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts +++ b/app/components/UI/Bridge/_mocks_/bridgeReducerState.ts @@ -39,6 +39,7 @@ export const mockBridgeReducerState: BridgeState = { visiblePillChainIds: undefined, selectedQuoteRequestId: undefined, batchSellSourceTokens: [], + batchSellSourceTokenAmounts: {}, batchSellDestToken: undefined, batchSellSlippages: {}, }; diff --git a/app/components/UI/Bridge/_mocks_/initialState.ts b/app/components/UI/Bridge/_mocks_/initialState.ts index 3cc24e1fd76..d93addab3e5 100644 --- a/app/components/UI/Bridge/_mocks_/initialState.ts +++ b/app/components/UI/Bridge/_mocks_/initialState.ts @@ -770,6 +770,7 @@ export const initialState = { slippage: '0.5', batchSellSlippages: {}, batchSellSourceTokens: [], + batchSellSourceTokenAmounts: {}, batchSellDestToken: undefined, isSubmittingTx: false, bridgeViewMode: undefined, diff --git a/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/BatchSellDestinationTokenSelectorModal.test.tsx b/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/BatchSellDestinationTokenSelectorModal.test.tsx index 31029c57b37..17309381d10 100644 --- a/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/BatchSellDestinationTokenSelectorModal.test.tsx +++ b/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/BatchSellDestinationTokenSelectorModal.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { fireEvent, render } from '@testing-library/react-native'; import { CaipAssetType, Hex } from '@metamask/utils'; +import { formatAddressToAssetId } from '@metamask/bridge-controller'; import { BridgeTokenMetadata } from '../../constants/tokens'; import { BridgeToken } from '../../types'; @@ -14,6 +15,10 @@ const usdcAssetId = 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType; const usdtAssetId = 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7' as CaipAssetType; +const usdcBalanceAssetId = formatAddressToAssetId( + BridgeTokenMetadata[usdcAssetId].address, + BridgeTokenMetadata[usdcAssetId].chainId, +) as CaipAssetType; const mockSourceToken: BridgeToken = { address: '0x1111111111111111111111111111111111111111', chainId: '0x1' as Hex, @@ -24,7 +29,7 @@ let mockSelectedDestinationToken: BridgeToken | undefined; let mockDestinationStablecoins: BridgeToken[]; let mockBalancesByAssetId: Record< string, - { balance: string; balanceFiat?: string } + { balance: string; balanceFiat?: string; tokenFiatAmount?: number } >; jest.mock('@react-navigation/native', () => ({ @@ -168,9 +173,10 @@ describe('BatchSellDestinationTokenSelectorModal', () => { it('renders the stablecoin fiat value from wallet balances', () => { mockBalancesByAssetId = { - [usdcAssetId]: { + [usdcBalanceAssetId]: { balance: '123', balanceFiat: '$123.00', + tokenFiatAmount: 123, }, }; @@ -183,9 +189,36 @@ describe('BatchSellDestinationTokenSelectorModal', () => { expect(queryByText('123 USDC')).not.toBeOnTheScreen(); }); - it('does not render a balance fallback when fiat value is missing', () => { - const { queryByText } = render(); + it('falls back to the stablecoin balance when fiat value is missing', () => { + mockBalancesByAssetId = { + [usdcBalanceAssetId]: { + balance: '123', + }, + }; + + const { getByText, queryByText } = render( + , + ); + + expect(getByText('123 USDC')).toBeOnTheScreen(); + expect(queryByText('0')).not.toBeOnTheScreen(); + }); + + it('falls back to the stablecoin balance when fiat value is zero for a nonzero balance', () => { + mockBalancesByAssetId = { + [usdcBalanceAssetId]: { + balance: '123', + balanceFiat: '$0.00', + tokenFiatAmount: 0, + }, + }; + + const { getByText, queryByText } = render( + , + ); + expect(getByText('123 USDC')).toBeOnTheScreen(); + expect(queryByText('$0.00')).not.toBeOnTheScreen(); expect(queryByText('0')).not.toBeOnTheScreen(); }); diff --git a/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/index.tsx b/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/index.tsx index 9d35612d24d..1c06e9390e9 100644 --- a/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/index.tsx +++ b/app/components/UI/Bridge/components/BatchSellDestinationTokenSelectorModal/index.tsx @@ -2,6 +2,8 @@ import { useNavigation } from '@react-navigation/native'; import React, { useCallback, useMemo, useRef } from 'react'; import { Pressable } from 'react-native'; import { useDispatch, useSelector } from 'react-redux'; +import BigNumber from 'bignumber.js'; +import { formatAddressToAssetId } from '@metamask/bridge-controller'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { AvatarToken, @@ -20,8 +22,10 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; -import { useBalancesByAssetId } from '../../hooks/useBalancesByAssetId'; -import { getBridgeTokenAssetId } from '../../utils/tokenUtils'; +import { + type BalanceData, + useBalancesByAssetId, +} from '../../hooks/useBalancesByAssetId'; import { selectBatchSellDestStablecoins, selectBatchSellDestToken, @@ -30,6 +34,7 @@ import { } from '../../../../../core/redux/slices/bridge'; import { RootState } from '../../../../../reducers'; import { BridgeToken } from '../../types'; +import { formatTokenBalance } from '../../utils'; import { BatchSellDestinationTokenSelectorModalSelectorsIDs } from './BatchSellDestinationTokenSelectorModal.testIds'; const getTokenKey = (token: BridgeToken) => `${token.chainId}:${token.address}`; @@ -42,6 +47,26 @@ const isSameToken = (tokenA?: BridgeToken, tokenB?: BridgeToken) => tokenA.address.toLowerCase() === tokenB.address.toLowerCase(), ); +function getStablecoinBalanceDisplayValue( + balanceData: BalanceData | undefined, + symbol: string, +) { + const balance = balanceData?.balance; + + if (!balance) return undefined; + + const hasNonZeroTokenBalance = new BigNumber(balance).gt(0); + const hasMissingFiatValue = + !balanceData.balanceFiat || + (balanceData.tokenFiatAmount === 0 && hasNonZeroTokenBalance); + + if (hasMissingFiatValue) { + return `${formatTokenBalance(balance)} ${symbol}`; + } + + return balanceData.balanceFiat; +} + export function BatchSellDestinationTokenSelectorModal() { const navigation = useNavigation(); const dispatch = useDispatch(); @@ -94,10 +119,11 @@ export function BatchSellDestinationTokenSelectorModal() { {destinationTokens.map((token) => { const tokenKey = getTokenKey(token); const isSelected = isSameToken(token, selectedDestinationToken); - const assetId = getBridgeTokenAssetId(token); - const tokenFiatValue = assetId - ? balancesByAssetId[assetId]?.balanceFiat - : undefined; + const assetId = formatAddressToAssetId(token.address, token.chainId); + const tokenBalanceValue = getStablecoinBalanceDisplayValue( + assetId ? balancesByAssetId[assetId] : undefined, + token.symbol, + ); return ( - {tokenFiatValue ? ( + {tokenBalanceValue ? ( - {tokenFiatValue} + {tokenBalanceValue} ) : null} diff --git a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx index 4d6f8094605..9b0d0c575db 100644 --- a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx +++ b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.test.tsx @@ -1,64 +1,168 @@ import React from 'react'; +import { StyleSheet } from 'react-native'; import { fireEvent, render } from '@testing-library/react-native'; +import { lightTheme } from '@metamask/design-tokens'; import Routes from '../../../../../constants/navigation/Routes'; import { BatchSellQuoteDetailsModalSelectorsIDs } from '../BatchSellQuoteDetailsModal/BatchSellQuoteDetailsModal.testIds'; import { BatchSellFinalReviewModal } from './index'; import { BatchSellFinalReviewModalSelectorsIDs } from './BatchSellFinalReviewModal.testIds'; -import { BatchSellFinalReviewModalParams } from './BatchSellFinalReviewModal.types'; const mockGoBack = jest.fn(); const mockReplace = jest.fn(); -let mockRouteParams: BatchSellFinalReviewModalParams; +const mockUpdateBatchSellQuoteParams = jest.fn(); +const mockGetNewQuote = jest.fn(); +const mockUseBatchSellHasSufficientGas = jest.fn((_params: unknown) => true); +const errorTextColor = lightTheme.colors.error.default; +const ethAssetId = 'eip155:1/erc20:0x1111111111111111111111111111111111111111'; +const uniAssetId = 'eip155:1/erc20:0x2222222222222222222222222222222222222222'; +const linkAssetId = 'eip155:1/erc20:0x3333333333333333333333333333333333333333'; +const defaultSelectedTokens = [ + { + address: '0x1111111111111111111111111111111111111111', + chainId: '0x1', + decimals: 18, + symbol: 'ETH', + image: 'eth-image-url', + }, + { + address: '0x2222222222222222222222222222222222222222', + chainId: '0x1', + decimals: 18, + symbol: 'UNI', + }, +]; +const linkToken = { + address: '0x3333333333333333333333333333333333333333', + chainId: '0x1', + decimals: 18, + symbol: 'LINK', +}; -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ - goBack: mockGoBack, - replace: mockReplace, - }), -})); +interface MockQuoteTokenData { + key: string; + tokenSymbol: string; + slippage: string; + receivedAmount: string; + receivedAmountFiat: string; + isLoading: boolean; + isHighPriceImpact: boolean; + isQuoteUnavailable: boolean; +} -jest.mock('../../../../../util/navigation/navUtils', () => ({ - useParams: () => mockRouteParams, -})); +interface MockBatchSellQuoteData { + tokenData: Record; + totalReceived: { formatted: string }; + minimumReceived: { formatted: string }; + isLoading: boolean; + isSummaryLoading: boolean; + isGasless: boolean; + hasAnyQuote: boolean; + hasPendingQuoteRows: boolean; + needsNewQuote: boolean; + quotePercentFee?: string; + networkFee: { + amount?: string; + valueInCurrency?: string | null; + asset?: { + address: string; + assetId: string; + chainId: number; + decimals: number; + name: string; + symbol: string; + }; + formatted: string; + formattedFiat: string; + }; + networkFeeIsLoading: boolean; +} -const defaultParams: BatchSellFinalReviewModalParams = { - sourceTokens: [ - { - key: 'eth', - tokenSymbol: 'ETH', - image: 'eth-image-url', - }, - { - key: 'uni', - tokenSymbol: 'UNI', - }, - ], - tokenData: [ - { - key: 'eth', +const defaultQuoteData: MockBatchSellQuoteData = { + tokenData: { + [ethAssetId]: { + key: ethAssetId, tokenSymbol: 'ETH', slippage: '0.5%', receivedAmount: '3,456.78 USDC', + receivedAmountFiat: '$3,456.78', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: false, }, - { - key: 'uni', + [uniAssetId]: { + key: uniAssetId, tokenSymbol: 'UNI', slippage: '0.5%', receivedAmount: '500 USDC', + receivedAmountFiat: '$500.00', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: false, }, - ], - totalReceived: '+7,638.23 USDC', - minimumReceived: '+7,485.47 USDC', + }, + totalReceived: { formatted: '7,638.23 USDC' }, + minimumReceived: { formatted: '7,485.47 USDC' }, isLoading: false, - networkFee: '1.20 USDC', - networkFeeFiat: '$1.20', - metamaskFeePercent: '0.875', + isSummaryLoading: false, + isGasless: false, + hasAnyQuote: true, + hasPendingQuoteRows: false, + needsNewQuote: false, + quotePercentFee: '1.25', + networkFee: { + amount: '1.2', + valueInCurrency: '1.2', + asset: { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', + chainId: 1, + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + formatted: '1.20 USDC', + formattedFiat: '$1.20', + }, + networkFeeIsLoading: false, }; +let mockSelectedTokens = defaultSelectedTokens; +let mockBatchSellQuoteData = defaultQuoteData; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + goBack: mockGoBack, + replace: mockReplace, + }), +})); + +jest.mock('react-redux', () => ({ + useSelector: (selector: (state: unknown) => unknown) => selector({}), +})); -function renderModal(overrides: Partial = {}) { - mockRouteParams = { - ...defaultParams, +jest.mock('../../../../../core/redux/slices/bridge', () => ({ + selectBatchSellSourceTokens: jest.fn(() => mockSelectedTokens), +})); + +jest.mock('../../hooks/useBatchSellQuoteData', () => ({ + useBatchSellQuoteData: jest.fn(() => mockBatchSellQuoteData), +})); + +jest.mock('../../hooks/useBatchSellQuoteRequest', () => ({ + useBatchSellQuoteRequest: jest.fn(() => ({ + updateBatchSellQuoteParams: mockUpdateBatchSellQuoteParams, + getNewQuote: mockGetNewQuote, + })), +})); + +jest.mock('../../hooks/useBatchSellHasSufficientGas', () => ({ + useBatchSellHasSufficientGas: (params: unknown) => + mockUseBatchSellHasSufficientGas(params), +})); + +function renderModal(overrides: Partial = {}) { + mockBatchSellQuoteData = { + ...defaultQuoteData, ...overrides, }; @@ -68,11 +172,18 @@ function renderModal(overrides: Partial = {}) { describe('BatchSellFinalReviewModal', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouteParams = defaultParams; + mockSelectedTokens = defaultSelectedTokens; + mockBatchSellQuoteData = defaultQuoteData; + mockUpdateBatchSellQuoteParams.mockClear(); + mockGetNewQuote.mockClear(); + mockUseBatchSellHasSufficientGas.mockReturnValue(true); }); - it('renders the final review sheet content from route params', () => { + it('renders the final review sheet content from live quote data', () => { const { getByTestId, getByText } = renderModal(); + const sellAllButton = getByTestId( + BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON, + ); expect( getByTestId(BatchSellFinalReviewModalSelectorsIDs.SHEET), @@ -83,14 +194,27 @@ describe('BatchSellFinalReviewModal', () => { expect(getByText('ETH • 0.5% slippage')).toBeOnTheScreen(); expect(getByText('3,456.78 USDC')).toBeOnTheScreen(); expect(getByText('Total received')).toBeOnTheScreen(); - expect(getByText('+7,638.23 USDC')).toBeOnTheScreen(); + expect(getByText('7,638.23 USDC')).toBeOnTheScreen(); expect(getByText('Minimum received')).toBeOnTheScreen(); - expect(getByText('+7,485.47 USDC')).toBeOnTheScreen(); + expect(getByText('7,485.47 USDC')).toBeOnTheScreen(); expect(getByText('Network fee')).toBeOnTheScreen(); expect(getByText('1.20 USDC')).toBeOnTheScreen(); expect(getByText('$1.20')).toBeOnTheScreen(); expect(getByText('Sell all')).toBeOnTheScreen(); - expect(getByText('Includes 0.875% MetaMask fee')).toBeOnTheScreen(); + expect(sellAllButton.props.accessibilityState.disabled).not.toBe(true); + expect(getByText('Includes 1.25% MetaMask fee')).toBeOnTheScreen(); + }); + + it('hides the MetaMask fee disclosure when quoteBpsFee has no fee', () => { + const { queryByTestId } = renderModal({ + quotePercentFee: undefined, + }); + + expect( + queryByTestId( + BatchSellFinalReviewModalSelectorsIDs.METAMASK_FEE_DISCLOSURE, + ), + ).toBeNull(); }); it('closes with navigation when the close button is pressed', () => { @@ -113,9 +237,9 @@ describe('BatchSellFinalReviewModal', () => { expect(queryByText('ETH • 0.5% slippage')).toBeNull(); expect(queryByText('UNI • 0.5% slippage')).toBeNull(); expect(getByText('Total received')).toBeOnTheScreen(); - expect(getByText('+7,638.23 USDC')).toBeOnTheScreen(); + expect(getByText('7,638.23 USDC')).toBeOnTheScreen(); expect(getByText('Minimum received')).toBeOnTheScreen(); - expect(getByText('+7,485.47 USDC')).toBeOnTheScreen(); + expect(getByText('7,485.47 USDC')).toBeOnTheScreen(); }); it('expands token rows after they are collapsed', () => { @@ -131,6 +255,30 @@ describe('BatchSellFinalReviewModal', () => { expect(getByText('UNI • 0.5% slippage')).toBeOnTheScreen(); }); + it('shows only quoted rows and source tokens', () => { + mockSelectedTokens = [...defaultSelectedTokens, linkToken]; + const { getByText, queryByText } = renderModal({ + tokenData: { + ...defaultQuoteData.tokenData, + [linkAssetId]: { + key: linkAssetId, + tokenSymbol: 'LINK', + slippage: '0.5%', + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: true, + }, + }, + }); + + expect(getByText('2 tokens')).toBeOnTheScreen(); + expect(getByText('ETH • 0.5% slippage')).toBeOnTheScreen(); + expect(getByText('UNI • 0.5% slippage')).toBeOnTheScreen(); + expect(queryByText('LINK • 0.5% slippage')).toBeNull(); + }); + it('switches to the minimum received info modal when the info button is pressed', () => { const { getByTestId } = renderModal(); @@ -145,7 +293,6 @@ describe('BatchSellFinalReviewModal', () => { { sourceModal: { screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, - params: defaultParams, }, }, ); @@ -165,21 +312,40 @@ describe('BatchSellFinalReviewModal', () => { { sourceModal: { screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, - params: defaultParams, }, }, ); }); - it('renders quote amount skeletons while loading', () => { - const { getByTestId, getByText, queryByText } = renderModal({ + it('keeps token rows visible as skeletons while loading and disables Sell all', () => { + const { getByTestId, getByText, queryByTestId, queryByText } = renderModal({ + tokenData: { + [ethAssetId]: { + ...defaultQuoteData.tokenData[ethAssetId], + isLoading: true, + }, + [uniAssetId]: { + ...defaultQuoteData.tokenData[uniAssetId], + isLoading: true, + }, + }, isLoading: true, + isSummaryLoading: true, + hasAnyQuote: false, + hasPendingQuoteRows: true, }); + expect(getByText('2 tokens')).toBeOnTheScreen(); expect(getByText('ETH • 0.5% slippage')).toBeOnTheScreen(); + expect(getByText('UNI • 0.5% slippage')).toBeOnTheScreen(); expect( getByTestId( - `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-eth`, + `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-${ethAssetId}`, + ), + ).toBeOnTheScreen(); + expect( + getByTestId( + `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-${uniAssetId}`, ), ).toBeOnTheScreen(); expect( @@ -193,6 +359,108 @@ describe('BatchSellFinalReviewModal', () => { ), ).toBeOnTheScreen(); expect(queryByText('3,456.78 USDC')).toBeNull(); - expect(queryByText('+7,638.23 USDC')).toBeNull(); + expect(queryByText('7,638.23 USDC')).toBeNull(); + expect( + queryByTestId( + `${BatchSellFinalReviewModalSelectorsIDs.SOURCE_TOKEN_AVATAR}-${linkAssetId}`, + ), + ).toBeNull(); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.disabled, + ).toBe(true); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.busy, + ).toBe(true); + }); + + it('renders a network fee values skeleton while the network fee is loading', () => { + const { getByTestId, getByText, queryByText } = renderModal({ + networkFeeIsLoading: true, + }); + + expect( + getByTestId( + BatchSellFinalReviewModalSelectorsIDs.NETWORK_FEE_VALUES_SKELETON, + ), + ).toBeOnTheScreen(); + expect(getByText('Network fee')).toBeOnTheScreen(); + expect(queryByText('1.20 USDC')).toBeNull(); + expect(queryByText('$1.20')).toBeNull(); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.disabled, + ).toBe(true); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.busy, + ).toBe(true); + }); + + it('blocks Sell all and highlights the network fee when gas is insufficient', () => { + mockUseBatchSellHasSufficientGas.mockReturnValue(false); + + const { getByTestId, getByText } = renderModal(); + const getTextColor = (text: string) => + StyleSheet.flatten(getByText(text).props.style).color; + + expect(getByText('Insufficient funds')).toBeOnTheScreen(); + expect(getTextColor('Network fee')).toBe(errorTextColor); + expect(getTextColor('1.20 USDC')).toBe(errorTextColor); + expect(getTextColor('$1.20')).toBe(errorTextColor); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.disabled, + ).toBe(true); + expect( + getByTestId(BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON).props + .accessibilityState.busy, + ).not.toBe(true); + }); + + it('shows Get new quote when max refresh expires and fetches fresh quotes', () => { + mockUseBatchSellHasSufficientGas.mockReturnValue(false); + + const { getByTestId, getByText } = renderModal({ + needsNewQuote: true, + networkFeeIsLoading: true, + hasPendingQuoteRows: true, + }); + const button = getByTestId( + BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON, + ); + + fireEvent.press(button); + + expect(getByText('Get new quote')).toBeOnTheScreen(); + expect(button.props.accessibilityState.disabled).not.toBe(true); + expect(button.props.accessibilityState.busy).not.toBe(true); + expect(mockGetNewQuote).toHaveBeenCalledTimes(1); + }); + + it('updates quote values from live data while mounted', () => { + const { getByText, rerender } = renderModal(); + + expect(getByText('7,638.23 USDC')).toBeOnTheScreen(); + + mockBatchSellQuoteData = { + ...defaultQuoteData, + tokenData: { + ...defaultQuoteData.tokenData, + [ethAssetId]: { + ...defaultQuoteData.tokenData[ethAssetId], + receivedAmount: '3,500 USDC', + }, + }, + totalReceived: { formatted: '7,700 USDC' }, + minimumReceived: { formatted: '7,650 USDC' }, + }; + + rerender(); + + expect(getByText('3,500 USDC')).toBeOnTheScreen(); + expect(getByText('7,700 USDC')).toBeOnTheScreen(); + expect(getByText('7,650 USDC')).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.testIds.ts b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.testIds.ts index 90ca1d4a8b7..d88fc1be03a 100644 --- a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.testIds.ts +++ b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.testIds.ts @@ -6,6 +6,8 @@ export const BatchSellFinalReviewModalSelectorsIDs = { SOURCE_TOKEN_AVATAR: 'batch-sell-final-review-source-token-avatar', NETWORK_FEE_ROW: 'batch-sell-final-review-network-fee-row', NETWORK_FEE_INFO_BUTTON: 'batch-sell-final-review-network-fee-info-button', + NETWORK_FEE_VALUES_SKELETON: + 'batch-sell-final-review-network-fee-values-skeleton', SELL_ALL_BUTTON: 'batch-sell-final-review-sell-all-button', METAMASK_FEE_DISCLOSURE: 'batch-sell-final-review-metamask-fee-disclosure', }; diff --git a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.types.ts b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.types.ts deleted file mode 100644 index 27c3781ff21..00000000000 --- a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { BatchSellQuoteDetailsModalParams } from '../BatchSellQuoteDetailsModal/BatchSellQuoteDetailsModal.types'; - -export interface BatchSellFinalReviewSourceTokenData { - key: string; - tokenSymbol: string; - image?: string; -} - -export interface BatchSellFinalReviewModalParams - extends BatchSellQuoteDetailsModalParams { - sourceTokens: BatchSellFinalReviewSourceTokenData[]; - networkFee: string; - networkFeeFiat: string; - metamaskFeePercent: string; -} diff --git a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx index 7c9a58c7a2c..23b96a14467 100644 --- a/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx +++ b/app/components/UI/Bridge/components/BatchSellFinalReviewModal/index.tsx @@ -1,8 +1,10 @@ import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Pressable } from 'react-native'; +import { useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { formatAddressToAssetId } from '@metamask/bridge-controller'; import { AvatarToken, AvatarTokenSize, @@ -28,21 +30,67 @@ import { import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; -import { useParams } from '../../../../../util/navigation/navUtils'; +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; +import { selectBatchSellSourceTokens } from '../../../../../core/redux/slices/bridge'; +import { + type BatchSellQuoteTokenData, + useBatchSellQuoteData, +} from '../../hooks/useBatchSellQuoteData'; +import { useBatchSellQuoteRequest } from '../../hooks/useBatchSellQuoteRequest'; +import { useBatchSellHasSufficientGas } from '../../hooks/useBatchSellHasSufficientGas'; +import type { BridgeToken } from '../../types'; import { BatchSellQuoteDetails } from '../BatchSellQuoteDetailsModal'; import { BatchSellFinalReviewModalSelectorsIDs } from './BatchSellFinalReviewModal.testIds'; -import { - BatchSellFinalReviewModalParams, - BatchSellFinalReviewSourceTokenData, -} from './BatchSellFinalReviewModal.types'; const MAX_VISIBLE_SOURCE_TOKEN_AVATARS = 5; const SOURCE_TOKEN_AVATAR_OVERLAP = 12; +const NETWORK_FEE_VALUES_SKELETON_WIDTH = 150; +const NETWORK_FEE_SKELETON_HEIGHT = 24; + +const getTokenKey = (token: BridgeToken) => `${token.chainId}:${token.address}`; + +interface FinalReviewQuoteData { + sourceTokens: BridgeToken[]; + tokenData: BatchSellQuoteTokenData[]; +} + +function getFinalReviewQuoteData({ + isLoading, + sourceTokens, + tokenDataByAssetId, +}: { + isLoading: boolean; + sourceTokens: BridgeToken[]; + tokenDataByAssetId: Record; +}) { + return sourceTokens.reduce( + (quoteData, sourceToken) => { + const assetId = formatAddressToAssetId( + sourceToken.address, + sourceToken.chainId, + ); + const tokenData = assetId ? tokenDataByAssetId[assetId] : undefined; + + if ( + !tokenData || + (!isLoading && (tokenData.isLoading || tokenData.isQuoteUnavailable)) + ) { + return quoteData; + } + + quoteData.sourceTokens.push(sourceToken); + quoteData.tokenData.push(tokenData); + + return quoteData; + }, + { sourceTokens: [], tokenData: [] }, + ); +} function SourceTokenAvatarStack({ sourceTokens, }: { - sourceTokens: BatchSellFinalReviewSourceTokenData[]; + sourceTokens: BridgeToken[]; }) { const tw = useTailwind(); @@ -50,23 +98,27 @@ function SourceTokenAvatarStack({ {sourceTokens .slice(0, MAX_VISIBLE_SOURCE_TOKEN_AVATARS) - .map((sourceToken, index) => ( - - - - ))} + .map((sourceToken, index) => { + const sourceTokenKey = getTokenKey(sourceToken); + + return ( + + + + ); + })} ); } @@ -76,7 +128,7 @@ function YouSellRow({ isTokenDetailsExpanded, onToggleTokenDetails, }: { - sourceTokens: BatchSellFinalReviewSourceTokenData[]; + sourceTokens: BridgeToken[]; isTokenDetailsExpanded: boolean; onToggleTokenDetails: () => void; }) { @@ -132,13 +184,25 @@ function YouSellRow({ function NetworkFeeRow({ networkFee, - networkFeeFiat, + hasInsufficientGas, + isLoading, onInfoPress, }: { - networkFee: string; - networkFeeFiat: string; + networkFee: { + formatted: string; + formattedFiat: string; + }; + hasInsufficientGas: boolean; + isLoading: boolean; onInfoPress: () => void; }) { + const textColor = hasInsufficientGas + ? TextColor.ErrorDefault + : TextColor.TextAlternative; + const fiatTextColor = hasInsufficientGas + ? TextColor.ErrorDefault + : TextColor.TextDefault; + return ( {strings('bridge.network_fee')} @@ -179,22 +243,35 @@ function NetworkFeeRow({ gap={2} twClassName="min-w-0 flex-1" > - - {networkFee} - - - {networkFeeFiat} - + {isLoading ? ( + + ) : ( + <> + + {networkFee.formatted} + + + {networkFee.formattedFiat} + + + )} ); @@ -203,8 +280,56 @@ function NetworkFeeRow({ export function BatchSellFinalReviewModal() { const navigation = useNavigation>>(); - const params = useParams(); + const selectedTokens = useSelector(selectBatchSellSourceTokens); + const batchSellQuoteData = useBatchSellQuoteData({ + shouldUpdateBatchSellTrades: false, + }); + const { getNewQuote } = useBatchSellQuoteRequest(); + const hasSufficientGas = useBatchSellHasSufficientGas({ + isGasless: batchSellQuoteData.isGasless, + networkFee: batchSellQuoteData.networkFee, + }); const [isTokenDetailsExpanded, setIsTokenDetailsExpanded] = useState(true); + const finalReviewQuoteData = useMemo( + () => + getFinalReviewQuoteData({ + isLoading: batchSellQuoteData.isLoading, + sourceTokens: selectedTokens, + tokenDataByAssetId: batchSellQuoteData.tokenData, + }), + [ + batchSellQuoteData.isLoading, + batchSellQuoteData.tokenData, + selectedTokens, + ], + ); + const hasInsufficientGas = hasSufficientGas === false; + const isSellAllDisabled = + batchSellQuoteData.isLoading || + batchSellQuoteData.networkFeeIsLoading || + !batchSellQuoteData.hasAnyQuote || + batchSellQuoteData.hasPendingQuoteRows || + hasInsufficientGas; + const isButtonDisabled = batchSellQuoteData.needsNewQuote + ? false + : isSellAllDisabled; + const isSellAllLoading = + !batchSellQuoteData.needsNewQuote && + isSellAllDisabled && + (batchSellQuoteData.isLoading || + batchSellQuoteData.networkFeeIsLoading || + batchSellQuoteData.hasPendingQuoteRows); + const actionButtonLabel = (() => { + if (batchSellQuoteData.needsNewQuote) { + return strings('quote_expired_modal.get_new_quote'); + } + + if (hasInsufficientGas) { + return strings('bridge.insufficient_funds'); + } + + return strings('bridge.batch_sell_sell_all'); + })(); const handleToggleTokenDetails = () => { setIsTokenDetailsExpanded((isExpanded) => !isExpanded); @@ -216,7 +341,6 @@ export function BatchSellFinalReviewModal() { { sourceModal: { screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, - params, }, }, ); @@ -226,11 +350,14 @@ export function BatchSellFinalReviewModal() { navigation.replace(Routes.BRIDGE.MODALS.BATCH_SELL_NETWORK_FEE_INFO_MODAL, { sourceModal: { screen: Routes.BRIDGE.MODALS.BATCH_SELL_FINAL_REVIEW_MODAL, - params, }, }); }; + const handleSellAll = useCallback(() => { + // TODO: submit the executable Batch Sell trades. + }, []); + return ( @@ -268,21 +396,30 @@ export function BatchSellFinalReviewModal() { variant={ButtonVariant.Primary} size={ButtonSize.Lg} isFullWidth + isDisabled={isButtonDisabled} + isLoading={isSellAllLoading} + onPress={ + batchSellQuoteData.needsNewQuote ? getNewQuote : handleSellAll + } testID={BatchSellFinalReviewModalSelectorsIDs.SELL_ALL_BUTTON} > - {strings('bridge.batch_sell_sell_all')} + {actionButtonLabel} - - {strings('bridge.batch_sell_includes_metamask_fee', { - fee: params.metamaskFeePercent, - })} - + {batchSellQuoteData.quotePercentFee ? ( + + {strings('bridge.batch_sell_includes_metamask_fee', { + fee: batchSellQuoteData.quotePercentFee, + })} + + ) : null} ); diff --git a/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/BatchSellNetworkFeeInfoModal.test.tsx b/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/BatchSellNetworkFeeInfoModal.test.tsx index 35d34433778..c33dea993db 100644 --- a/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/BatchSellNetworkFeeInfoModal.test.tsx +++ b/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/BatchSellNetworkFeeInfoModal.test.tsx @@ -46,7 +46,7 @@ describe('BatchSellNetworkFeeInfoModal', () => { getByTestId(BatchSellNetworkFeeInfoModalSelectorsIDs.DESCRIPTION), ).toBeOnTheScreen(); expect( - getByText(strings('bridge.network_fee_info_content')), + getByText(strings('bridge.batch_sell_network_fee_info_content')), ).toBeOnTheScreen(); }); diff --git a/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/index.tsx b/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/index.tsx index b17c0d954d1..931b0421225 100644 --- a/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/index.tsx +++ b/app/components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/index.tsx @@ -48,7 +48,7 @@ export function BatchSellNetworkFeeInfoModal() { color={TextColor.TextAlternative} testID={BatchSellNetworkFeeInfoModalSelectorsIDs.DESCRIPTION} > - {strings('bridge.network_fee_info_content')} + {strings('bridge.batch_sell_network_fee_info_content')} diff --git a/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.test.tsx b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.test.tsx new file mode 100644 index 00000000000..50bda1dd8df --- /dev/null +++ b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.test.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; + +import { strings } from '../../../../../../locales/i18n'; +import { BatchSellPriceImpactInfoModal } from './index'; +import { BatchSellPriceImpactInfoModalSelectorsIDs } from './BatchSellPriceImpactInfoModal.testIds'; +import { BatchSellPriceImpactInfoModalParams } from './BatchSellPriceImpactInfoModal.types'; + +const mockGoBack = jest.fn(); +let mockRouteParams: BatchSellPriceImpactInfoModalParams; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + goBack: mockGoBack, + }), +})); + +jest.mock('../../../../../util/navigation/navUtils', () => ({ + useParams: () => mockRouteParams, +})); + +function renderModal( + params: BatchSellPriceImpactInfoModalParams = { priceImpact: '0.06' }, +) { + mockRouteParams = params; + + return render(); +} + +describe('BatchSellPriceImpactInfoModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRouteParams = { priceImpact: '0.06' }; + }); + + it('renders the high price impact information', () => { + const { getByTestId, getByText } = renderModal(); + + expect( + getByTestId(BatchSellPriceImpactInfoModalSelectorsIDs.SHEET), + ).toBeOnTheScreen(); + expect( + getByText(strings('bridge.batch_sell_high_price_impact')), + ).toBeOnTheScreen(); + expect( + getByTestId(BatchSellPriceImpactInfoModalSelectorsIDs.DESCRIPTION), + ).toBeOnTheScreen(); + expect( + getByText( + strings('bridge.batch_sell_high_price_impact_description', { + priceImpact: '6.00%', + }), + ), + ).toBeOnTheScreen(); + }); + + it('closes with navigation when the close button is pressed', () => { + const { getByTestId } = renderModal(); + + fireEvent.press( + getByTestId(BatchSellPriceImpactInfoModalSelectorsIDs.CLOSE_BUTTON), + ); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.testIds.ts b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.testIds.ts new file mode 100644 index 00000000000..192a22fb0b2 --- /dev/null +++ b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.testIds.ts @@ -0,0 +1,5 @@ +export const BatchSellPriceImpactInfoModalSelectorsIDs = { + SHEET: 'batch-sell-price-impact-info-modal-sheet', + CLOSE_BUTTON: 'batch-sell-price-impact-info-modal-close-button', + DESCRIPTION: 'batch-sell-price-impact-info-modal-description', +}; diff --git a/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.types.ts b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.types.ts new file mode 100644 index 00000000000..850dbc08dff --- /dev/null +++ b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/BatchSellPriceImpactInfoModal.types.ts @@ -0,0 +1,3 @@ +export interface BatchSellPriceImpactInfoModalParams { + priceImpact: string; +} diff --git a/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/index.tsx b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/index.tsx new file mode 100644 index 00000000000..d5a7d016a20 --- /dev/null +++ b/app/components/UI/Bridge/components/BatchSellPriceImpactInfoModal/index.tsx @@ -0,0 +1,58 @@ +import { useNavigation } from '@react-navigation/native'; +import React from 'react'; +import { + BottomSheet, + BottomSheetHeader, + Box, + ButtonIconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; + +import { strings } from '../../../../../../locales/i18n'; +import { useParams } from '../../../../../util/navigation/navUtils'; +import { BatchSellPriceImpactInfoModalSelectorsIDs } from './BatchSellPriceImpactInfoModal.testIds'; +import { BatchSellPriceImpactInfoModalParams } from './BatchSellPriceImpactInfoModal.types'; + +function formatPriceImpact(priceImpact: string) { + const parsedPriceImpact = Number(priceImpact); + + if (!Number.isFinite(parsedPriceImpact)) return '0%'; + + return `${(parsedPriceImpact * 100).toFixed(2)}%`; +} + +export function BatchSellPriceImpactInfoModal() { + const navigation = useNavigation(); + const { priceImpact } = useParams(); + const formattedPriceImpact = formatPriceImpact(priceImpact); + + return ( + + + {strings('bridge.batch_sell_high_price_impact')} + + + + {strings('bridge.batch_sell_high_price_impact_description', { + priceImpact: formattedPriceImpact, + })} + + + + ); +} diff --git a/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetails.tsx b/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetails.tsx index d43a681b3c7..2f02a4c72d9 100644 --- a/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetails.tsx +++ b/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetails.tsx @@ -28,12 +28,12 @@ const VALUE_SKELETON_HEIGHT = 24; function QuoteDetailsRow({ tokenData, - isLoading, }: { tokenData: BatchSellQuoteDetailsTokenData; - isLoading?: boolean; }) { const rowKey = tokenData.key ?? tokenData.tokenSymbol; + const isRowLoading = tokenData.isLoading; + const isRowQuoteUnavailable = tokenData.isQuoteUnavailable && !isRowLoading; return ( - {isLoading ? ( + {isRowLoading ? ( + ) : isRowQuoteUnavailable ? ( + + {strings('bridge.batch_sell_no_quote_available')} + ) : ( ))} @@ -190,7 +198,7 @@ export function BatchSellQuoteDetails({ ; + totalReceived: { formatted: string }; + minimumReceived: { formatted: string }; + isSummaryLoading: boolean; +} + +const defaultQuoteData: MockBatchSellQuoteData = { + tokenData: { + [ethAssetId]: { + key: 'eth', + tokenSymbol: 'ETH', + slippage: '0.5%', + receivedAmount: '3,456.78 USDC', + receivedAmountFiat: '$3,456.78', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: false, + }, + [uniAssetId]: { + key: 'uni', + tokenSymbol: 'UNI', + slippage: '0.5%', + receivedAmount: '500 USDC', + receivedAmountFiat: '$500.00', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: false, + }, + }, + totalReceived: { formatted: '7,638.23 USDC' }, + minimumReceived: { formatted: '7,485.47 USDC' }, + isSummaryLoading: false, +}; +let mockSelectedTokens = defaultSourceTokens; +let mockBatchSellQuoteData = defaultQuoteData; jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ @@ -20,11 +87,28 @@ jest.mock('@react-navigation/native', () => ({ }), })); -jest.mock('../../../../../util/navigation/navUtils', () => ({ - useParams: () => mockRouteParams, +jest.mock('react-redux', () => ({ + useSelector: (selector: (state: unknown) => unknown) => selector({}), +})); + +jest.mock('../../../../../core/redux/slices/bridge', () => ({ + selectBatchSellSourceTokens: jest.fn(() => mockSelectedTokens), })); -const defaultParams: BatchSellQuoteDetailsModalParams = { +jest.mock('../../hooks/useBatchSellQuoteData', () => ({ + getBatchSellOrderedQuoteTokenData: jest.fn( + ( + sourceTokens: typeof defaultSourceTokens, + tokenData: Record, + ) => + sourceTokens + .map((token) => tokenData[`eip155:1/erc20:${token.address}`]) + .filter(Boolean), + ), + useBatchSellQuoteData: jest.fn(() => mockBatchSellQuoteData), +})); + +const defaultDetailsProps: BatchSellQuoteDetailsProps = { tokenData: [ { key: 'eth', @@ -39,15 +123,13 @@ const defaultParams: BatchSellQuoteDetailsModalParams = { receivedAmount: '500 USDC', }, ], - totalReceived: '7,638.23 USDC', - minimumReceived: '7,485.47 USDC', + totalReceived: { formatted: '7,638.23 USDC' }, + minimumReceived: { formatted: '7,485.47 USDC' }, }; -function renderModal( - overrides: Partial = {}, -) { - mockRouteParams = { - ...defaultParams, +function renderModal(overrides: Partial = {}) { + mockBatchSellQuoteData = { + ...defaultQuoteData, ...overrides, }; @@ -57,10 +139,11 @@ function renderModal( describe('BatchSellQuoteDetailsModal', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouteParams = defaultParams; + mockSelectedTokens = defaultSourceTokens; + mockBatchSellQuoteData = defaultQuoteData; }); - it('renders the sheet header and quote rows from route params', () => { + it('renders the sheet header and quote rows from live quote data', () => { const { getAllByText, getByTestId, getByText } = renderModal(); expect( @@ -103,23 +186,23 @@ describe('BatchSellQuoteDetailsModal', () => { expect(getByText('7,485.47 USDC')).toBeOnTheScreen(); }); - it('renders skeletons for quote amounts while loading', () => { - const { getByTestId, getByText, queryByText } = renderModal({ - isLoading: true, + it('renders summary skeletons while loading', () => { + const { getByTestId, getByText, queryByTestId, queryByText } = renderModal({ + isSummaryLoading: true, }); expect(getByText('ETH • 0.5% slippage')).toBeOnTheScreen(); expect(getByText('UNI • 0.5% slippage')).toBeOnTheScreen(); expect( - getByTestId( + queryByTestId( `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-eth`, ), - ).toBeOnTheScreen(); + ).toBeNull(); expect( - getByTestId( + queryByTestId( `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-uni`, ), - ).toBeOnTheScreen(); + ).toBeNull(); expect( getByTestId( BatchSellQuoteDetailsModalSelectorsIDs.TOTAL_RECEIVED_SKELETON, @@ -135,13 +218,90 @@ describe('BatchSellQuoteDetailsModal', () => { BatchSellQuoteDetailsModalSelectorsIDs.MINIMUM_RECEIVED_INFO_BUTTON, ), ).toBeOnTheScreen(); - expect(queryByText('3,456.78 USDC')).toBeNull(); + expect(queryByText('3,456.78 USDC')).toBeOnTheScreen(); expect(queryByText('7,638.23 USDC')).toBeNull(); }); + it('renders row-level loading and unavailable states', () => { + mockSelectedTokens = [...defaultSourceTokens, linkSourceToken]; + const { getAllByText, getByTestId, getByText, queryByTestId } = renderModal( + { + tokenData: { + ...defaultQuoteData.tokenData, + [uniAssetId]: { + ...defaultQuoteData.tokenData[uniAssetId], + isLoading: true, + }, + [linkAssetId]: { + key: 'link', + tokenSymbol: 'LINK', + slippage: '0.5%', + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: true, + }, + }, + totalReceived: { formatted: '3,456.78 USDC' }, + minimumReceived: { formatted: '3,456.78 USDC' }, + isSummaryLoading: false, + }, + ); + + expect(getAllByText('3,456.78 USDC').length).toBeGreaterThan(0); + expect( + queryByTestId( + `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-eth`, + ), + ).toBeNull(); + expect( + getByTestId( + `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-uni`, + ), + ).toBeOnTheScreen(); + expect(getByText('UNI • 0.5% slippage')).toBeOnTheScreen(); + expect(getByText('No quote available')).toBeOnTheScreen(); + }); + + it('updates quote rows from live quote data while mounted', () => { + const { getByTestId, getByText, queryByTestId, rerender } = renderModal({ + tokenData: { + [ethAssetId]: { + ...defaultQuoteData.tokenData[ethAssetId], + isLoading: true, + }, + [uniAssetId]: { + ...defaultQuoteData.tokenData[uniAssetId], + isLoading: true, + }, + }, + totalReceived: { formatted: '-- USDC' }, + minimumReceived: { formatted: '-- USDC' }, + isSummaryLoading: true, + }); + + expect( + getByTestId( + `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-eth`, + ), + ).toBeOnTheScreen(); + + mockBatchSellQuoteData = defaultQuoteData; + + rerender(); + + expect(getByText('3,456.78 USDC')).toBeOnTheScreen(); + expect( + queryByTestId( + `${BatchSellQuoteDetailsModalSelectorsIDs.QUOTE_ROW_RECEIVED_AMOUNT_SKELETON}-eth`, + ), + ).toBeNull(); + }); + it('hides quote rows when token details are collapsed', () => { const props: BatchSellQuoteDetailsProps = { - ...defaultParams, + ...defaultDetailsProps, isTokenDetailsExpanded: false, }; const { getByText, queryByText } = render( @@ -159,7 +319,7 @@ describe('BatchSellQuoteDetailsModal', () => { it('calls onMinimumReceivedInfoPress when the info button is pressed', () => { const onMinimumReceivedInfoPress = jest.fn(); const props: BatchSellQuoteDetailsProps = { - ...defaultParams, + ...defaultDetailsProps, onMinimumReceivedInfoPress, }; const { getByTestId } = render(); @@ -187,7 +347,6 @@ describe('BatchSellQuoteDetailsModal', () => { { sourceModal: { screen: Routes.BRIDGE.MODALS.BATCH_SELL_QUOTE_DETAILS_MODAL, - params: defaultParams, }, }, ); diff --git a/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetailsModal.types.ts b/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetailsModal.types.ts index 168bebb37d5..a3a28654231 100644 --- a/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetailsModal.types.ts +++ b/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetailsModal.types.ts @@ -2,19 +2,20 @@ export interface BatchSellQuoteDetailsTokenData { tokenSymbol: string; slippage: string; receivedAmount: string; + isLoading?: boolean; + isQuoteUnavailable?: boolean; key?: string; } +export interface BatchSellQuoteDetailsAmountData { + formatted: string; +} + export interface BatchSellQuoteDetailsProps { tokenData: BatchSellQuoteDetailsTokenData[]; - totalReceived: string; - minimumReceived: string; + totalReceived: BatchSellQuoteDetailsAmountData; + minimumReceived: BatchSellQuoteDetailsAmountData; isLoading?: boolean; isTokenDetailsExpanded?: boolean; onMinimumReceivedInfoPress?: () => void; } - -export type BatchSellQuoteDetailsModalParams = Omit< - BatchSellQuoteDetailsProps, - 'onMinimumReceivedInfoPress' ->; diff --git a/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/index.tsx b/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/index.tsx index 33d70236d1f..d95e222f0e4 100644 --- a/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/index.tsx +++ b/app/components/UI/Bridge/components/BatchSellQuoteDetailsModal/index.tsx @@ -1,6 +1,7 @@ import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; -import React from 'react'; +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; import { BottomSheet, BottomSheetHeader, @@ -8,25 +9,36 @@ import { } from '@metamask/design-system-react-native'; import Routes from '../../../../../constants/navigation/Routes'; -import { useParams } from '../../../../../util/navigation/navUtils'; +import { selectBatchSellSourceTokens } from '../../../../../core/redux/slices/bridge'; +import { + getBatchSellOrderedQuoteTokenData, + useBatchSellQuoteData, +} from '../../hooks/useBatchSellQuoteData'; import { BatchSellQuoteDetails } from './BatchSellQuoteDetails'; import { BatchSellQuoteDetailsModalSelectorsIDs } from './BatchSellQuoteDetailsModal.testIds'; -import { BatchSellQuoteDetailsModalParams } from './BatchSellQuoteDetailsModal.types'; import { strings } from '../../../../../../locales/i18n'; export function BatchSellQuoteDetailsModal() { const navigation = useNavigation>>(); - const quoteDetailsParams = useParams(); - const { tokenData, totalReceived, minimumReceived, isLoading } = - quoteDetailsParams; + const sourceTokens = useSelector(selectBatchSellSourceTokens); + const batchSellQuoteData = useBatchSellQuoteData({ + shouldUpdateBatchSellTrades: false, + }); + const tokenData = useMemo( + () => + getBatchSellOrderedQuoteTokenData( + sourceTokens, + batchSellQuoteData.tokenData, + ), + [batchSellQuoteData.tokenData, sourceTokens], + ); const handleOpenMinimumReceivedInfo = () => { navigation.replace( Routes.BRIDGE.MODALS.BATCH_SELL_MINIMUM_RECEIVED_INFO_MODAL, { sourceModal: { screen: Routes.BRIDGE.MODALS.BATCH_SELL_QUOTE_DETAILS_MODAL, - params: quoteDetailsParams, }, }, ); @@ -48,9 +60,9 @@ export function BatchSellQuoteDetailsModal() { diff --git a/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.test.ts b/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.test.ts index b7f71027fbd..723b9378ac6 100644 --- a/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.test.ts +++ b/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.test.ts @@ -12,13 +12,6 @@ jest.mock('../useTokensWithBalance', () => ({ useTokensWithBalance: jest.fn(), })); -jest.mock('@metamask/bridge-controller', () => ({ - formatAddressToAssetId: jest.fn( - (address: string, chainId: string) => `${chainId}/erc20:${address}`, - ), - isNonEvmChainId: jest.fn((chainId: string) => !chainId.startsWith('0x')), -})); - const mockUseTokensWithBalance = useTokensWithBalance as jest.Mock; describe('useBalancesByAssetId', () => { @@ -43,13 +36,13 @@ describe('useBalancesByAssetId', () => { it('maps token balances to assetId keys', () => { const mockTokens = [ createMockTokenWithBalance({ - address: '0xtoken1', + address: '0x1111111111111111111111111111111111111111', balance: '50.0', balanceFiat: '$50', tokenFiatAmount: 50, }), createMockTokenWithBalance({ - address: '0xtoken2', + address: '0x2222222222222222222222222222222222222222', balance: '100.0', balanceFiat: '$100', tokenFiatAmount: 100, @@ -64,13 +57,13 @@ describe('useBalancesByAssetId', () => { ); expect(result.current.balancesByAssetId).toEqual({ - '0x1/erc20:0xtoken1': { + 'eip155:1/erc20:0x1111111111111111111111111111111111111111': { balance: '50.0', balanceFiat: '$50', tokenFiatAmount: 50, currencyExchangeRate: 1, }, - '0x1/erc20:0xtoken2': { + 'eip155:1/erc20:0x2222222222222222222222222222222222222222': { balance: '100.0', balanceFiat: '$100', tokenFiatAmount: 100, @@ -79,6 +72,74 @@ describe('useBalancesByAssetId', () => { }); }); + it('maps EVM token balances to canonical and lowercase assetId keys', () => { + const mockTokens = [ + createMockTokenWithBalance({ + address: '0xA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48', + balance: '50.0', + balanceFiat: '$50', + }), + ]; + mockUseTokensWithBalance.mockReturnValue(mockTokens); + + const { result } = renderHook(() => + useBalancesByAssetId({ + chainIds: [MOCK_CHAIN_IDS_HEX.ethereum as Hex], + }), + ); + + expect( + result.current.balancesByAssetId[ + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as CaipAssetType + ], + ).toEqual( + expect.objectContaining({ + balance: '50.0', + balanceFiat: '$50', + }), + ); + expect( + result.current.balancesByAssetId[ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType + ], + ).toEqual( + expect.objectContaining({ + balance: '50.0', + balanceFiat: '$50', + }), + ); + }); + + it('maps non-EVM token balances to a single assetId key', () => { + const mockTokens = [ + createMockTokenWithBalance({ + address: 'SoLTokenABC', + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, + balance: '50.0', + balanceFiat: '$50', + }), + ]; + mockUseTokensWithBalance.mockReturnValue(mockTokens); + + const { result } = renderHook(() => + useBalancesByAssetId({ + chainIds: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId], + }), + ); + + expect(Object.keys(result.current.balancesByAssetId)).toHaveLength(1); + expect( + result.current.balancesByAssetId[ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:SoLTokenABC' as CaipAssetType + ], + ).toEqual( + expect.objectContaining({ + balance: '50.0', + balanceFiat: '$50', + }), + ); + }); + it('returns tokensWithBalance array from useTokensWithBalance', () => { const mockTokens = [ createMockTokenWithBalance({ address: '0xtoken1' }), @@ -100,11 +161,11 @@ describe('useBalancesByAssetId', () => { it('excludes tokens without balance', () => { const mockTokens = [ createMockTokenWithBalance({ - address: '0xwithbalance', + address: '0x3333333333333333333333333333333333333333', balance: '50.0', }), createMockTokenWithBalance({ - address: '0xnobalance', + address: '0x4444444444444444444444444444444444444444', balance: undefined, }), ]; @@ -119,12 +180,12 @@ describe('useBalancesByAssetId', () => { expect(Object.keys(result.current.balancesByAssetId)).toHaveLength(1); expect( result.current.balancesByAssetId[ - '0x1/erc20:0xwithbalance' as CaipAssetType + 'eip155:1/erc20:0x3333333333333333333333333333333333333333' as CaipAssetType ], ).toBeDefined(); expect( result.current.balancesByAssetId[ - '0x1/erc20:0xnobalance' as CaipAssetType + 'eip155:1/erc20:0x4444444444444444444444444444444444444444' as CaipAssetType ], ).toBeUndefined(); }); @@ -134,12 +195,12 @@ describe('useBalancesByAssetId', () => { it('handles multiple chain IDs', () => { const mockTokens = [ createMockTokenWithBalance({ - address: '0xtoken1', + address: '0x1111111111111111111111111111111111111111', chainId: MOCK_CHAIN_IDS_HEX.ethereum as Hex, balance: '10.0', }), createMockTokenWithBalance({ - address: '0xtoken2', + address: '0x2222222222222222222222222222222222222222', chainId: '0xa' as Hex, balance: '20.0', }), @@ -153,17 +214,21 @@ describe('useBalancesByAssetId', () => { ); expect( - result.current.balancesByAssetId['0x1/erc20:0xtoken1' as CaipAssetType], + result.current.balancesByAssetId[ + 'eip155:1/erc20:0x1111111111111111111111111111111111111111' as CaipAssetType + ], ).toBeDefined(); expect( - result.current.balancesByAssetId['0xa/erc20:0xtoken2' as CaipAssetType], + result.current.balancesByAssetId[ + 'eip155:10/erc20:0x2222222222222222222222222222222222222222' as CaipAssetType + ], ).toBeDefined(); }); it('handles CAIP chain IDs', () => { const mockTokens = [ createMockTokenWithBalance({ - address: '0xtoken1', + address: '0x1111111111111111111111111111111111111111', chainId: 'eip155:1' as CaipChainId, balance: '100.0', }), @@ -176,7 +241,7 @@ describe('useBalancesByAssetId', () => { expect( result.current.balancesByAssetId[ - 'eip155:1/erc20:0xtoken1' as CaipAssetType + 'eip155:1/erc20:0x1111111111111111111111111111111111111111' as CaipAssetType ], ).toBeDefined(); }); @@ -208,7 +273,7 @@ describe('useBalancesByAssetId', () => { it('preserves optional balance properties', () => { const mockTokens = [ createMockTokenWithBalance({ - address: '0xtoken1', + address: '0x5555555555555555555555555555555555555555', balance: '50.0', balanceFiat: undefined, tokenFiatAmount: undefined, @@ -224,7 +289,9 @@ describe('useBalancesByAssetId', () => { ); expect( - result.current.balancesByAssetId['0x1/erc20:0xtoken1' as CaipAssetType], + result.current.balancesByAssetId[ + 'eip155:1/erc20:0x5555555555555555555555555555555555555555' as CaipAssetType + ], ).toEqual({ balance: '50.0', balanceFiat: undefined, @@ -237,7 +304,7 @@ describe('useBalancesByAssetId', () => { it('includes accountType when token has accountType', () => { const mockTokens = [ createMockTokenWithBalance({ - address: '0xbtctoken', + address: '0x6666666666666666666666666666666666666666', balance: '1.5', balanceFiat: '$45000', tokenFiatAmount: 45000, @@ -254,7 +321,7 @@ describe('useBalancesByAssetId', () => { expect( result.current.balancesByAssetId[ - '0x1/erc20:0xbtctoken' as CaipAssetType + 'eip155:1/erc20:0x6666666666666666666666666666666666666666' as CaipAssetType ], ).toEqual({ balance: '1.5', diff --git a/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.ts b/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.ts index e1d617340ee..e0d779443f6 100644 --- a/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.ts +++ b/app/components/UI/Bridge/hooks/useBalancesByAssetId/index.ts @@ -45,17 +45,22 @@ export const useBalancesByAssetId = ({ tokensWithBalance.forEach((token) => { const assetId = formatAddressToAssetId(token.address, token.chainId); if (assetId && token.balance) { - // Normalize assetId because API returns assetId in lowercase for EVM chains - const normalizedAssetId = isNonEvmChainId(token.chainId) - ? assetId - : (assetId.toLowerCase() as CaipAssetType); - balancesMap[normalizedAssetId] = { + const balanceData = { balance: token.balance, balanceFiat: token.balanceFiat, tokenFiatAmount: token.tokenFiatAmount, currencyExchangeRate: token.currencyExchangeRate, accountType: token.accountType, }; + + // Store the canonical bridge-controller key for checksummed lookups for EVM. + balancesMap[assetId] = balanceData; + + // Also store the lowercase EVM key + const normalizedAssetId = isNonEvmChainId(token.chainId) + ? assetId + : (assetId.toLowerCase() as CaipAssetType); + balancesMap[normalizedAssetId] = balanceData; } }); diff --git a/app/components/UI/Bridge/hooks/useBatchSellHasSufficientGas/index.ts b/app/components/UI/Bridge/hooks/useBatchSellHasSufficientGas/index.ts new file mode 100644 index 00000000000..b792d4df2a3 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useBatchSellHasSufficientGas/index.ts @@ -0,0 +1,63 @@ +import { + formatChainIdToCaip, + formatChainIdToHex, + isNonEvmChainId, +} from '@metamask/bridge-controller'; +import type { CaipChainId, Hex } from '@metamask/utils'; +import BigNumber from 'bignumber.js'; +import { ethers } from 'ethers'; + +import { isNumberValue } from '../../../../../util/number/bigint'; +import { useLatestBalance } from '../useLatestBalance'; +import type { useBatchSellQuoteData } from '../useBatchSellQuoteData'; + +type BatchSellNetworkFee = ReturnType< + typeof useBatchSellQuoteData +>['networkFee']; + +interface Props { + isGasless: boolean; + networkFee: BatchSellNetworkFee; +} + +/** + * @returns null if the fee token balance is not available, true if the balance is sufficient, false if the balance is insufficient + */ +export const useBatchSellHasSufficientGas = ({ + isGasless, + networkFee, +}: Props): boolean | null => { + const networkFeeAsset = networkFee.asset; + const networkFeeChainId = networkFeeAsset?.chainId; + + let hexOrCaipChainId: CaipChainId | Hex | undefined; + if (networkFeeChainId) { + hexOrCaipChainId = isNonEvmChainId(networkFeeChainId) + ? formatChainIdToCaip(networkFeeChainId) + : formatChainIdToHex(networkFeeChainId); + } + + const feeTokenBalance = useLatestBalance({ + address: networkFeeAsset?.address, + chainId: hexOrCaipChainId, + decimals: networkFeeAsset?.decimals, + }); + + // TODO figure out what happen when the transactions array is empty in obtainBatchSellQuotes endpoint + if (isGasless) { + return true; + } + + const networkFeeAmount = + isNumberValue(networkFee.amount) && networkFee.amount != null + ? new BigNumber(networkFee.amount).toFixed() + : null; + const atomicNetworkFee = + networkFeeAmount && networkFeeAsset?.decimals !== undefined + ? ethers.utils.parseUnits(networkFeeAmount, networkFeeAsset.decimals) + : null; + + return feeTokenBalance?.atomicBalance && atomicNetworkFee + ? feeTokenBalance.atomicBalance.gte(atomicNetworkFee) + : null; +}; diff --git a/app/components/UI/Bridge/hooks/useBatchSellHasSufficientGas/useBatchSellHasSufficientGas.test.ts b/app/components/UI/Bridge/hooks/useBatchSellHasSufficientGas/useBatchSellHasSufficientGas.test.ts new file mode 100644 index 00000000000..d2dd0f86a22 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useBatchSellHasSufficientGas/useBatchSellHasSufficientGas.test.ts @@ -0,0 +1,148 @@ +import { BigNumber } from 'ethers'; + +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { useLatestBalance } from '../useLatestBalance'; +import { useBatchSellHasSufficientGas } from './index'; + +jest.mock('../useLatestBalance'); + +type BatchSellNetworkFee = Parameters< + typeof useBatchSellHasSufficientGas +>[0]['networkFee']; + +const feeAsset: NonNullable = { + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', + chainId: 1, + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', +}; + +const createNetworkFee = ( + overrides: Partial = {}, +): BatchSellNetworkFee => ({ + amount: '0.001', + valueInCurrency: '3.25', + asset: feeAsset, + formatted: '0.001 ETH', + formattedFiat: '$3.25', + ...overrides, +}); + +describe('useBatchSellHasSufficientGas', () => { + const mockUseLatestBalance = useLatestBalance as jest.MockedFunction< + typeof useLatestBalance + >; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns true when the fee token balance covers the batch sell network fee', () => { + mockUseLatestBalance.mockReturnValue({ + displayBalance: '0.01', + atomicBalance: BigNumber.from('10000000000000000'), + }); + + const { result } = renderHookWithProvider( + () => + useBatchSellHasSufficientGas({ + isGasless: false, + networkFee: createNetworkFee({ amount: '0.001' }), + }), + { state: {} }, + ); + + expect(result.current).toBe(true); + expect(mockUseLatestBalance).toHaveBeenCalledWith({ + address: feeAsset.address, + chainId: '0x1', + decimals: feeAsset.decimals, + }); + }); + + it('returns false when the fee token balance is below the batch sell network fee', () => { + mockUseLatestBalance.mockReturnValue({ + displayBalance: '0.001', + atomicBalance: BigNumber.from('1000000000000000'), + }); + + const { result } = renderHookWithProvider( + () => + useBatchSellHasSufficientGas({ + isGasless: false, + networkFee: createNetworkFee({ amount: '0.01' }), + }), + { state: {} }, + ); + + expect(result.current).toBe(false); + }); + + it('handles scientific notation in the batch sell network fee', () => { + mockUseLatestBalance.mockReturnValue({ + displayBalance: '0.001', + atomicBalance: BigNumber.from('1000000000000000'), + }); + + const { result } = renderHookWithProvider( + () => + useBatchSellHasSufficientGas({ + isGasless: false, + networkFee: createNetworkFee({ amount: '9.200359292e-8' }), + }), + { state: {} }, + ); + + expect(result.current).toBe(true); + }); + + it('returns null when the batch sell network fee is missing', () => { + mockUseLatestBalance.mockReturnValue({ + displayBalance: '0.01', + atomicBalance: BigNumber.from('10000000000000000'), + }); + + const { result } = renderHookWithProvider( + () => + useBatchSellHasSufficientGas({ + isGasless: false, + networkFee: createNetworkFee({ amount: undefined }), + }), + { state: {} }, + ); + + expect(result.current).toBe(null); + }); + + it('returns null when the fee token balance is missing', () => { + mockUseLatestBalance.mockReturnValue(undefined); + + const { result } = renderHookWithProvider( + () => + useBatchSellHasSufficientGas({ + isGasless: false, + networkFee: createNetworkFee(), + }), + { state: {} }, + ); + + expect(result.current).toBe(null); + }); + + it('returns true when the batch sell quotes are gasless', () => { + mockUseLatestBalance.mockReturnValue(undefined); + + const { result } = renderHookWithProvider( + () => + useBatchSellHasSufficientGas({ + isGasless: true, + networkFee: createNetworkFee({ amount: undefined, asset: undefined }), + }), + { state: {} }, + ); + + expect(result.current).toBe(true); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts new file mode 100644 index 00000000000..c1a9e8a71f3 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/index.ts @@ -0,0 +1,506 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import BigNumber from 'bignumber.js'; +import { CaipAssetType } from '@metamask/utils'; +import { + formatAddressToAssetId, + isNativeAddress, +} from '@metamask/bridge-controller'; + +import { + selectBatchSellDestToken, + selectBatchSellQuotes, + selectBatchSellSlippages, + selectBatchSellSourceTokens, + selectBatchSellTrades, + selectBridgeFeatureFlags, +} from '../../../../../core/redux/slices/bridge'; +import AppConstants from '../../../../../core/AppConstants'; +import Engine from '../../../../../core/Engine'; +import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; +import formatFiat from '../../../../../util/formatFiat'; +import Logger from '../../../../../util/Logger'; +import { formatTokenBalance } from '../../utils'; +import { + getBatchSellSlippage, + getSlippageDisplayValue, +} from '../../components/SlippageModal/utils'; +import type { BridgeToken } from '../../types'; +import { getQuoteRefreshRate, isQuoteExpired } from '../../utils/quoteUtils'; + +const UNKNOWN_DESTINATION_TOKEN_SYMBOL = 'UNKNOWN'; +const QUOTE_DETAILS_PLACEHOLDER_AMOUNT = '--'; +const BATCH_SELL_TRADES_REQUEST_KEY_SEPARATOR = '|'; + +export interface BatchSellQuoteTokenData { + key: string; + tokenSymbol: string; + slippage: string; + receivedAmount: string; + receivedAmountFiat: string; + priceImpact?: string; + isLoading: boolean; + isHighPriceImpact: boolean; + isQuoteUnavailable: boolean; +} + +export type BatchSellQuoteTokenDataByAssetId = Record< + CaipAssetType, + BatchSellQuoteTokenData +>; +type BatchSellRecommendedQuote = NonNullable< + ReturnType['recommendedQuotes'][number] +>; +type BatchSellRecommendedQuotes = ReturnType< + typeof selectBatchSellQuotes +>['recommendedQuotes']; +type BatchSellQuoteAmountKey = 'toTokenAmount' | 'minToTokenAmount'; + +interface BatchSellQuoteRow { + assetId: CaipAssetType; + recommendedQuote: BatchSellRecommendedQuote | undefined; + tokenSymbol: string; +} + +interface UseBatchSellQuoteDataOptions { + shouldUpdateBatchSellTrades?: boolean; +} + +export function getBatchSellOrderedQuoteTokenData( + sourceTokens: BridgeToken[], + tokenData: BatchSellQuoteTokenDataByAssetId, +) { + return sourceTokens.reduce( + (quoteTokenData, token) => { + const assetId = formatAddressToAssetId(token.address, token.chainId); + const tokenQuoteData = assetId ? tokenData[assetId] : undefined; + + if (tokenQuoteData) quoteTokenData.push(tokenQuoteData); + + return quoteTokenData; + }, + [], + ); +} + +function formatTokenAmountWithSymbol( + amount: string | undefined, + symbol: string | undefined, +) { + const tokenSymbol = symbol ? ` ${symbol}` : ''; + + if (amount === undefined) + return `${QUOTE_DETAILS_PLACEHOLDER_AMOUNT}${tokenSymbol}`; + + return `${formatTokenBalance(amount)}${tokenSymbol}`; +} + +function formatQuoteDisplayValue({ + amount, + valueInCurrency, + symbol, + currency, +}: { + amount: string | undefined; + valueInCurrency: string | null | undefined; + symbol: string | undefined; + currency: string; +}) { + const hasTokenAmount = amount !== undefined; + const hasNonZeroTokenAmount = hasTokenAmount && new BigNumber(amount).gt(0); + const hasMissingDisplayValue = + !valueInCurrency || + (new BigNumber(valueInCurrency).isZero() && hasNonZeroTokenAmount); + + if (hasMissingDisplayValue && hasTokenAmount) { + return formatTokenAmountWithSymbol(amount, symbol); + } + + if (!valueInCurrency) return '-'; + + return formatFiat(new BigNumber(valueInCurrency), currency); +} + +function formatCurrencyDisplayValue( + valueInCurrency: string | null | undefined, + currency: string, +) { + if (!valueInCurrency) return '-'; + + return formatFiat(new BigNumber(valueInCurrency), currency); +} + +function isQuoteForDestinationAssetId( + quote: BatchSellRecommendedQuote, + destinationAssetId: CaipAssetType | undefined, +) { + return ( + destinationAssetId !== undefined && + formatAddressToAssetId( + quote.quote.destAsset.address, + quote.quote.destChainId, + ) === destinationAssetId + ); +} + +function getRecommendedQuoteBySourceAndDestinationAssetId( + recommendedQuotes: BatchSellRecommendedQuotes, + sourceAssetId: CaipAssetType, + destinationAssetId: CaipAssetType | undefined, +) { + return recommendedQuotes.find((quote): quote is BatchSellRecommendedQuote => + Boolean( + quote && + formatAddressToAssetId( + quote.quote.srcAsset.address, + quote.quote.srcChainId, + ) === sourceAssetId && + isQuoteForDestinationAssetId(quote, destinationAssetId), + ), + ); +} + +function getBatchSellTradesRequestKey( + recommendedQuotes: BatchSellRecommendedQuotes, +) { + return recommendedQuotes + .map((quote) => quote?.quoteId ?? quote?.quote.requestId ?? '') + .join(BATCH_SELL_TRADES_REQUEST_KEY_SEPARATOR); +} + +function sumRecommendedQuoteAmounts( + recommendedQuotes: BatchSellRecommendedQuote[], + amountKey: BatchSellQuoteAmountKey, +) { + return recommendedQuotes.reduce( + (total, quote) => ({ + amount: new BigNumber(total.amount) + .plus(quote[amountKey]?.amount ?? 0) + .toString(), + valueInCurrency: + total.valueInCurrency || quote[amountKey]?.valueInCurrency + ? new BigNumber(total.valueInCurrency ?? 0) + .plus(quote[amountKey]?.valueInCurrency ?? 0) + .toString() + : null, + }), + { amount: '0', valueInCurrency: null as string | null }, + ); +} + +function getBatchSellMetamaskFeePercent( + recommendedQuotes: BatchSellRecommendedQuote[], +) { + const quoteBpsFee = recommendedQuotes + .map((recommendedQuote) => { + // TODO: remove this once controller types are updated + // @ts-expect-error: controller types are not up to date yet + const fee = recommendedQuote.quote.feeData?.metabridge?.quoteBpsFee; + + return fee as number | string | null | undefined; + }) + .find((fee): fee is number | string => fee !== undefined && fee !== null); + const parsedQuoteBpsFee = + quoteBpsFee === undefined ? undefined : new BigNumber(quoteBpsFee); + + if (!parsedQuoteBpsFee?.isFinite() || parsedQuoteBpsFee.lte(0)) + return undefined; + + return parsedQuoteBpsFee.div(100).toString(); +} + +export function useBatchSellQuoteData({ + shouldUpdateBatchSellTrades = true, +}: UseBatchSellQuoteDataOptions = {}) { + const sourceTokens = useSelector(selectBatchSellSourceTokens); + const selectedDestinationToken = useSelector(selectBatchSellDestToken); + const batchSellSlippages = useSelector(selectBatchSellSlippages); + const batchSellQuotes = useSelector(selectBatchSellQuotes); + const batchSellTrades = useSelector(selectBatchSellTrades); + const bridgeFeatureFlags = useSelector(selectBridgeFeatureFlags); + const currentCurrency = useSelector(selectCurrentCurrency); + const priceImpactWarningThreshold = + bridgeFeatureFlags?.priceImpactThreshold?.warning ?? + AppConstants.BRIDGE.PRICE_IMPACT_WARNING_THRESHOLD; + const refreshRate = getQuoteRefreshRate(bridgeFeatureFlags, sourceTokens[0]); + + const destinationTokenSymbol = + selectedDestinationToken?.symbol ?? UNKNOWN_DESTINATION_TOKEN_SYMBOL; + const destinationAssetId = selectedDestinationToken + ? formatAddressToAssetId( + selectedDestinationToken.address, + selectedDestinationToken.chainId, + ) + : undefined; + const recommendedQuotes = useMemo( + () => batchSellQuotes.recommendedQuotes ?? [], + [batchSellQuotes.recommendedQuotes], + ); + const recommendedQuotesRequestKey = useMemo( + () => getBatchSellTradesRequestKey(recommendedQuotes), + [recommendedQuotes], + ); + const lastFetchedRecommendedQuotesRequestKey = useRef( + undefined, + ); + const lastBatchSellTradesRequestKey = useRef(undefined); + useEffect(() => { + if (!batchSellQuotes.isLoading) { + lastFetchedRecommendedQuotesRequestKey.current = + recommendedQuotesRequestKey; + } + }, [batchSellQuotes.isLoading, recommendedQuotesRequestKey]); + + const shouldHideStaleRefreshQuotes = Boolean( + batchSellQuotes.isLoading && + lastFetchedRecommendedQuotesRequestKey.current && + lastFetchedRecommendedQuotesRequestKey.current === + recommendedQuotesRequestKey, + ); + const visibleRecommendedQuotes = useMemo( + () => (shouldHideStaleRefreshQuotes ? [] : recommendedQuotes), + [recommendedQuotes, shouldHideStaleRefreshQuotes], + ); + const hasStaleDestinationQuotes = recommendedQuotes.some( + (quote) => + quote && !isQuoteForDestinationAssetId(quote, destinationAssetId), + ); + const hasQuoteResultsForSelectedTokens = + sourceTokens.length > 0 && + (Boolean(batchSellQuotes.quotesLastFetchedMs) || + visibleRecommendedQuotes.length === sourceTokens.length); + const quoteRows = useMemo( + () => + sourceTokens.reduce((rows, token) => { + const assetId = formatAddressToAssetId(token.address, token.chainId); + + if (!assetId) return rows; + + rows.push({ + assetId, + recommendedQuote: getRecommendedQuoteBySourceAndDestinationAssetId( + visibleRecommendedQuotes, + assetId, + destinationAssetId, + ), + tokenSymbol: token.symbol, + }); + + return rows; + }, []), + [destinationAssetId, sourceTokens, visibleRecommendedQuotes], + ); + const availableRecommendedQuotes = useMemo( + () => + quoteRows + .map(({ recommendedQuote }) => recommendedQuote) + .filter((quote): quote is BatchSellRecommendedQuote => Boolean(quote)), + [quoteRows], + ); + const hasAnyQuote = availableRecommendedQuotes.length > 0; + const totalNetworkFee = batchSellTrades.totalNetworkFee; + // Quote-level gasless params are not reliable for Batch Sell because gasless + // behavior is only simulated when the controller calls obtainGaslessBatch. + // Clients do not consume that API response directly; selectBatchSellTrades + // exposes the controller-interpreted result, so derive gasless state from it. + const isGasless = + hasAnyQuote && + batchSellTrades.isBatchSellTradeAvailable && + Boolean( + totalNetworkFee?.asset && !isNativeAddress(totalNetworkFee.asset.address), + ); + const isWaitingForQuoteRows = + !hasQuoteResultsForSelectedTokens || + batchSellQuotes.isLoading || + hasStaleDestinationQuotes; + const hasPendingQuoteRows = quoteRows.some( + ({ recommendedQuote }) => !recommendedQuote && isWaitingForQuoteRows, + ); + const canDisplayAggregatedQuoteData = + hasAnyQuote && !hasStaleDestinationQuotes; + const needsNewQuote = + canDisplayAggregatedQuoteData && + !batchSellQuotes.isLoading && + isQuoteExpired( + batchSellQuotes.isQuoteGoingToRefresh, + refreshRate, + batchSellQuotes.quotesLastFetchedMs ?? null, + ); + const isLoading = + batchSellQuotes.isLoading || + !hasQuoteResultsForSelectedTokens || + hasStaleDestinationQuotes; + const isSummaryLoading = + (!hasAnyQuote || hasStaleDestinationQuotes) && isLoading; + const totalReceived = useMemo( + () => + sumRecommendedQuoteAmounts(availableRecommendedQuotes, 'toTokenAmount'), + [availableRecommendedQuotes], + ); + const minimumReceived = useMemo( + () => + sumRecommendedQuoteAmounts( + availableRecommendedQuotes, + 'minToTokenAmount', + ), + [availableRecommendedQuotes], + ); + const batchSellTradesRequestKey = useMemo( + () => getBatchSellTradesRequestKey(availableRecommendedQuotes), + [availableRecommendedQuotes], + ); + const networkFeeIsLoading = !batchSellTrades.isBatchSellTradeAvailable; + const totalReceivedAmount = canDisplayAggregatedQuoteData + ? totalReceived.amount + : undefined; + const totalReceivedValueInCurrency = canDisplayAggregatedQuoteData + ? totalReceived.valueInCurrency + : undefined; + const minimumReceivedAmount = canDisplayAggregatedQuoteData + ? minimumReceived.amount + : undefined; + const totalNetworkFeeAmount = canDisplayAggregatedQuoteData + ? totalNetworkFee?.amount + : undefined; + const totalNetworkFeeValueInCurrency = canDisplayAggregatedQuoteData + ? totalNetworkFee?.valueInCurrency + : undefined; + const totalReceivedData = { + amount: totalReceivedAmount, + valueInCurrency: totalReceivedValueInCurrency, + formatted: formatTokenAmountWithSymbol( + totalReceivedAmount, + destinationTokenSymbol, + ), + formattedFiat: canDisplayAggregatedQuoteData + ? formatQuoteDisplayValue({ + amount: totalReceivedAmount, + valueInCurrency: totalReceivedValueInCurrency, + symbol: destinationTokenSymbol, + currency: currentCurrency, + }) + : '-', + }; + const minimumReceivedData = { + amount: minimumReceivedAmount, + valueInCurrency: canDisplayAggregatedQuoteData + ? minimumReceived.valueInCurrency + : undefined, + formatted: formatTokenAmountWithSymbol( + minimumReceivedAmount, + destinationTokenSymbol, + ), + }; + const networkFeeData = { + amount: totalNetworkFeeAmount, + valueInCurrency: totalNetworkFeeValueInCurrency, + asset: totalNetworkFee?.asset, + formatted: formatTokenAmountWithSymbol( + totalNetworkFeeAmount, + totalNetworkFee?.asset.symbol, + ), + formattedFiat: canDisplayAggregatedQuoteData + ? formatCurrencyDisplayValue( + totalNetworkFeeValueInCurrency, + currentCurrency, + ) + : '-', + }; + const quotePercentFee = useMemo( + () => getBatchSellMetamaskFeePercent(availableRecommendedQuotes), + [availableRecommendedQuotes], + ); + + useEffect(() => { + if ( + !shouldUpdateBatchSellTrades || + !hasAnyQuote || + hasPendingQuoteRows || + hasStaleDestinationQuotes + ) { + return; + } + + if (lastBatchSellTradesRequestKey.current === batchSellTradesRequestKey) { + return; + } + + lastBatchSellTradesRequestKey.current = batchSellTradesRequestKey; + + Engine.context.BridgeController.updateBatchSellTrades( + availableRecommendedQuotes, + ).catch((error) => { + Logger.error(error, 'Failed to update Batch Sell trades'); + }); + }, [ + availableRecommendedQuotes, + batchSellTradesRequestKey, + hasAnyQuote, + hasPendingQuoteRows, + hasStaleDestinationQuotes, + shouldUpdateBatchSellTrades, + ]); + + const tokenData = useMemo( + () => + quoteRows.reduce( + (tokenDataByAssetId, { assetId, recommendedQuote, tokenSymbol }) => { + const slippage = getBatchSellSlippage(batchSellSlippages, assetId); + const quoteDestinationTokenSymbol = + recommendedQuote?.quote.destAsset.symbol ?? destinationTokenSymbol; + const priceImpact = recommendedQuote?.quote.priceData?.priceImpact; + const parsedPriceImpact = Number(priceImpact); + const isMissingQuote = !recommendedQuote; + + tokenDataByAssetId[assetId] = { + key: assetId, + tokenSymbol, + slippage: getSlippageDisplayValue(slippage), + receivedAmount: formatTokenAmountWithSymbol( + recommendedQuote?.toTokenAmount.amount, + quoteDestinationTokenSymbol, + ), + receivedAmountFiat: formatQuoteDisplayValue({ + amount: recommendedQuote?.toTokenAmount.amount, + valueInCurrency: recommendedQuote?.toTokenAmount.valueInCurrency, + symbol: quoteDestinationTokenSymbol, + currency: currentCurrency, + }), + priceImpact, + isHighPriceImpact: + priceImpact !== undefined && + Number.isFinite(parsedPriceImpact) && + parsedPriceImpact >= priceImpactWarningThreshold, + isLoading: isMissingQuote && isWaitingForQuoteRows, + isQuoteUnavailable: isMissingQuote && !isWaitingForQuoteRows, + }; + + return tokenDataByAssetId; + }, + {}, + ), + [ + batchSellSlippages, + destinationTokenSymbol, + currentCurrency, + isWaitingForQuoteRows, + priceImpactWarningThreshold, + quoteRows, + ], + ); + + return { + tokenData, + totalReceived: totalReceivedData, + minimumReceived: minimumReceivedData, + isLoading, + isSummaryLoading, + isGasless, + hasAnyQuote, + hasPendingQuoteRows, + needsNewQuote, + networkFeeIsLoading, + networkFee: networkFeeData, + quotePercentFee, + }; +} diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts new file mode 100644 index 00000000000..22d88793101 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteData/useBatchSellQuoteData.test.ts @@ -0,0 +1,847 @@ +import { renderHook } from '@testing-library/react-native'; +import { CaipAssetType, Hex } from '@metamask/utils'; + +import Engine from '../../../../../core/Engine'; +import { BridgeToken } from '../../types'; +import { useBatchSellQuoteData } from '.'; + +jest.mock('../useBatchSellQuoteRequest', () => ({ + getBatchSellAtomicSourceAmount: jest.fn( + (_token: BridgeToken, sourceAmount?: string) => + sourceAmount && Number(sourceAmount) > 0 ? '1' : undefined, + ), +})); + +jest.mock('../../../../../core/Engine', () => ({ + __esModule: true, + default: { + context: { + BridgeController: { + state: { + batchSellTrades: undefined, + batchSellTradesLoadingStatus: undefined, + quotesLoadingStatus: undefined, + }, + updateBatchSellTrades: jest.fn().mockResolvedValue(undefined), + }, + }, + }, +})); + +const ethAssetId = + 'eip155:1/erc20:0x1111111111111111111111111111111111111111' as CaipAssetType; +const uniAssetId = + 'eip155:1/erc20:0x2222222222222222222222222222222222222222' as CaipAssetType; + +const ethToken: BridgeToken = { + address: '0x1111111111111111111111111111111111111111', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + balance: '1', +}; + +const uniToken: BridgeToken = { + address: '0x2222222222222222222222222222222222222222', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'UNI', + balance: '2', +}; + +const usdcToken: BridgeToken = { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', +}; + +const usdtToken: BridgeToken = { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDT', +}; + +function buildMockRecommendedQuote( + sourceToken: BridgeToken, + amount: string, + valueInCurrency: string | null, + destinationToken: BridgeToken = usdcToken, + priceData?: { priceImpact?: string }, + quoteId = `${sourceToken.symbol}-${destinationToken.symbol}-${amount}`, + quoteOverrides: Partial<{ + gasIncluded: boolean; + gasIncluded7702: boolean; + gasSponsored: boolean; + quoteBpsFee: number | string | null; + }> = {}, +) { + const { quoteBpsFee = 87.5, ...remainingQuoteOverrides } = quoteOverrides; + + return { + quoteId, + quote: { + requestId: quoteId, + srcAsset: { address: sourceToken.address }, + srcChainId: Number(sourceToken.chainId), + destAsset: { + address: destinationToken.address, + symbol: destinationToken.symbol, + }, + destChainId: Number(destinationToken.chainId), + feeData: { metabridge: { quoteBpsFee } }, + ...(priceData ? { priceData } : {}), + ...remainingQuoteOverrides, + }, + toTokenAmount: { amount, valueInCurrency }, + minToTokenAmount: { amount, valueInCurrency }, + }; +} + +type MockRecommendedQuote = ReturnType; + +const ethNetworkFeeAsset = { + symbol: 'ETH', + chainId: 1, + address: '0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60' as CaipAssetType, + name: 'Ether', + decimals: 18, +}; + +const usdcNetworkFeeAsset = { + symbol: 'USDC', + chainId: 1, + address: usdcToken.address, + assetId: + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType, + name: 'USD Coin', + decimals: 6, +}; + +let mockSelectedTokens: BridgeToken[] = [ethToken, uniToken]; +let mockSelectedDestinationToken: BridgeToken | undefined = usdcToken; +let mockBatchSellSourceTokenAmounts: Partial< + Record +> = { + [ethAssetId]: '1', + [uniAssetId]: '2', +}; +let mockBatchSellQuotes: { + recommendedQuotes: (MockRecommendedQuote | null)[]; + totalReceived: { amount: string; valueInCurrency: string | null }; + minimumReceived: { amount: string; valueInCurrency: string | null }; + isLoading: boolean; + quotesLastFetchedMs?: number; + isQuoteGoingToRefresh: boolean; +} = { + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', '123.45'), + buildMockRecommendedQuote(uniToken, '77', '77.89'), + ], + totalReceived: { amount: '200', valueInCurrency: '201.34' }, + minimumReceived: { amount: '190', valueInCurrency: '191.23' }, + isLoading: false, + isQuoteGoingToRefresh: true, +}; +let mockBatchSellTrades: { + totalNetworkFee: + | { + amount: string; + valueInCurrency: string | null; + asset: typeof ethNetworkFeeAsset; + } + | undefined; + isBatchSellTradeAvailable: boolean; +} = { + totalNetworkFee: { + amount: '1.2', + valueInCurrency: '1.25', + asset: ethNetworkFeeAsset, + }, + isBatchSellTradeAvailable: true, +}; +let mockBridgeFeatureFlags: { + chains: Record; + refreshRate: number; + priceImpactThreshold?: { warning?: number }; +} = { + chains: {}, + refreshRate: 30000, + priceImpactThreshold: { warning: 0.05 }, +}; + +jest.mock('react-redux', () => ({ + useSelector: (selector: (state: unknown) => unknown) => selector({}), +})); + +jest.mock('../../../../../core/redux/slices/bridge', () => ({ + selectBatchSellDestToken: jest.fn(() => mockSelectedDestinationToken), + selectBatchSellQuotes: jest.fn(() => mockBatchSellQuotes), + selectBatchSellSlippages: jest.fn(() => ({})), + selectBatchSellSourceTokenAmounts: jest.fn( + () => mockBatchSellSourceTokenAmounts, + ), + selectBatchSellSourceTokens: jest.fn(() => mockSelectedTokens), + selectBatchSellTrades: jest.fn(() => mockBatchSellTrades), + selectBridgeFeatureFlags: jest.fn(() => mockBridgeFeatureFlags), +})); + +jest.mock('../../../../../selectors/currencyRateController', () => ({ + selectCurrentCurrency: jest.fn(() => 'USD'), +})); + +jest.mock('../../../../../util/Logger', () => ({ + __esModule: true, + default: { + error: jest.fn(), + log: jest.fn(), + }, +})); + +describe('useBatchSellQuoteData', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSelectedTokens = [ethToken, uniToken]; + mockSelectedDestinationToken = usdcToken; + mockBatchSellSourceTokenAmounts = { + [ethAssetId]: '1', + [uniAssetId]: '2', + }; + mockBatchSellQuotes = { + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', '123.45'), + buildMockRecommendedQuote(uniToken, '77', '77.89'), + ], + totalReceived: { amount: '200', valueInCurrency: '201.34' }, + minimumReceived: { amount: '190', valueInCurrency: '191.23' }, + isLoading: false, + isQuoteGoingToRefresh: true, + }; + mockBatchSellTrades = { + totalNetworkFee: { + amount: '1.2', + valueInCurrency: '1.25', + asset: ethNetworkFeeAsset, + }, + isBatchSellTradeAvailable: true, + }; + mockBridgeFeatureFlags = { + chains: {}, + refreshRate: 30000, + priceImpactThreshold: { warning: 0.05 }, + }; + }); + + it('formats complete Batch Sell quote data', () => { + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.isGasless).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.hasPendingQuoteRows).toBe(false); + expect(result.current.needsNewQuote).toBe(false); + expect(result.current.totalReceived.amount).toBe('200'); + expect(result.current.totalReceived.valueInCurrency).toBe('201.34'); + expect(result.current.minimumReceived.amount).toBe('200'); + expect(result.current.networkFee.amount).toBe('1.2'); + expect(result.current.networkFee.valueInCurrency).toBe('1.25'); + expect(result.current.quotePercentFee).toBe('0.875'); + expect(result.current.totalReceived.formatted).toBe('200 USDC'); + expect(result.current.totalReceived.formattedFiat).toBe('$201.34'); + expect(result.current.minimumReceived.formatted).toBe('200 USDC'); + expect(result.current.networkFeeIsLoading).toBe(false); + expect(result.current.networkFee.formatted).toBe('1.2 ETH'); + expect(result.current.networkFee.formattedFiat).toBe('$1.25'); + expect( + Engine.context.BridgeController.updateBatchSellTrades, + ).toHaveBeenCalledWith(mockBatchSellQuotes.recommendedQuotes); + expect(result.current.tokenData).toEqual({ + [ethAssetId]: expect.objectContaining({ + key: ethAssetId, + tokenSymbol: 'ETH', + receivedAmount: '123 USDC', + receivedAmountFiat: '$123.45', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: false, + }), + [uniAssetId]: expect.objectContaining({ + key: uniAssetId, + tokenSymbol: 'UNI', + receivedAmount: '77 USDC', + receivedAmountFiat: '$77.89', + isLoading: false, + isHighPriceImpact: false, + isQuoteUnavailable: false, + }), + }); + }); + + it('does not mark Batch Sell quote data as gasless when the network fee is the native gas token', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote( + ethToken, + '123', + '123.45', + usdcToken, + undefined, + 'gasless-eth', + { gasIncluded: true, gasIncluded7702: false }, + ), + buildMockRecommendedQuote( + uniToken, + '77', + '77.89', + usdcToken, + undefined, + 'gasless-uni', + { gasIncluded: false, gasIncluded7702: true }, + ), + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.isGasless).toBe(false); + }); + + it('marks Batch Sell quote data as gasless when the network fee is not the native gas token', () => { + mockBatchSellTrades = { + ...mockBatchSellTrades, + totalNetworkFee: { + amount: '1.2', + valueInCurrency: '1.25', + asset: usdcNetworkFeeAsset, + }, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.isGasless).toBe(true); + }); + + it('does not need a new quote when the quote is expired but going to refresh', () => { + const now = 60000; + const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(now); + mockBridgeFeatureFlags = { + ...mockBridgeFeatureFlags, + refreshRate: 30000, + }; + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + quotesLastFetchedMs: 1, + isQuoteGoingToRefresh: true, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.needsNewQuote).toBe(false); + + dateNowSpy.mockRestore(); + }); + + it('needs a new quote when the quote is expired and no longer refreshing', () => { + const now = 60000; + const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(now); + mockBridgeFeatureFlags = { + ...mockBridgeFeatureFlags, + refreshRate: 30000, + }; + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + quotesLastFetchedMs: 1, + isQuoteGoingToRefresh: false, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.needsNewQuote).toBe(true); + expect(result.current.totalReceived.formatted).toBe('200 USDC'); + expect(result.current.totalReceived.formattedFiat).toBe('$201.34'); + + dateNowSpy.mockRestore(); + }); + + it('derives the MetaMask fee from the quoteBpsFee on quote data', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote( + ethToken, + '123', + '123.45', + usdcToken, + undefined, + 'dynamic-fee-eth', + { quoteBpsFee: 125 }, + ), + buildMockRecommendedQuote( + uniToken, + '77', + '77.89', + usdcToken, + undefined, + 'dynamic-fee-uni', + { quoteBpsFee: 125 }, + ), + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.quotePercentFee).toBe('1.25'); + }); + + it('does not expose a MetaMask fee when quoteBpsFee is zero', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote( + ethToken, + '123', + '123.45', + usdcToken, + undefined, + 'zero-fee-eth', + { quoteBpsFee: 0 }, + ), + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.quotePercentFee).toBeUndefined(); + }); + + it('does not fetch Batch Sell trades again for the same quote ids', () => { + const { rerender } = renderHook(() => useBatchSellQuoteData()); + + expect( + Engine.context.BridgeController.updateBatchSellTrades, + ).toHaveBeenCalledTimes(1); + + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [...mockBatchSellQuotes.recommendedQuotes], + }; + + rerender({}); + + expect( + Engine.context.BridgeController.updateBatchSellTrades, + ).toHaveBeenCalledTimes(1); + }); + + it('fetches Batch Sell trades again when the recommended quote id changes', () => { + const { rerender } = renderHook(() => useBatchSellQuoteData()); + + const [firstQuote, secondQuote] = mockBatchSellQuotes.recommendedQuotes; + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + firstQuote + ? { + ...firstQuote, + quoteId: 'updated-quote-id', + } + : firstQuote, + secondQuote, + ], + }; + + rerender({}); + + expect( + Engine.context.BridgeController.updateBatchSellTrades, + ).toHaveBeenCalledTimes(2); + }); + + it('falls back to destination token amounts when display currency values are unavailable', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', null), + buildMockRecommendedQuote(uniToken, '77', null), + ], + totalReceived: { amount: '200', valueInCurrency: '0' }, + }; + mockBatchSellTrades = { + ...mockBatchSellTrades, + totalNetworkFee: { + amount: '1.2', + valueInCurrency: '', + asset: ethNetworkFeeAsset, + }, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.totalReceived.formattedFiat).toBe('200 USDC'); + expect(result.current.networkFee.formatted).toBe('1.2 ETH'); + expect(result.current.networkFee.formattedFiat).toBe('-'); + expect(result.current.tokenData).toEqual({ + [ethAssetId]: expect.objectContaining({ + receivedAmountFiat: '123 USDC', + }), + [uniAssetId]: expect.objectContaining({ + receivedAmountFiat: '77 USDC', + }), + }); + }); + + it('does not fall back to the destination token symbol when trade fee is unavailable', () => { + mockBatchSellTrades = { + totalNetworkFee: undefined, + isBatchSellTradeAvailable: false, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.networkFee.formatted).toBe('--'); + expect(result.current.networkFeeIsLoading).toBe(true); + expect(result.current.networkFee.formattedFiat).toBe('-'); + }); + + it('marks quote rows below the warning threshold as safe', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', '123.45', usdcToken, { + priceImpact: '0.049', + }), + buildMockRecommendedQuote(uniToken, '77', '77.89'), + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.tokenData[ethAssetId]).toEqual( + expect.objectContaining({ + priceImpact: '0.049', + isHighPriceImpact: false, + }), + ); + }); + + it('marks quote rows at the warning threshold as high price impact', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', '123.45', usdcToken, { + priceImpact: '0.05', + }), + buildMockRecommendedQuote(uniToken, '77', '77.89'), + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.tokenData[ethAssetId]).toEqual( + expect.objectContaining({ + priceImpact: '0.05', + isHighPriceImpact: true, + }), + ); + }); + + it('falls back to the default warning threshold when the flag is absent', () => { + mockBridgeFeatureFlags = { + ...mockBridgeFeatureFlags, + priceImpactThreshold: {}, + }; + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', '123.45', usdcToken, { + priceImpact: '0.05', + }), + buildMockRecommendedQuote(uniToken, '77', '77.89'), + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.tokenData[ethAssetId].isHighPriceImpact).toBe(true); + }); + + it('matches recommended quotes by source asset id instead of array index', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(uniToken, '77', '77.89'), + buildMockRecommendedQuote(ethToken, '123', '123.45'), + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.tokenData[ethAssetId]).toEqual( + expect.objectContaining({ + tokenSymbol: 'ETH', + receivedAmount: '123 USDC', + receivedAmountFiat: '$123.45', + }), + ); + expect(result.current.tokenData[uniAssetId]).toEqual( + expect.objectContaining({ + tokenSymbol: 'UNI', + receivedAmount: '77 USDC', + receivedAmountFiat: '$77.89', + }), + ); + }); + + it('hides stale quotes when their destination does not match the selected stablecoin', () => { + mockSelectedDestinationToken = usdcToken; + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', null, usdtToken), + buildMockRecommendedQuote(uniToken, '77', '77.89', usdtToken), + ], + totalReceived: { amount: '200', valueInCurrency: '201.34' }, + minimumReceived: { amount: '190', valueInCurrency: '191.23' }, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(false); + expect(result.current.isLoading).toBe(true); + expect(result.current.isSummaryLoading).toBe(true); + expect(result.current.hasPendingQuoteRows).toBe(true); + expect(result.current.totalReceived.formatted).toBe('-- USDC'); + expect(result.current.totalReceived.formattedFiat).toBe('-'); + expect(result.current.minimumReceived.formatted).toBe('-- USDC'); + expect(result.current.networkFee.formatted).toBe('-- ETH'); + expect(result.current.networkFee.formattedFiat).toBe('-'); + expect(result.current.tokenData).toEqual({ + [ethAssetId]: expect.objectContaining({ + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isQuoteUnavailable: false, + }), + [uniAssetId]: expect.objectContaining({ + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isQuoteUnavailable: false, + }), + }); + }); + + it('marks rows without recommended quotes as unavailable after loading', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', '123.45'), + null, + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.hasPendingQuoteRows).toBe(false); + expect( + Engine.context.BridgeController.updateBatchSellTrades, + ).toHaveBeenCalledWith([mockBatchSellQuotes.recommendedQuotes[0]]); + expect(result.current.tokenData[uniAssetId]).toEqual( + expect.objectContaining({ + tokenSymbol: 'UNI', + receivedAmount: '-- USDC', + receivedAmountFiat: '-', + isLoading: false, + isQuoteUnavailable: true, + }), + ); + }); + + it('shows streamed row data and progressive totals while other rows are loading', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + isLoading: true, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '123', '123.45'), + null, + ], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.isLoading).toBe(true); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.hasPendingQuoteRows).toBe(true); + expect(result.current.totalReceived.formatted).toBe('123 USDC'); + expect(result.current.totalReceived.formattedFiat).toBe('$123.45'); + expect(result.current.minimumReceived.formatted).toBe('123 USDC'); + expect( + Engine.context.BridgeController.updateBatchSellTrades, + ).not.toHaveBeenCalled(); + expect(result.current.tokenData[ethAssetId]).toEqual( + expect.objectContaining({ + tokenSymbol: 'ETH', + receivedAmount: '123 USDC', + isLoading: false, + isQuoteUnavailable: false, + }), + ); + expect(result.current.tokenData[uniAssetId]).toEqual( + expect.objectContaining({ + tokenSymbol: 'UNI', + isLoading: true, + isQuoteUnavailable: false, + }), + ); + }); + + it('clears pending rows when every selected token has a quote while still loading', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + isLoading: true, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.isLoading).toBe(true); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.hasPendingQuoteRows).toBe(false); + expect( + Engine.context.BridgeController.updateBatchSellTrades, + ).toHaveBeenCalledWith(mockBatchSellQuotes.recommendedQuotes); + }); + + it('hides stale quotes when a refresh starts and reveals new streamed quotes progressively', () => { + const { result, rerender } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.totalReceived.formatted).toBe('200 USDC'); + + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + isLoading: true, + }; + + rerender({}); + + expect(result.current.hasAnyQuote).toBe(false); + expect(result.current.isSummaryLoading).toBe(true); + expect(result.current.hasPendingQuoteRows).toBe(true); + expect(result.current.totalReceived.formatted).toBe('-- USDC'); + expect(result.current.tokenData[ethAssetId]).toEqual( + expect.objectContaining({ + isLoading: true, + }), + ); + + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [ + buildMockRecommendedQuote(ethToken, '125', '125.45'), + null, + ], + }; + + rerender({}); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.hasPendingQuoteRows).toBe(true); + expect(result.current.totalReceived.formatted).toBe('125 USDC'); + expect(result.current.tokenData[ethAssetId]).toEqual( + expect.objectContaining({ + receivedAmount: '125 USDC', + isLoading: false, + }), + ); + expect(result.current.tokenData[uniAssetId]).toEqual( + expect.objectContaining({ + isLoading: true, + }), + ); + }); + + it('keeps the batch loading before initial quote results arrive', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [], + totalReceived: { amount: '0', valueInCurrency: null }, + minimumReceived: { amount: '0', valueInCurrency: null }, + isLoading: false, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(false); + expect(result.current.isLoading).toBe(true); + expect(result.current.isSummaryLoading).toBe(true); + expect(result.current.hasPendingQuoteRows).toBe(true); + expect(result.current.totalReceived.formattedFiat).toBe('-'); + expect(result.current.tokenData[ethAssetId]).toEqual( + expect.objectContaining({ + tokenSymbol: 'ETH', + isLoading: true, + isQuoteUnavailable: false, + }), + ); + }); + + it('keeps the batch loading when quote results do not match selected tokens', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [buildMockRecommendedQuote(ethToken, '123', '123.45')], + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(true); + expect(result.current.isLoading).toBe(true); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.hasPendingQuoteRows).toBe(true); + expect(result.current.tokenData[uniAssetId]).toEqual( + expect.objectContaining({ + tokenSymbol: 'UNI', + isLoading: true, + isQuoteUnavailable: false, + }), + ); + }); + + it('marks the quote set unavailable when no rows have quotes', () => { + mockBatchSellQuotes = { + ...mockBatchSellQuotes, + recommendedQuotes: [null, null], + totalReceived: { amount: '0', valueInCurrency: null }, + minimumReceived: { amount: '0', valueInCurrency: null }, + }; + + const { result } = renderHook(() => useBatchSellQuoteData()); + + expect(result.current.hasAnyQuote).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.isSummaryLoading).toBe(false); + expect(result.current.hasPendingQuoteRows).toBe(false); + expect(result.current.totalReceived.formattedFiat).toBe('-'); + expect(result.current.tokenData).toEqual({ + [ethAssetId]: expect.objectContaining({ + tokenSymbol: 'ETH', + isLoading: false, + isQuoteUnavailable: true, + }), + [uniAssetId]: expect.objectContaining({ + tokenSymbol: 'UNI', + isLoading: false, + isQuoteUnavailable: true, + }), + }); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/index.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/index.ts new file mode 100644 index 00000000000..d847635fd85 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/index.ts @@ -0,0 +1,223 @@ +import { useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { debounce } from 'lodash'; +import BigNumber from 'bignumber.js'; +import { + formatAddressToAssetId, + formatAddressToCaipReference, +} from '@metamask/bridge-controller'; + +import Engine from '../../../../../core/Engine'; +import { + selectBatchSellDestToken, + selectBatchSellSlippages, + selectBatchSellSourceTokenAmounts, + selectBatchSellSourceTokens, +} from '../../../../../core/redux/slices/bridge'; +import { selectBatchSellSourceWalletAddress } from '../../../../../selectors/bridge'; +import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; +import { getDecimalChainId } from '../../../../../util/networks'; +import type { BridgeToken } from '../../types'; +import { getBatchSellSlippage } from '../../components/SlippageModal/utils'; +import { getSecurityWarnings } from '../../utils/tokenSecurityUtils'; + +export const BATCH_SELL_QUOTE_DEBOUNCE_MS = 300; + +interface BuildBatchSellQuoteRequestDataParams { + batchSellSlippages: ReturnType; + batchSellSourceTokenAmounts: ReturnType< + typeof selectBatchSellSourceTokenAmounts + >; + destToken: BridgeToken | undefined; + smartTransactionsEnabled: boolean; + sourceTokens: BridgeToken[]; + walletAddress: string | undefined; +} + +type BatchSellQuoteContext = Parameters< + typeof Engine.context.BridgeController.updateBridgeQuoteRequestParams +>[1]; +type BatchSellQuoteRequest = Parameters< + typeof Engine.context.BridgeController.updateBridgeQuoteRequestParams +>[0]; + +interface BatchSellQuoteRequestData { + quoteRequest: BatchSellQuoteRequest; + context: BatchSellQuoteContext; +} + +export function getBatchSellSourceTokenAmount( + token: BridgeToken, + percent: number, +) { + if (percent <= 0) return '0'; + if (!token.balance) return undefined; + + const sourceAmount = new BigNumber(token.balance).times(percent).div(100); + + if (!sourceAmount.isFinite()) return undefined; + + return sourceAmount.toFixed(); +} + +export function getBatchSellAtomicSourceAmount( + token: BridgeToken, + sourceAmount: string | undefined, +) { + if (!sourceAmount) return undefined; + + const atomicAmount = new BigNumber(sourceAmount) + .times(new BigNumber(10).pow(token.decimals)) + .integerValue(BigNumber.ROUND_DOWN); + + if (!atomicAmount.isFinite() || atomicAmount.lte(0)) return undefined; + + return atomicAmount.toFixed(0); +} + +function getBatchSellUsdAmountSource(token: BridgeToken, sourceAmount: string) { + const balance = token.balance ? Number(token.balance) : 0; + const numericSourceAmount = Number(sourceAmount); + + if (!Number.isFinite(numericSourceAmount) || balance <= 0) return 0; + + return ((token.tokenFiatAmount ?? 0) * numericSourceAmount) / balance; +} + +export function buildBatchSellQuoteRequestData({ + batchSellSlippages, + batchSellSourceTokenAmounts, + destToken, + smartTransactionsEnabled, + sourceTokens, + walletAddress, +}: BuildBatchSellQuoteRequestDataParams): BatchSellQuoteRequestData[] { + if (!destToken || !walletAddress) return []; + + const securityWarnings = getSecurityWarnings(destToken); + + return sourceTokens.reduce( + (quoteRequestData, sourceToken) => { + const assetId = formatAddressToAssetId( + sourceToken.address, + sourceToken.chainId, + ); + const sourceAmount = assetId + ? batchSellSourceTokenAmounts[assetId] + : undefined; + const srcTokenAmount = getBatchSellAtomicSourceAmount( + sourceToken, + sourceAmount, + ); + + if (!assetId || !sourceAmount || !srcTokenAmount) return quoteRequestData; + + const slippage = getBatchSellSlippage(batchSellSlippages, assetId); + const slippageNumber = + slippage === undefined ? undefined : Number(slippage); + + quoteRequestData.push({ + // The backend decides what kind of quote to return, so gasIncluded + // and gasIncluded7702 values are ignored. No need to include them. + quoteRequest: { + srcChainId: getDecimalChainId(sourceToken.chainId), + srcTokenAddress: formatAddressToCaipReference(sourceToken.address), + destChainId: getDecimalChainId(destToken.chainId), + destTokenAddress: formatAddressToCaipReference(destToken.address), + srcTokenAmount, + slippage: + slippageNumber === undefined || Number.isNaN(slippageNumber) + ? undefined + : slippageNumber, + walletAddress, + destWalletAddress: walletAddress, + }, + context: { + stx_enabled: smartTransactionsEnabled, + token_symbol_source: sourceToken.symbol, + token_symbol_destination: destToken.symbol, + token_security_type_destination: destToken.securityData?.type ?? null, + security_warnings: securityWarnings, + usd_amount_source: getBatchSellUsdAmountSource( + sourceToken, + sourceAmount, + ), + }, + }); + + return quoteRequestData; + }, + [], + ); +} + +async function updateBatchSellQuoteRequests( + quoteRequestData: BatchSellQuoteRequestData[], +) { + if (quoteRequestData.length === 0) return; + + for (let index = 0; index < quoteRequestData.length; index += 1) { + const { quoteRequest, context } = quoteRequestData[index]; + + await Engine.context.BridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + context, + index, + quoteRequestData.length, + ); + } +} + +export function useBatchSellQuoteRequest() { + const sourceTokens = useSelector(selectBatchSellSourceTokens); + const batchSellSourceTokenAmounts = useSelector( + selectBatchSellSourceTokenAmounts, + ); + const destToken = useSelector(selectBatchSellDestToken); + const batchSellSlippages = useSelector(selectBatchSellSlippages); + const walletAddress = useSelector(selectBatchSellSourceWalletAddress); + const smartTransactionsEnabled = useSelector(selectShouldUseSmartTransaction); + + const quoteRequestData = useMemo( + () => + buildBatchSellQuoteRequestData({ + batchSellSlippages, + batchSellSourceTokenAmounts, + destToken, + smartTransactionsEnabled, + sourceTokens, + walletAddress, + }), + [ + batchSellSlippages, + batchSellSourceTokenAmounts, + destToken, + sourceTokens, + walletAddress, + smartTransactionsEnabled, + ], + ); + + const updateQuoteParams = useCallback( + () => updateBatchSellQuoteRequests(quoteRequestData), + [quoteRequestData], + ); + + const updateBatchSellQuoteParams = useMemo( + () => debounce(updateQuoteParams, BATCH_SELL_QUOTE_DEBOUNCE_MS), + [updateQuoteParams], + ); + + const getNewQuote = useCallback(() => { + Engine.context.BridgeController?.resetState?.(); + updateBatchSellQuoteParams(); + }, [updateBatchSellQuoteParams]); + + return useMemo( + () => ({ + updateBatchSellQuoteParams, + getNewQuote, + }), + [getNewQuote, updateBatchSellQuoteParams], + ); +} diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts new file mode 100644 index 00000000000..4b4e5ebab5f --- /dev/null +++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts @@ -0,0 +1,382 @@ +import { act } from '@testing-library/react-native'; +import { CaipAssetType, Hex } from '@metamask/utils'; + +import Engine from '../../../../../core/Engine'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { createBridgeTestState } from '../../testUtils'; +import type { BridgeToken } from '../../types'; +import { + BATCH_SELL_QUOTE_DEBOUNCE_MS, + buildBatchSellQuoteRequestData, + getBatchSellAtomicSourceAmount, + getBatchSellSourceTokenAmount, + useBatchSellQuoteRequest, +} from '.'; + +let mockWalletAddress: string | undefined = + '0x1234567890123456789012345678901234567890'; + +jest.mock('../../../../../core/Engine', () => ({ + __esModule: true, + default: { + context: { + BridgeController: { + resetState: jest.fn(), + updateBridgeQuoteRequestParams: jest.fn().mockResolvedValue(undefined), + }, + }, + }, +})); + +jest.mock('../../../../../selectors/bridge', () => ({ + selectBatchSellSourceWalletAddress: jest.fn(() => mockWalletAddress), +})); + +jest.mock('../../../../../selectors/smartTransactionsController', () => ({ + selectShouldUseSmartTransaction: jest.fn(() => false), +})); + +const ethToken: BridgeToken = { + address: '0x1111111111111111111111111111111111111111', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + balance: '1.498', + tokenFiatAmount: 3000, +}; + +const uniToken: BridgeToken = { + address: '0x2222222222222222222222222222222222222222', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'UNI', + balance: '154.297', + tokenFiatAmount: 1000, +}; + +const usdcToken: BridgeToken = { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', +}; + +const ethAssetId = + 'eip155:1/erc20:0x1111111111111111111111111111111111111111' as CaipAssetType; + +function getBridgeControllerMock() { + return Engine.context.BridgeController as jest.Mocked< + typeof Engine.context.BridgeController + >; +} + +async function flushQuoteRequestDebounce() { + await act(async () => { + jest.advanceTimersByTime(BATCH_SELL_QUOTE_DEBOUNCE_MS); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe('useBatchSellQuoteRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockWalletAddress = '0x1234567890123456789012345678901234567890'; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns Batch Sell quote request functions', () => { + const testState = createBridgeTestState(); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + expect(typeof result.current.updateBatchSellQuoteParams).toBe('function'); + expect(typeof result.current.updateBatchSellQuoteParams.cancel).toBe( + 'function', + ); + expect(typeof result.current.getNewQuote).toBe('function'); + }); + + it('calculates source amounts from token balance percentages', () => { + const amount = getBatchSellSourceTokenAmount(ethToken, 50); + + expect(amount).toBe('0.749'); + }); + + it('calculates atomic source amounts from source amount values', () => { + const amount = getBatchSellAtomicSourceAmount(ethToken, '0.749'); + + expect(amount).toBe('749000000000000000'); + }); + + it('builds quote request data for non-zero Batch Sell source token amounts', () => { + const quoteRequestData = buildBatchSellQuoteRequestData({ + batchSellSlippages: { + [ethAssetId]: '2.5', + }, + batchSellSourceTokenAmounts: { + [ethAssetId]: '0.749', + }, + destToken: usdcToken, + smartTransactionsEnabled: false, + sourceTokens: [ethToken, uniToken], + walletAddress: mockWalletAddress, + }); + + expect(quoteRequestData).toEqual([ + expect.objectContaining({ + quoteRequest: expect.objectContaining({ + srcChainId: '1', + srcTokenAddress: ethToken.address, + destChainId: '1', + destTokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + srcTokenAmount: '749000000000000000', + slippage: 2.5, + walletAddress: mockWalletAddress, + destWalletAddress: mockWalletAddress, + }), + context: expect.objectContaining({ + stx_enabled: false, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + token_security_type_destination: null, + usd_amount_source: 1500, + }), + }), + ]); + }); + + it('updates BridgeController quote request params in index order', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + batchSellSourceTokens: [ethToken, uniToken], + batchSellSourceTokenAmounts: { + [ethAssetId]: ethToken.balance, + 'eip155:1/erc20:0x2222222222222222222222222222222222222222': + uniToken.balance, + }, + batchSellDestToken: usdcToken, + batchSellSlippages: {}, + }, + }); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + result.current.updateBatchSellQuoteParams(); + await flushQuoteRequestDebounce(); + + const bridgeController = getBridgeControllerMock(); + expect( + bridgeController.updateBridgeQuoteRequestParams, + ).toHaveBeenCalledTimes(2); + expect( + bridgeController.updateBridgeQuoteRequestParams.mock.calls[0][2], + ).toBe(0); + expect( + bridgeController.updateBridgeQuoteRequestParams.mock.calls[0][3], + ).toBe(2); + expect( + bridgeController.updateBridgeQuoteRequestParams.mock.calls[1][2], + ).toBe(1); + expect( + bridgeController.updateBridgeQuoteRequestParams.mock.calls[1][3], + ).toBe(2); + }); + + it('passes Batch Sell context to BridgeController quote request params', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + batchSellSourceTokens: [ethToken, uniToken], + batchSellSourceTokenAmounts: { + [ethAssetId]: '0.749', + 'eip155:1/erc20:0x2222222222222222222222222222222222222222': + '38.57425', + }, + batchSellDestToken: usdcToken, + batchSellSlippages: {}, + }, + }); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + result.current.updateBatchSellQuoteParams(); + await flushQuoteRequestDebounce(); + + const bridgeController = getBridgeControllerMock(); + expect( + bridgeController.updateBridgeQuoteRequestParams.mock.calls[0][1], + ).toEqual( + expect.objectContaining({ + stx_enabled: false, + token_symbol_source: 'ETH', + token_symbol_destination: 'USDC', + token_security_type_destination: null, + usd_amount_source: 1500, + }), + ); + expect( + bridgeController.updateBridgeQuoteRequestParams.mock.calls[1][1], + ).toEqual( + expect.objectContaining({ + stx_enabled: false, + token_symbol_source: 'UNI', + token_symbol_destination: 'USDC', + token_security_type_destination: null, + usd_amount_source: 250, + }), + ); + }); + + it('skips update when destination token is missing', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + batchSellSourceTokens: [ethToken], + batchSellDestToken: undefined, + }, + }); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + result.current.updateBatchSellQuoteParams(); + await flushQuoteRequestDebounce(); + + expect( + getBridgeControllerMock().updateBridgeQuoteRequestParams, + ).not.toHaveBeenCalled(); + }); + + it('skips update when wallet address is missing', async () => { + mockWalletAddress = undefined; + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + batchSellSourceTokens: [ethToken], + batchSellSourceTokenAmounts: { + [ethAssetId]: ethToken.balance, + }, + batchSellDestToken: usdcToken, + }, + }); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + result.current.updateBatchSellQuoteParams(); + await flushQuoteRequestDebounce(); + + expect( + getBridgeControllerMock().updateBridgeQuoteRequestParams, + ).not.toHaveBeenCalled(); + }); + + it('skips update when token percentages produce zero source amounts', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + batchSellSourceTokens: [ethToken, uniToken], + batchSellSourceTokenAmounts: { + [ethAssetId]: '0', + 'eip155:1/erc20:0x2222222222222222222222222222222222222222': '0', + }, + batchSellDestToken: usdcToken, + }, + }); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + result.current.updateBatchSellQuoteParams(); + await flushQuoteRequestDebounce(); + + expect( + getBridgeControllerMock().updateBridgeQuoteRequestParams, + ).not.toHaveBeenCalled(); + }); + + it('does not reset BridgeController state during quote request updates', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + batchSellSourceTokens: [ethToken], + batchSellSourceTokenAmounts: { + [ethAssetId]: ethToken.balance, + }, + batchSellDestToken: usdcToken, + }, + }); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + result.current.updateBatchSellQuoteParams(); + await flushQuoteRequestDebounce(); + + expect(getBridgeControllerMock().resetState).not.toHaveBeenCalled(); + }); + + it('resets BridgeController state before requesting a new quote', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + batchSellSourceTokens: [ethToken], + batchSellSourceTokenAmounts: { + [ethAssetId]: ethToken.balance, + }, + batchSellDestToken: usdcToken, + }, + }); + + const { result } = renderHookWithProvider( + () => useBatchSellQuoteRequest(), + { + state: testState, + }, + ); + + result.current.getNewQuote(); + + expect(getBridgeControllerMock().resetState).toHaveBeenCalledTimes(1); + await flushQuoteRequestDebounce(); + expect( + getBridgeControllerMock().updateBridgeQuoteRequestParams, + ).toHaveBeenCalledTimes(1); + expect( + getBridgeControllerMock().resetState.mock.invocationCallOrder[0], + ).toBeLessThan( + getBridgeControllerMock().updateBridgeQuoteRequestParams.mock + .invocationCallOrder[0], + ); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts b/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts index da8bc2d0e20..c4d761c5ae7 100644 --- a/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts +++ b/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts @@ -113,7 +113,7 @@ const mockActiveQuote = { value: '0xde0b6b3a7640000', data: '0x5f575529000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000136f6e65496e6368563646656544796e616d69630000000000000000000000000000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000001033050560000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000f326e4de8f66a0bdc0970b79e0924e33c79f191500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000048a76dfc3b0000000000000000000000000000000000000000000000000000000103305056200000000000000000000000e0554a476a092703abdb3ef35c80e0d76d32939f7dcbea7c0000000000000000000000000000000000000000000000001f', gasLimit: 266281, - }, + } as const, estimatedProcessingTimeInSeconds: 0, sentAmount: { amount: '1', diff --git a/app/components/UI/Bridge/routes.tsx b/app/components/UI/Bridge/routes.tsx index 5d9f4aa6bdd..a6ad992ade2 100644 --- a/app/components/UI/Bridge/routes.tsx +++ b/app/components/UI/Bridge/routes.tsx @@ -28,6 +28,7 @@ import { BatchSellQuoteDetailsModal } from './components/BatchSellQuoteDetailsMo import { BatchSellFinalReviewModal } from './components/BatchSellFinalReviewModal'; import { BatchSellNetworkFeeInfoModal } from './components/BatchSellNetworkFeeInfoModal'; import { BatchSellMinimumReceivedInfoModal } from './components/BatchSellMinimumReceivedInfoModal'; +import { BatchSellPriceImpactInfoModal } from './components/BatchSellPriceImpactInfoModal'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type ScreenComponent = React.ComponentType; @@ -137,5 +138,9 @@ export const BridgeModalStack = () => ( name={Routes.BRIDGE.MODALS.BATCH_SELL_MINIMUM_RECEIVED_INFO_MODAL} component={BatchSellMinimumReceivedInfoModal} /> + ); diff --git a/app/components/UI/Bridge/utils/tokenUtils.ts b/app/components/UI/Bridge/utils/tokenUtils.ts index f927e887e1e..8972a759bde 100644 --- a/app/components/UI/Bridge/utils/tokenUtils.ts +++ b/app/components/UI/Bridge/utils/tokenUtils.ts @@ -60,17 +60,6 @@ export function normalizeEvmAssetId(assetId: CaipAssetType): CaipAssetType { } } -export function getBridgeTokenAssetId( - token: BridgeToken, -): CaipAssetType | undefined { - try { - const assetId = formatAddressToAssetId(token.address, token.chainId); - return assetId ? normalizeEvmAssetId(assetId) : undefined; - } catch { - return undefined; - } -} - /** * Creates a formatted native token object for the given chain ID */ diff --git a/app/components/Views/TradeWalletActions/TradeWalletActions.test.tsx b/app/components/Views/TradeWalletActions/TradeWalletActions.test.tsx index 0abc1406a76..99a2acb85b2 100644 --- a/app/components/Views/TradeWalletActions/TradeWalletActions.test.tsx +++ b/app/components/Views/TradeWalletActions/TradeWalletActions.test.tsx @@ -25,6 +25,7 @@ import { selectPerpsEnabledFlag } from '../../UI/Perps'; import { selectIsFirstTimePerpsUser } from '../../UI/Perps/selectors/perpsController'; import { selectPredictEnabledFlag } from '../../UI/Predict'; import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; +import { isHardwareAccount } from '../../../util/address'; import TradeWalletActions from './TradeWalletActions'; jest.mock('react-native-device-info', () => ({ @@ -193,6 +194,11 @@ jest.mock('../../../constants/bridge', () => ({ BATCH_SELL_ENABLED: true, })); +jest.mock('../../../util/address', () => ({ + ...jest.requireActual('../../../util/address'), + isHardwareAccount: jest.fn(), +})); + const mockInitialState: DeepPartial = { swaps: { '0x1': { isLive: true }, hasOnboarded: false, isLive: true }, fiatOrders: { @@ -291,6 +297,7 @@ jest.mock('../../../util/navigation/navUtils', () => ({ describe('TradeWalletActions', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(isHardwareAccount).mockReturnValue(false); mockUseStakingEligibility.mockReturnValue({ isEligible: true, @@ -375,6 +382,27 @@ describe('TradeWalletActions', () => { ).toBeDefined(); }); + it('does not render Batch Sell for hardware wallets', () => { + jest.mocked(isHardwareAccount).mockReturnValue(true); + + const { getByTestId, queryByTestId } = renderScreen( + TradeWalletActions, + { + name: 'TradeWalletActions', + }, + { + state: mockInitialState, + }, + ); + + expect( + queryByTestId(WalletActionsBottomSheetSelectorsIDs.BATCH_SELL_BUTTON), + ).toBeNull(); + expect( + getByTestId(WalletActionsBottomSheetSelectorsIDs.SWAP_BUTTON), + ).toBeDefined(); + }); + it('does not render earn button when user is not eligible', () => { ( selectStablecoinLendingEnabledFlag as jest.MockedFunction< diff --git a/app/components/Views/TradeWalletActions/TradeWalletActions.tsx b/app/components/Views/TradeWalletActions/TradeWalletActions.tsx index d12af4fdcbe..70aa1145ab3 100644 --- a/app/components/Views/TradeWalletActions/TradeWalletActions.tsx +++ b/app/components/Views/TradeWalletActions/TradeWalletActions.tsx @@ -43,9 +43,13 @@ import Routes from '../../../constants/navigation/Routes'; import AppConstants from '../../../core/AppConstants'; import { selectIsSwapsEnabled } from '../../../core/redux/slices/bridge'; import { RootState } from '../../../reducers'; -import { selectCanSignTransactions } from '../../../selectors/accountsController'; +import { + selectCanSignTransactions, + selectSelectedInternalAccountAddress, +} from '../../../selectors/accountsController'; import { earnSelectors } from '../../../selectors/earnController'; import { selectChainId } from '../../../selectors/networkController'; +import { isHardwareAccount } from '../../../util/address'; import { getDecimalChainId } from '../../../util/networks'; import { SwapBridgeNavigationLocation, @@ -111,6 +115,12 @@ function TradeWalletActions() { const { isEligible: isEarnEligible } = useStakingEligibility(); const canSignTransactions = useSelector(selectCanSignTransactions); + const selectedAddress = useSelector(selectSelectedInternalAccountAddress); + const isHardwareWallet = selectedAddress + ? Boolean(isHardwareAccount(selectedAddress)) + : false; + const shouldRenderBatchSell = + BATCH_SELL_ENABLED && AppConstants.SWAPS.ACTIVE && !isHardwareWallet; const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); const isPredictEnabled = useSelector(selectPredictEnabledFlag); @@ -299,7 +309,7 @@ function TradeWalletActions() { `px-0`, )} > - {BATCH_SELL_ENABLED && AppConstants.SWAPS.ACTIVE && ( + {shouldRenderBatchSell && ( diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 26b9ba0aa1d..a05f3d080b3 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -331,6 +331,7 @@ const Routes = { BATCH_SELL_NETWORK_FEE_INFO_MODAL: 'BatchSellNetworkFeeInfoModal', BATCH_SELL_MINIMUM_RECEIVED_INFO_MODAL: 'BatchSellMinimumReceivedInfoModal', + BATCH_SELL_PRICE_IMPACT_INFO_MODAL: 'BatchSellPriceImpactInfoModal', }, BRIDGE_TRANSACTION_DETAILS: 'BridgeTransactionDetails', }, diff --git a/app/core/Engine/controllers/bridge-controller/bridge-controller-init.test.ts b/app/core/Engine/controllers/bridge-controller/bridge-controller-init.test.ts index 2547029c475..b4d94fd5afa 100644 --- a/app/core/Engine/controllers/bridge-controller/bridge-controller-init.test.ts +++ b/app/core/Engine/controllers/bridge-controller/bridge-controller-init.test.ts @@ -410,5 +410,29 @@ describe('BridgeController Init', () => { handleBridgeFetch(url, options); expect(handleFetch).toHaveBeenCalledWith(url.toString(), options); }); + + it('should use fetch if the url includes obtainGaslessBatch', async () => { + const url = new URL('http://localhost:3000/obtainGaslessBatch'); + const options = { + body: JSON.stringify({ quotes: [] }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }; + const response = { + ok: true, + status: 200, + statusText: 'OK', + } as unknown as Response; + const fetchMock = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(response); + + await expect(handleBridgeFetch(url, options)).resolves.toBe(response); + + expect(fetchMock).toHaveBeenCalledWith(url.toString(), options); + expect(handleFetch).not.toHaveBeenCalled(); + + fetchMock.mockRestore(); + }); }); }); diff --git a/app/core/Engine/controllers/bridge-controller/bridge-controller-init.ts b/app/core/Engine/controllers/bridge-controller/bridge-controller-init.ts index 1f9ad2754fe..89a957cf589 100644 --- a/app/core/Engine/controllers/bridge-controller/bridge-controller-init.ts +++ b/app/core/Engine/controllers/bridge-controller/bridge-controller-init.ts @@ -34,10 +34,17 @@ export const handleBridgeFetch = async ( url: RequestInfo | URL, options: RequestInit = {}, ) => { - if (url.toString().includes('Stream')) { + const urlString = url.toString(); + + if (urlString.includes('Stream')) { // @ts-expect-error - expoFetch has a different RequestInit type - return expoFetch(url.toString(), options); + return expoFetch(urlString, options); + } + + if (urlString.includes('/obtainGaslessBatch')) { + return fetch(urlString, options); } + return handleFetch(url, options); }; diff --git a/app/core/NavigationService/types.ts b/app/core/NavigationService/types.ts index a746199b899..93259262e57 100644 --- a/app/core/NavigationService/types.ts +++ b/app/core/NavigationService/types.ts @@ -19,8 +19,6 @@ import type { BrowserParams } from '../../components/Views/Browser/Browser.types // Bridge params import type { BridgeRouteParams } from '../../components/UI/Bridge/hooks/useSwapBridgeNavigation'; import type { BridgeTokenSelectorRouteParams } from '../../components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector'; -import type { BatchSellQuoteDetailsModalParams } from '../../components/UI/Bridge/components/BatchSellQuoteDetailsModal/BatchSellQuoteDetailsModal.types'; -import type { BatchSellFinalReviewModalParams } from '../../components/UI/Bridge/components/BatchSellFinalReviewModal/BatchSellFinalReviewModal.types'; import type { BatchSellNetworkFeeInfoModalParams } from '../../components/UI/Bridge/components/BatchSellNetworkFeeInfoModal/BatchSellNetworkFeeInfoModal.types'; import type { BatchSellMinimumReceivedInfoModalParams } from '../../components/UI/Bridge/components/BatchSellMinimumReceivedInfoModal/BatchSellMinimumReceivedInfoModal.types'; import type { @@ -550,8 +548,8 @@ export interface RootStackParamList extends ParamListBase { BlockaidModal: BlockaidModalParams; RecipientSelectorModal: undefined; BatchSellDestinationTokenSelectorModal: undefined; - BatchSellQuoteDetailsModal: BatchSellQuoteDetailsModalParams; - BatchSellFinalReviewModal: BatchSellFinalReviewModalParams; + BatchSellQuoteDetailsModal: undefined; + BatchSellFinalReviewModal: undefined; BatchSellNetworkFeeInfoModal: BatchSellNetworkFeeInfoModalParams | undefined; BatchSellMinimumReceivedInfoModal: | BatchSellMinimumReceivedInfoModalParams diff --git a/app/core/redux/slices/bridge/index.test.ts b/app/core/redux/slices/bridge/index.test.ts index ed980ae97f5..53d020377cf 100644 --- a/app/core/redux/slices/bridge/index.test.ts +++ b/app/core/redux/slices/bridge/index.test.ts @@ -24,10 +24,14 @@ import reducer, { selectIsRwaSwap, setBatchSellSourceTokens, selectBatchSellSourceTokens, + setBatchSellSourceTokenAmount, + setBatchSellSourceTokenAmounts, + selectBatchSellSourceTokenAmounts, setBatchSellDestToken, selectBatchSellDestToken, selectBatchSellDestStablecoins, selectBatchSellDestStablecoinsByChain, + selectBatchSellQuotes, selectBatchSellSlippages, setBatchSellTokenSlippage, setBatchSellTokenSlippages, @@ -37,12 +41,32 @@ import { BridgeToken, BridgeViewMode, } from '../../../../components/UI/Bridge/types'; -import { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; +import { + CaipAssetType, + CaipChainId, + Hex, + parseCaipAssetType, +} from '@metamask/utils'; import { RootState } from '../../../../reducers'; import { cloneDeep } from 'lodash'; import { BridgeTokenMetadata } from '../../../../components/UI/Bridge/constants/tokens'; +import { formatAddressToAssetId } from '@metamask/bridge-controller'; describe('bridge slice', () => { + function getChecksummedBridgeTokenMetadata(assetId: CaipAssetType) { + const metadata = BridgeTokenMetadata[assetId]; + const formattedAssetId = formatAddressToAssetId( + metadata.address, + metadata.chainId, + ) as CaipAssetType; + const { assetReference } = parseCaipAssetType(formattedAssetId); + + return { + ...metadata, + address: assetReference, + }; + } + const mockToken: BridgeToken = { address: '0x123', symbol: 'ETH', @@ -98,6 +122,7 @@ describe('bridge slice', () => { selectedQuoteRequestId: undefined, abTestContext: undefined, batchSellSourceTokens: [], + batchSellSourceTokenAmounts: {}, batchSellDestToken: undefined, batchSellSlippages: {}, }); @@ -281,6 +306,50 @@ describe('bridge slice', () => { expect(selectBatchSellSourceTokens(mockState)).toEqual([mockToken]); }); + it('sets Batch Sell source token amount by asset ID', () => { + const assetId = + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType; + + const state = reducer( + initialState, + setBatchSellSourceTokenAmount({ assetId, amount: '1.5' }), + ); + + expect(state.batchSellSourceTokenAmounts[assetId]).toBe('1.5'); + }); + + it('replaces Batch Sell source token amount map', () => { + const assetId = + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType; + + const state = reducer( + { + ...initialState, + batchSellSourceTokenAmounts: { + 'eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7': '0.5', + }, + }, + setBatchSellSourceTokenAmounts({ [assetId]: '3' }), + ); + + expect(state.batchSellSourceTokenAmounts).toEqual({ [assetId]: '3' }); + }); + + it('selects Batch Sell source token amount map', () => { + const assetId = + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType; + const mockState = { + bridge: { + ...initialState, + batchSellSourceTokenAmounts: { [assetId]: '2' }, + }, + } as RootState; + + expect(selectBatchSellSourceTokenAmounts(mockState)).toEqual({ + [assetId]: '2', + }); + }); + it('sets Batch Sell destination token metadata', () => { const state = reducer(initialState, setBatchSellDestToken(mockToken)); @@ -760,7 +829,7 @@ describe('bridge slice', () => { { symbol: 'USDC', name: 'USD Coin', - address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6, image: 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', @@ -796,14 +865,12 @@ describe('bridge slice', () => { batchSellDestStablecoins: [baseUsdc], } as unknown as any; - const expectedEthUsdc = - BridgeTokenMetadata[ - 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType - ]; - const expectedBaseUsdc = - BridgeTokenMetadata[ - 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' as CaipAssetType - ]; + const expectedEthUsdc = getChecksummedBridgeTokenMetadata( + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType, + ); + const expectedBaseUsdc = getChecksummedBridgeTokenMetadata( + 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' as CaipAssetType, + ); const result = selectBatchSellDestStablecoinsByChain( mockState as unknown as RootState, @@ -859,6 +926,20 @@ describe('bridge slice', () => { }); }); + describe('selectBatchSellQuotes', () => { + it('uses the BridgeController quote request count', () => { + const mockState = cloneDeep(mockRootState); + mockState.engine.backgroundState.BridgeController.quoteRequest = [ + { srcTokenAddress: '0x1111111111111111111111111111111111111111' }, + { srcTokenAddress: '0x2222222222222222222222222222222222222222' }, + ] as unknown as typeof mockState.engine.backgroundState.BridgeController.quoteRequest; + + const result = selectBatchSellQuotes(mockState as unknown as RootState); + + expect(result.recommendedQuotes).toHaveLength(2); + }); + }); + describe('selectTokenSelectorNetworkFilter', () => { it('should return undefined when no filter is set', () => { const mockState = cloneDeep(mockRootState); diff --git a/app/core/redux/slices/bridge/index.ts b/app/core/redux/slices/bridge/index.ts index 2fe2ce26b86..c080f70dbeb 100644 --- a/app/core/redux/slices/bridge/index.ts +++ b/app/core/redux/slices/bridge/index.ts @@ -4,6 +4,7 @@ import { Hex, CaipChainId, parseCaipChainId, + parseCaipAssetType, CaipAssetType, } from '@metamask/utils'; import { createSelector } from 'reselect'; @@ -18,10 +19,13 @@ import { formatChainIdToCaip, isSolanaChainId, selectBridgeQuotes as selectBridgeQuotesBase, + selectBatchSellQuotes as selectBatchSellQuotesBase, + selectBatchSellTrades as selectBatchSellTradesBase, SortOrder, selectBridgeFeatureFlags as selectBridgeFeatureFlagsBase, DEFAULT_FEATURE_FLAG_CONFIG, isNonEvmChainId, + formatAddressToAssetId, formatChainIdToHex, type QuoteStreamCompleteData, } from '@metamask/bridge-controller'; @@ -42,10 +46,7 @@ import { selectCanSignTransactions } from '../../../../selectors/accountsControl import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; import { hasMinimumRequiredVersion } from './utils/hasMinimumRequiredVersion'; import { Bip44TokensForDefaultPairs } from '../../../../components/UI/Bridge/constants/default-swap-dest-tokens'; -import { - normalizeEvmAssetId, - normalizeTokenAddress, -} from '../../../../components/UI/Bridge/utils/tokenUtils'; +import { normalizeTokenAddress } from '../../../../components/UI/Bridge/utils/tokenUtils'; import { isStockRwaBridgeToken } from '../../../../components/UI/Bridge/utils/isStockRwaBridgeToken'; import { selectRWAEnabledFlag } from '../../../../selectors/featureFlagController/rwa'; import { BridgeTokenMetadata } from '../../../../components/UI/Bridge/constants/tokens'; @@ -95,6 +96,9 @@ export interface BridgeState { */ selectedQuoteRequestId: string | undefined; batchSellSourceTokens: BridgeToken[]; + batchSellSourceTokenAmounts: Partial< + Record + >; batchSellDestToken: BridgeToken | undefined; batchSellSlippages: Partial>; } @@ -123,6 +127,7 @@ export const initialState: BridgeState = { // Batch Sell batchSellSourceTokens: [], + batchSellSourceTokenAmounts: {}, batchSellDestToken: undefined, batchSellSlippages: {}, }; @@ -260,6 +265,22 @@ const slice = createSlice({ setBatchSellSourceTokens: (state, action: PayloadAction) => { state.batchSellSourceTokens = action.payload.map(normalizeBridgeToken); }, + setBatchSellSourceTokenAmount: ( + state, + action: PayloadAction<{ + assetId: CaipAssetType; + amount: string | undefined; + }>, + ) => { + state.batchSellSourceTokenAmounts[action.payload.assetId] = + action.payload.amount; + }, + setBatchSellSourceTokenAmounts: ( + state, + action: PayloadAction, + ) => { + state.batchSellSourceTokenAmounts = action.payload; + }, setBatchSellDestToken: ( state, action: PayloadAction, @@ -368,23 +389,57 @@ export const selectBridgeFeatureFlags = createSelector( }, ); +function formatBatchSellStablecoinAssetId( + assetId: CaipAssetType, +): CaipAssetType | undefined { + try { + const { assetNamespace, assetReference, chainId } = + parseCaipAssetType(assetId); + + if (chainId.startsWith('eip155:') && assetNamespace === 'erc20') { + return formatAddressToAssetId(assetReference, chainId); + } + + return formatAddressToAssetId(assetId) ?? assetId; + } catch { + return undefined; + } +} + function getBridgeTokenMetadata( assetId: CaipAssetType, ): BridgeToken | undefined { - const exactMatch = BridgeTokenMetadata[assetId]; + const formattedAssetId = formatBatchSellStablecoinAssetId(assetId); - if (exactMatch) { - return exactMatch; + if (!formattedAssetId) { + return undefined; } - const normalizedAssetId = normalizeEvmAssetId(assetId); const metadataAssetIds = Object.keys(BridgeTokenMetadata) as CaipAssetType[]; const metadataAssetId = metadataAssetIds.find( (bridgeTokenMetadataAssetId) => - normalizeEvmAssetId(bridgeTokenMetadataAssetId) === normalizedAssetId, + formatBatchSellStablecoinAssetId(bridgeTokenMetadataAssetId) === + formattedAssetId, ); + const tokenMetadata = metadataAssetId + ? BridgeTokenMetadata[metadataAssetId] + : undefined; - return metadataAssetId ? BridgeTokenMetadata[metadataAssetId] : undefined; + if (!tokenMetadata) { + return undefined; + } + + const { assetNamespace, assetReference, chainId } = + parseCaipAssetType(formattedAssetId); + + if (chainId.startsWith('eip155:') && assetNamespace === 'erc20') { + return { + ...tokenMetadata, + address: assetReference, + }; + } + + return tokenMetadata; } function getBatchSellDestStablecoinMetadata( @@ -574,6 +629,11 @@ export const selectBatchSellSourceTokens = createSelector( (bridgeState) => bridgeState.batchSellSourceTokens, ); +export const selectBatchSellSourceTokenAmounts = createSelector( + selectBridgeState, + (bridgeState) => bridgeState.batchSellSourceTokenAmounts ?? {}, +); + export const selectBatchSellDestToken = createSelector( selectBridgeState, (bridgeState) => bridgeState.batchSellDestToken, @@ -650,6 +710,21 @@ export const selectBridgeQuotes = createSelector( }, ); +export const selectBatchSellQuotes = createSelector( + selectControllerFields, + (requiredControllerFields) => + selectBatchSellQuotesBase(requiredControllerFields, { + sortOrder: SortOrder.COST_ASC, + requestCount: requiredControllerFields.quoteRequest.length, + }), +); + +export const selectBatchSellTrades = createSelector( + selectControllerFields, + (requiredControllerFields) => + selectBatchSellTradesBase(requiredControllerFields), +); + export const selectIsSolanaSourced = createSelector( selectSourceToken, (sourceToken) => sourceToken?.chainId && isSolanaChainId(sourceToken.chainId), @@ -873,6 +948,8 @@ export const { setVisiblePillChainIds, setSelectedQuoteRequestId, setBatchSellSourceTokens, + setBatchSellSourceTokenAmount, + setBatchSellSourceTokenAmounts, setBatchSellDestToken, setBatchSellTokenSlippage, setBatchSellTokenSlippages, diff --git a/app/selectors/bridge.ts b/app/selectors/bridge.ts index 493cf8617a1..a68a56521a0 100644 --- a/app/selectors/bridge.ts +++ b/app/selectors/bridge.ts @@ -10,6 +10,7 @@ import { RootState } from '../reducers'; import { selectSourceToken, selectDestToken, + selectBatchSellSourceTokens, selectIsSwap, selectIsGasIncludedSTXSendBundleSupported, selectIsGasIncluded7702Supported, @@ -38,6 +39,24 @@ export const selectSourceWalletAddress = createSelector( }, ); +/** + * Gets the wallet address for the first Batch Sell source token by finding the + * selected account that matches the token's chain scope. + */ +export const selectBatchSellSourceWalletAddress = createSelector( + [(state: RootState) => state, selectBatchSellSourceTokens], + (state, sourceTokens) => { + const [sourceToken] = sourceTokens; + if (!sourceToken) return undefined; + + const chainId = formatChainIdToCaip(sourceToken.chainId); + const internalAccount = + selectSelectedInternalAccountByScope(state)(chainId); + + return internalAccount ? internalAccount.address : undefined; + }, +); + /** * Returns a Set of InternalAccount IDs that are valid as destination accounts * for the currently selected destination token. For EVM destinations, includes diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index f4437a3ec04..de8446ce4fc 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -698,6 +698,8 @@ }, "BridgeController": { "assetExchangeRates": {}, + "batchSellTrades": null, + "batchSellTradesLoadingStatus": null, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, "quoteRequest": [ diff --git a/locales/languages/en.json b/locales/languages/en.json index 842faf30b40..9117e3ee84f 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7516,6 +7516,9 @@ "batch_sell_total_received": "Total received", "batch_sell_minimum_received": "Minimum received", "batch_sell_quote_details_row": "{{tokenSymbol}} • {{slippage}} slippage", + "batch_sell_no_quote_available": "No quote available", + "batch_sell_high_price_impact": "High price impact", + "batch_sell_high_price_impact_description": "This trade has an estimated {{priceImpact}} price impact, which reflects how much your trade changes the market price. The quote already reflects this.", "batch_sell_review": "Review", "batch_sell_you_sell": "You sell", "batch_sell_token_count": "{{tokenCount}} tokens", @@ -7555,6 +7558,7 @@ "quote_info_title": "Rate", "network_fee_info_title": "Network fee", "network_fee_info_content": "Network fees depend on how busy the network is and how complex your transaction is.", + "batch_sell_network_fee_info_content": "Network fees depend on how busy the network is and how complex your transaction is. If you don't have enough to cover the fee, we'll take it from the token you're converting to.", "network_fee_info_content_sponsored": "This network fee is paid by MetaMask, so you can transact without {{nativeToken}} in your account.", "points": "Est. points", "points_tooltip": "Points", diff --git a/package.json b/package.json index ecfc41900c9..e2ead296c41 100644 --- a/package.json +++ b/package.json @@ -252,7 +252,7 @@ "@metamask/authenticated-user-storage": "^2.0.0", "@metamask/base-controller": "^9.0.1", "@metamask/bitcoin-wallet-snap": "^1.11.0", - "@metamask/bridge-controller": "^72.0.0", + "@metamask/bridge-controller": "^73.0.1", "@metamask/bridge-status-controller": "^71.1.1", "@metamask/chain-agnostic-permission": "^1.5.0", "@metamask/chomp-api-service": "^3.1.0", diff --git a/yarn.lock b/yarn.lock index 647ad903939..23e5bdf6949 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8131,7 +8131,7 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^72.0.0, @metamask/bridge-controller@npm:^72.0.4": +"@metamask/bridge-controller@npm:^72.0.4": version: 72.0.4 resolution: "@metamask/bridge-controller@npm:72.0.4" dependencies: @@ -35537,7 +35537,7 @@ __metadata: "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.1" "@metamask/bitcoin-wallet-snap": "npm:^1.11.0" - "@metamask/bridge-controller": "npm:^72.0.0" + "@metamask/bridge-controller": "npm:^73.0.1" "@metamask/bridge-status-controller": "npm:^71.1.1" "@metamask/browser-passworder": "npm:^5.0.0" "@metamask/browser-playground": "npm:0.3.0" From e0eb673612185870db1ae69c3824ec7dc27a5ec4 Mon Sep 17 00:00:00 2001 From: Vince Howard Date: Thu, 28 May 2026 16:17:48 -0600 Subject: [PATCH 7/7] feat(component-library): migrate row primitives to Pressable (#30764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Continues the `TouchableOpacity` → `Pressable` migration started in #30543 by migrating six shared design-system row primitives. These sit beneath dozens of selector flows (account picker, network selector, Buy/Deposit modals, Card onboarding, Rewards, multi-select token search), so fixing them once cascades. Migrated components: - `ListItemSelect` - `ListItemMultiSelect` - `ListItemMultiSelectButton` - `ListItemMultiSelectWithMenuButton` - `CellSelectWithMenu` (the inner secondary-text press target) - `PickerBase` (+ `PickerAccount` consumer) Secondary changes: - **`Pressable` and `PressableGH` now `forwardRef`.** `TouchableOpacity` forwarded refs; without this the migration would have been a quiet capability regression for any future caller that wants to `measure()` / focus / animate the underlying view. Added real ref-forwarding tests (assert `ref.current.measure` exists) to both primitives. - **Removed `PickerAccount`'s manual pressed-state machinery.** It maintained `useState(pressed)` + `onPressIn`/`onPressOut` handlers + a `basePressed` style swap to `background.pressed` — exactly what `Pressable` now does for free. - **Dropped resting `background.default` from `ListItemSelect`/`ListItemMultiSelect`.** Matches the SettingsDrawer precedent from #30543: parent surface (sheet / screen) owns the resting background so the pressed overlay composites correctly, including in pure-black mode. - **Test updates:** Multi-select rows now have a correctly role-tagged row AND an inner `ButtonIcon`, so `getByRole('button')` is ambiguous. Switched affected tests to `getAllByRole('button')[0]` to target the row. ## **Changelog** CHANGELOG entry:null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-790 ## **Manual testing steps** ```gherkin Feature: Row press feedback across DS primitives Scenario: user presses a single-select row in pure-black mode Given MM_PURE_BLACK_PREVIEW=true is set in .js.env and the app is rebuilt When user opens Buy → Token selector and taps any token row Then the row background briefly shifts to background.pressed and remains clearly visible And the row does not "disappear" against the pure-black backdrop And there is no full-subtree opacity dim Scenario: user presses a multi-select row with a side menu button Given the app is open When user opens the wallet header account picker and taps an account row Then the row flashes the pressed token And the MoreVertical (⋮) menu button at the right is independently pressable Scenario: user presses the wallet header account picker Given the app is open on the Wallet screen When user taps the account name pill in the header Then the pill briefly shifts to background.pressed (no manual state, no opacity dim) And the account selector sheet opens Scenario: disabled rows still appear dimmed Given a list with a disabled ListItemSelect row Then the disabled row renders at opacity 0.5 (behavior unchanged) ``` ## **Screenshots/Recordings** ### Notice there is no dim anymore when pressed https://github.com/user-attachments/assets/469def01-dfb1-40f7-a75f-5862430f5f33 ### **Before** https://github.com/user-attachments/assets/3965483c-28aa-4c57-b0c9-c39ad97d2dae ### **After** https://github.com/user-attachments/assets/469def01-dfb1-40f7-a75f-5862430f5f33 ## **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** > Touches widely reused UI primitives and press/visual behavior (including pure-black mode), but changes are localized to the component library with added tests and no auth or data-path impact. > > **Overview** > Continues the **`TouchableOpacity` → design-system `Pressable`** migration on shared row and picker primitives used across account/network selectors, buy flows, and multi-select lists. > > **`ListItemSelect`**, **`ListItemMultiSelect`**, **`ListItemMultiSelectButton`**, **`ListItemMultiSelectWithMenuButton`**, **`CellSelectWithMenu`** (clickable secondary text), **`PickerBase`**, and **`PickerAccount`** now use **`Pressable`** for press handling and **`background.pressed`** overlay instead of subtree opacity dimming. **`Pressable`** and **`PressableGH`** gain **`forwardRef`** (with ref tests) so callers can still **`measure`** the native view. > > Style/layout tweaks: resting **`background.default`** removed from select rows so parent surfaces own the backdrop; multi-select button rows move disabled opacity and selection background onto the outer pressable **`container`**; **`PickerAccount`** drops manual pressed-state styling and **`onPressIn`/`onPressOut`** wiring. Tests add **`ROW_TEST_ID`** targeting and assert row **`accessibilityRole="button"`** where row + side **`ButtonIcon`** both expose buttons. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ec0a7977a8cb0bb1a1d686845ed15ec51453c7cd. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../CellSelectWithMenu.styles.ts | 7 +- .../CellSelectWithMenu/CellSelectWithMenu.tsx | 41 +++++----- .../ListItemMultiSelectButton.constants.ts | 1 + .../ListItemMultiSelectButton.styles.ts | 33 ++++---- .../ListItemMultiSelectButton.test.tsx | 23 ++++-- .../ListItemMultiSelectButton.tsx | 25 +++--- ...ItemMultiSelectWithMenuButton.constants.ts | 1 + ...istItemMultiSelectWithMenuButton.styles.ts | 29 +++---- ...ListItemMultiSelectWithMenuButton.test.tsx | 38 ++++++--- .../ListItemMultiSelectWithMenuButton.tsx | 25 +++--- .../Pressable/Pressable.test.tsx | 14 +++- .../components-temp/Pressable/Pressable.tsx | 51 ++++++------ .../Pressable/PressableGH.test.tsx | 14 +++- .../components-temp/Pressable/PressableGH.tsx | 52 ++++++------ .../ListItemMultiSelect.styles.ts | 1 - .../ListItemMultiSelect.test.tsx | 12 +++ .../ListItemMultiSelect.tsx | 7 +- .../ListItemSelect/ListItemSelect.styles.ts | 1 - .../ListItemSelect/ListItemSelect.test.tsx | 12 +++ .../List/ListItemSelect/ListItemSelect.tsx | 7 +- .../PickerAccount/PickerAccount.stories.tsx | 8 +- .../PickerAccount/PickerAccount.styles.ts | 7 -- .../PickerAccount/PickerAccount.test.tsx | 15 ++-- .../Pickers/PickerAccount/PickerAccount.tsx | 81 +++++++------------ .../PickerAccount/PickerAccount.types.ts | 4 +- .../Pickers/PickerBase/PickerBase.tsx | 53 ++++++------ .../Pickers/PickerBase/PickerBase.types.ts | 5 +- 27 files changed, 300 insertions(+), 267 deletions(-) diff --git a/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts b/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts index 860adbd5f24..dbe320b0e44 100644 --- a/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts +++ b/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.styles.ts @@ -24,12 +24,7 @@ const styleSheet = (params: { const { colors } = params.theme; return StyleSheet.create({ - base: Object.assign( - { - padding: 16, - } as ViewStyle, - style, - ) as ViewStyle, + base: Object.assign({} as ViewStyle, style) as ViewStyle, cellBase: Object.assign( { flexDirection: 'row', diff --git a/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.tsx b/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.tsx index 88f229ffa3a..99ba10148ac 100644 --- a/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.tsx +++ b/app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.tsx @@ -2,10 +2,11 @@ // Third library dependencies. import React from 'react'; -import { TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native'; +import { View } from 'react-native'; // External dependencies. import { useStyles } from '../../hooks'; +import Pressable from '../Pressable'; import Tag from '../../../component-library/components/Tags/Tag'; // Internal dependencies. @@ -74,27 +75,25 @@ const CellSelectWithMenu = ({ )} {!!secondaryText && (props.onTextClick ? ( - - + - - {secondaryText} - - {showSecondaryTextIcon && ( - - )} - - + {secondaryText} + + {showSecondaryTextIcon && ( + + )} + ) : ( { it('renders with default props', () => { - const { getByRole } = render( + const { getByTestId } = render( + + + , + ); + + expect(getByTestId(ROW_TEST_ID)).toBeOnTheScreen(); + }); + + it('exposes accessibilityRole="button" on the row', () => { + const { getByTestId } = render( , ); - expect(getByRole('button')).toBeOnTheScreen(); + expect(getByTestId(ROW_TEST_ID).props.accessibilityRole).toBe('button'); }); it('calls onPress when the button is pressed', () => { const mockOnPress = jest.fn(); - const { getByRole } = render( + const { getByTestId } = render( { , ); - fireEvent.press(getByRole('button')); + fireEvent.press(getByTestId(ROW_TEST_ID)); expect(mockOnPress).toHaveBeenCalled(); }); diff --git a/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.tsx b/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.tsx index 48ea2f5e34d..d5cadd7c95e 100644 --- a/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.tsx +++ b/app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.tsx @@ -2,10 +2,11 @@ // Third party dependencies. import React from 'react'; -import { TouchableOpacity, View } from 'react-native'; +import { View } from 'react-native'; // External dependencies. import { useStyles } from '../../hooks'; +import Pressable from '../Pressable'; import ListItem from '../../../component-library/components/List/ListItem/ListItem'; // Internal dependencies. @@ -14,6 +15,7 @@ import { ListItemMultiSelectButtonProps } from './ListItemMultiSelectButton.type import { BUTTON_TEST_ID, DEFAULT_LISTITEMMULTISELECT_GAP, + ROW_TEST_ID, } from './ListItemMultiSelectButton.constants'; import ButtonIcon from '../../../component-library/components/Buttons/ButtonIcon'; import { @@ -46,18 +48,19 @@ const ListItemMultiSelectButton: React.FC = ({ }); return ( - - + + {children} - + {showButtonIcon ? ( = ({ /> ) : null} - + ); }; diff --git a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.constants.ts b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.constants.ts index 2464df23728..b45e59f09da 100644 --- a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.constants.ts +++ b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.constants.ts @@ -10,6 +10,7 @@ export const DEFAULT_LIST_ITEM_MULTISELECT_WITH_MENU_BUTTON_GAP = 16; export const BUTTON_TEST_ID = 'button-menu-select-with-menu-button-test-id'; export const BUTTON_TEXT_TEST_ID = 'button-text-select-with-menu-button-test-id'; +export const ROW_TEST_ID = 'list-item-multi-select-with-menu-button-row'; // Sample consts export const SAMPLE_LIST_ITEM_MULTISELECT_WITH_MENU_BUTTON_PROPS = { diff --git a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.styles.ts b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.styles.ts index ba5109ac607..9a852939ea2 100644 --- a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.styles.ts +++ b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.styles.ts @@ -23,16 +23,10 @@ const styleSheet = (params: { const { colors } = theme; const { style, isDisabled } = vars; return StyleSheet.create({ - base: Object.assign( - { - flex: 1, - position: 'relative', - opacity: isDisabled ? 0.5 : 1, - padding: 16, - zIndex: 1, - } as ViewStyle, - style, - ) as ViewStyle, + base: { + flex: 1, + padding: 16, + } as ViewStyle, containerColumn: { flexDirection: 'column', alignItems: 'flex-start', @@ -40,13 +34,16 @@ const styleSheet = (params: { paddingTop: 0, paddingBottom: 0, paddingLeft: 0, - zIndex: 2, - }, - container: { - backgroundColor: colors.background.default, - flexDirection: 'row', - alignItems: 'center', }, + container: Object.assign( + { + backgroundColor: colors.background.default, + flexDirection: 'row', + alignItems: 'center', + opacity: isDisabled ? 0.5 : 1, + } as ViewStyle, + style, + ) as ViewStyle, buttonIcon: { paddingRight: 20, }, diff --git a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.test.tsx b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.test.tsx index 0f62af57d09..f975eb6dbd5 100644 --- a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.test.tsx +++ b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.test.tsx @@ -6,16 +6,28 @@ import { View } from 'react-native'; // Internal dependencies. import ListItemMultiSelectWithMenuButton from './ListItemMultiSelectWithMenuButton'; import { IconName } from '../../../component-library/components/Icons/Icon'; -import { BUTTON_TEST_ID } from './ListItemMultiSelectWithMenuButton.constants'; +import { + BUTTON_TEST_ID, + ROW_TEST_ID, +} from './ListItemMultiSelectWithMenuButton.constants'; describe('ListItemMultiSelectWithMenuButton', () => { it('should render correctly with default props', () => { - const { getByRole } = render( + const { getByTestId } = render( , ); - expect(getByRole('button')).toBeOnTheScreen(); + expect(getByTestId(ROW_TEST_ID)).toBeOnTheScreen(); + }); + + it('exposes accessibilityRole="button" on the row', () => { + const { getByTestId } = render( + + + , + ); + expect(getByTestId(ROW_TEST_ID).props.accessibilityRole).toBe('button'); }); it('should not render checkbox icon when isSelected is false', () => { @@ -30,7 +42,7 @@ describe('ListItemMultiSelectWithMenuButton', () => { it('should call onPress when the button is pressed', () => { const mockOnPress = jest.fn(); - const { getByRole } = render( + const { getByTestId } = render( { , ); - fireEvent.press(getByRole('button')); + fireEvent.press(getByTestId(ROW_TEST_ID)); expect(mockOnPress).toHaveBeenCalled(); }); @@ -81,14 +93,14 @@ describe('ListItemMultiSelectWithMenuButton', () => { it('should be disabled when isDisabled is true', () => { const mockOnPress = jest.fn(); - const { getByRole } = render( + const { getByTestId } = render( , ); // The component should render without error when disabled - expect(getByRole('button')).toBeTruthy(); + expect(getByTestId(ROW_TEST_ID)).toBeTruthy(); }); it('should not render button icon when showButtonIcon is false', () => { @@ -102,23 +114,23 @@ describe('ListItemMultiSelectWithMenuButton', () => { it('should call onPress on long press', () => { const mockOnPress = jest.fn(); - const { getByRole } = render( + const { getByTestId } = render( , ); // Test that the component renders with onLongPress prop set to onPress - expect(getByRole('button')).toBeTruthy(); + expect(getByTestId(ROW_TEST_ID)).toBeTruthy(); }); it('should render with custom gap', () => { - const { getByRole } = render( + const { getByTestId } = render( , ); - expect(getByRole('button')).toBeTruthy(); + expect(getByTestId(ROW_TEST_ID)).toBeTruthy(); }); it('should use custom button test ID when provided', () => { @@ -145,7 +157,7 @@ describe('ListItemMultiSelectWithMenuButton', () => { }); it('should handle button props with text button', () => { - const { getByRole } = render( + const { getByTestId } = render( { , ); - expect(getByRole('button')).toBeTruthy(); + expect(getByTestId(ROW_TEST_ID)).toBeTruthy(); }); it('should handle button props with showButtonIcon false', () => { diff --git a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.tsx b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.tsx index e72dea4700e..d607aa32750 100644 --- a/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.tsx +++ b/app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.tsx @@ -2,10 +2,11 @@ // Third party dependencies. import React from 'react'; -import { TouchableOpacity, View } from 'react-native'; +import { View } from 'react-native'; // External dependencies. import { useStyles } from '../../hooks'; +import Pressable from '../Pressable'; import ListItem from '../../../component-library/components/List/ListItem/ListItem'; import Checkbox from '../../components/Checkbox'; @@ -15,6 +16,7 @@ import { ListItemMultiSelectWithMenuButtonProps } from './ListItemMultiSelectWit import { BUTTON_TEST_ID, DEFAULT_LIST_ITEM_MULTISELECT_WITH_MENU_BUTTON_GAP, + ROW_TEST_ID, } from './ListItemMultiSelectWithMenuButton.constants'; import ButtonIcon from '../../../component-library/components/Buttons/ButtonIcon'; import { @@ -43,19 +45,20 @@ const ListItemMultiSelectWithMenuButton: React.FC< }); return ( - - + + {children} - + {showButtonIcon ? ( ) : null} - + ); }; diff --git a/app/component-library/components-temp/Pressable/Pressable.test.tsx b/app/component-library/components-temp/Pressable/Pressable.test.tsx index 1fdd51cc996..6cbd0517f68 100644 --- a/app/component-library/components-temp/Pressable/Pressable.test.tsx +++ b/app/component-library/components-temp/Pressable/Pressable.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { StyleSheet, Text } from 'react-native'; +import { StyleSheet, Text, type View } from 'react-native'; import { fireEvent, render } from '@testing-library/react-native'; import { mockTheme } from '../../../util/theme'; @@ -96,6 +96,18 @@ describe('Pressable', () => { expect(resting.padding).toBe(16); }); + it('forwards a ref to the underlying view', () => { + const ref = React.createRef(); + render( + + x + , + ); + + expect(ref.current).not.toBeNull(); + expect(typeof ref.current?.measure).toBe('function'); + }); + it('resolves a function-form caller style on render', () => { const styleFn = jest.fn(() => ({ borderWidth: 1 })); render( diff --git a/app/component-library/components-temp/Pressable/Pressable.tsx b/app/component-library/components-temp/Pressable/Pressable.tsx index 0d16d46ad37..b48008565ec 100644 --- a/app/component-library/components-temp/Pressable/Pressable.tsx +++ b/app/component-library/components-temp/Pressable/Pressable.tsx @@ -1,8 +1,9 @@ -import React, { useCallback } from 'react'; +import React, { forwardRef, useCallback } from 'react'; import { Pressable as RNPressable, type PressableStateCallbackType, type StyleProp, + type View, type ViewStyle, } from 'react-native'; @@ -18,31 +19,31 @@ import type { PressableProps } from './Pressable.types'; * `background.pressed` token on top of whatever resting surface the * parent owns. The component itself never sets a resting background. */ -const Pressable = ({ - style, - accessibilityRole = 'button', - children, - ...props -}: PressableProps) => { - const { colors } = useTheme(); +const Pressable = forwardRef( + ({ style, accessibilityRole = 'button', children, ...props }, ref) => { + const { colors } = useTheme(); - const composedStyle = useCallback( - (state: PressableStateCallbackType): StyleProp => [ - typeof style === 'function' ? style(state) : style, - state.pressed && { backgroundColor: colors.background.pressed }, - ], - [style, colors.background.pressed], - ); + const composedStyle = useCallback( + (state: PressableStateCallbackType): StyleProp => [ + typeof style === 'function' ? style(state) : style, + state.pressed && { backgroundColor: colors.background.pressed }, + ], + [style, colors.background.pressed], + ); - return ( - - {children} - - ); -}; + return ( + + {children} + + ); + }, +); + +Pressable.displayName = 'Pressable'; export default Pressable; diff --git a/app/component-library/components-temp/Pressable/PressableGH.test.tsx b/app/component-library/components-temp/Pressable/PressableGH.test.tsx index 5968454a2d7..b4e71315dd1 100644 --- a/app/component-library/components-temp/Pressable/PressableGH.test.tsx +++ b/app/component-library/components-temp/Pressable/PressableGH.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Text } from 'react-native'; +import { Text, type View } from 'react-native'; import { fireEvent, render } from '@testing-library/react-native'; jest.mock('react-native-gesture-handler', () => { @@ -63,4 +63,16 @@ describe('PressableGH', () => { expect(getByLabelText('Action')).toBeOnTheScreen(); }); + + it('forwards a ref to the underlying view', () => { + const ref = React.createRef(); + render( + + x + , + ); + + expect(ref.current).not.toBeNull(); + expect(typeof ref.current?.measure).toBe('function'); + }); }); diff --git a/app/component-library/components-temp/Pressable/PressableGH.tsx b/app/component-library/components-temp/Pressable/PressableGH.tsx index 71f30a15803..37479bed72c 100644 --- a/app/component-library/components-temp/Pressable/PressableGH.tsx +++ b/app/component-library/components-temp/Pressable/PressableGH.tsx @@ -1,5 +1,5 @@ -import React, { useCallback } from 'react'; -import type { StyleProp, ViewStyle } from 'react-native'; +import React, { forwardRef, useCallback } from 'react'; +import type { StyleProp, View, ViewStyle } from 'react-native'; import { Pressable as RNGHPressable, type PressableStateCallbackType, @@ -16,31 +16,31 @@ import type { PressableGHProps } from './Pressable.types'; * scroll/list tree. Mixing RN core `Pressable` with RNGH scroll views * causes swipe/scroll gesture conflicts on Android. */ -const PressableGH = ({ - style, - accessibilityRole = 'button', - children, - ...props -}: PressableGHProps) => { - const { colors } = useTheme(); +const PressableGH = forwardRef( + ({ style, accessibilityRole = 'button', children, ...props }, ref) => { + const { colors } = useTheme(); - const composedStyle = useCallback( - (state: PressableStateCallbackType): StyleProp => [ - typeof style === 'function' ? style(state) : style, - state.pressed && { backgroundColor: colors.background.pressed }, - ], - [style, colors.background.pressed], - ); + const composedStyle = useCallback( + (state: PressableStateCallbackType): StyleProp => [ + typeof style === 'function' ? style(state) : style, + state.pressed && { backgroundColor: colors.background.pressed }, + ], + [style, colors.background.pressed], + ); - return ( - - {children} - - ); -}; + return ( + + {children} + + ); + }, +); + +PressableGH.displayName = 'PressableGH'; export default PressableGH; diff --git a/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.styles.ts b/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.styles.ts index 9c584ad4163..adf60f67b1a 100644 --- a/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.styles.ts +++ b/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.styles.ts @@ -27,7 +27,6 @@ const styleSheet = (params: { { padding: 16, borderRadius: 4, - backgroundColor: colors.background.default, opacity: isDisabled ? 0.5 : 1, } as ViewStyle, style, diff --git a/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.test.tsx b/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.test.tsx index ad83ea5bea4..b907dcbe6d5 100644 --- a/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.test.tsx +++ b/app/component-library/components/List/ListItemMultiSelect/ListItemMultiSelect.test.tsx @@ -22,6 +22,18 @@ describe('ListItemMultiSelect', () => { expect(getByTestId('test-content')).toBeOnTheScreen(); }); + it('exposes accessibilityRole="button" on the row', () => { + const { getByTestId } = render( + null} testID="list-item-multi-select"> + + , + ); + + expect(getByTestId('list-item-multi-select').props.accessibilityRole).toBe( + 'button', + ); + }); + it('renders when disabled', () => { const { getByTestId } = render( = ({ const { styles } = useStyles(styleSheet, { style, gap, isDisabled }); return ( - = ({ {isSelected && ( )} - + ); }; diff --git a/app/component-library/components/List/ListItemSelect/ListItemSelect.styles.ts b/app/component-library/components/List/ListItemSelect/ListItemSelect.styles.ts index cf5bef216bc..fd08dcd55ec 100644 --- a/app/component-library/components/List/ListItemSelect/ListItemSelect.styles.ts +++ b/app/component-library/components/List/ListItemSelect/ListItemSelect.styles.ts @@ -28,7 +28,6 @@ const styleSheet = (params: { position: 'relative', opacity: isDisabled ? 0.5 : 1, borderRadius: 4, - backgroundColor: colors.background.default, } as ViewStyle, style, ) as ViewStyle, diff --git a/app/component-library/components/List/ListItemSelect/ListItemSelect.test.tsx b/app/component-library/components/List/ListItemSelect/ListItemSelect.test.tsx index 698586de61e..020c4693d25 100644 --- a/app/component-library/components/List/ListItemSelect/ListItemSelect.test.tsx +++ b/app/component-library/components/List/ListItemSelect/ListItemSelect.test.tsx @@ -23,6 +23,18 @@ describe('ListItemSelect', () => { expect(getByTestId('test-content')).toBeOnTheScreen(); }); + it('exposes accessibilityRole="button" on the row', () => { + const { getByTestId } = render( + null} testID="list-item-select"> + + , + ); + + expect(getByTestId('list-item-select').props.accessibilityRole).toBe( + 'button', + ); + }); + it('renders when disabled', () => { const { getByTestId } = render( null} isDisabled testID="list-item-select"> diff --git a/app/component-library/components/List/ListItemSelect/ListItemSelect.tsx b/app/component-library/components/List/ListItemSelect/ListItemSelect.tsx index 88a15673442..3d0699eb1b4 100644 --- a/app/component-library/components/List/ListItemSelect/ListItemSelect.tsx +++ b/app/component-library/components/List/ListItemSelect/ListItemSelect.tsx @@ -2,10 +2,11 @@ // Third party dependencies. import React from 'react'; -import { TouchableOpacity, View } from 'react-native'; +import { View } from 'react-native'; // External dependencies. import { useStyles } from '../../../hooks'; +import Pressable from '../../../components-temp/Pressable'; import ListItem from '../../List/ListItem/ListItem'; // Internal dependencies. @@ -28,7 +29,7 @@ const ListItemSelect: React.FC = ({ const { styles } = useStyles(styleSheet, { style, isDisabled }); return ( - = ({ {isSelected && ( )} - + ); }; diff --git a/app/component-library/components/Pickers/PickerAccount/PickerAccount.stories.tsx b/app/component-library/components/Pickers/PickerAccount/PickerAccount.stories.tsx index 992a2ae1d90..51f6f26885f 100644 --- a/app/component-library/components/Pickers/PickerAccount/PickerAccount.stories.tsx +++ b/app/component-library/components/Pickers/PickerAccount/PickerAccount.stories.tsx @@ -5,7 +5,7 @@ import React from 'react'; // Internal dependencies. import { default as PickerAccountComponent } from './PickerAccount'; import { SAMPLE_PICKERACCOUNT_PROPS } from './PickerAccount.constants'; -import { TouchableOpacityProps, View } from 'react-native'; +import { View } from 'react-native'; import { PickerAccountProps } from './PickerAccount.types'; const PickerAccountMeta = { @@ -26,10 +26,6 @@ export const PickerAccount = { render: ( args: React.JSX.IntrinsicAttributes & PickerAccountProps & - React.RefAttributes< - React.ForwardRefExoticComponent< - TouchableOpacityProps & React.RefAttributes - > - >, + React.RefAttributes, ) => , }; diff --git a/app/component-library/components/Pickers/PickerAccount/PickerAccount.styles.ts b/app/component-library/components/Pickers/PickerAccount/PickerAccount.styles.ts index c2ac07ecd55..e7eb07f4f52 100644 --- a/app/component-library/components/Pickers/PickerAccount/PickerAccount.styles.ts +++ b/app/component-library/components/Pickers/PickerAccount/PickerAccount.styles.ts @@ -28,13 +28,6 @@ const styleSheet = (params: { flexDirection: 'row', borderWidth: 0, }, - basePressed: { - ...(style as ViewStyle), - flexDirection: 'row', - borderWidth: 0, - borderRadius: 2, - backgroundColor: colors.background.pressed, - }, accountAddressLabel: { color: colors.text.alternative, textAlign: 'center', diff --git a/app/component-library/components/Pickers/PickerAccount/PickerAccount.test.tsx b/app/component-library/components/Pickers/PickerAccount/PickerAccount.test.tsx index acb520b5f74..b1e09172831 100644 --- a/app/component-library/components/Pickers/PickerAccount/PickerAccount.test.tsx +++ b/app/component-library/components/Pickers/PickerAccount/PickerAccount.test.tsx @@ -1,5 +1,6 @@ // Third party dependencies. import React from 'react'; +import type { View } from 'react-native'; import { render, fireEvent } from '@testing-library/react-native'; // Internal dependencies. @@ -165,16 +166,12 @@ describe('PickerAccount', () => { }); describe('Ref Forwarding', () => { - it('forwards ref correctly', () => { - const TestRefComponent = () => { - const ref = React.useRef(null); - return ; - }; + it('exposes the underlying view via the forwarded ref', () => { + const ref = React.createRef(); + render(); - // Verify component renders without throwing when ref is provided - expect(() => { - render(); - }).not.toThrow(); + expect(ref.current).not.toBeNull(); + expect(typeof ref.current?.measure).toBe('function'); }); }); diff --git a/app/component-library/components/Pickers/PickerAccount/PickerAccount.tsx b/app/component-library/components/Pickers/PickerAccount/PickerAccount.tsx index ac3928f2f04..02aa734b256 100644 --- a/app/component-library/components/Pickers/PickerAccount/PickerAccount.tsx +++ b/app/component-library/components/Pickers/PickerAccount/PickerAccount.tsx @@ -1,8 +1,8 @@ /* eslint-disable react/prop-types */ // Third party dependencies. -import React, { forwardRef, useState, useCallback } from 'react'; -import { TouchableOpacity, GestureResponderEvent } from 'react-native'; +import React, { forwardRef } from 'react'; +import type { View } from 'react-native'; // External dependencies. import DSText, { TextVariant } from '../../Texts/Text'; @@ -14,55 +14,30 @@ import { PickerAccountProps } from './PickerAccount.types'; import styleSheet from './PickerAccount.styles'; import { WalletViewSelectorsIDs } from '../../../../components/Views/Wallet/WalletView.testIds'; -const PickerAccount: React.ForwardRefRenderFunction< - typeof TouchableOpacity, - PickerAccountProps -> = ( - { style, accountName, hitSlop, onPress, onPressIn, onPressOut, ...props }, - _ref: React.Ref, -) => { - const [pressed, setPressed] = useState(false); - - const { styles } = useStyles(styleSheet, { - style, - pressed, - }); - - const triggerOnPressedIn = useCallback( - (e: GestureResponderEvent) => { - setPressed(true); - onPressIn?.(e); - }, - [setPressed, onPressIn], - ); - - const triggerOnPressedOut = useCallback( - (e: GestureResponderEvent) => { - setPressed(false); - onPressOut?.(e); - }, - [setPressed, onPressOut], - ); - - return ( - - ( + ({ style, accountName, hitSlop, onPress, ...props }, ref) => { + const { styles } = useStyles(styleSheet, { style }); + + return ( + - {accountName} - - - ); -}; - -export default forwardRef(PickerAccount); + + {accountName} + + + ); + }, +); + +PickerAccount.displayName = 'PickerAccount'; + +export default PickerAccount; diff --git a/app/component-library/components/Pickers/PickerAccount/PickerAccount.types.ts b/app/component-library/components/Pickers/PickerAccount/PickerAccount.types.ts index 58663e4e8eb..cf3951c7e53 100644 --- a/app/component-library/components/Pickers/PickerAccount/PickerAccount.types.ts +++ b/app/component-library/components/Pickers/PickerAccount/PickerAccount.types.ts @@ -14,6 +14,4 @@ export interface PickerAccountProps extends Omit { /** * Style sheet input parameters. */ -export type PickerAccountStyleSheetVars = Pick & { - pressed: boolean; -}; +export type PickerAccountStyleSheetVars = Pick; diff --git a/app/component-library/components/Pickers/PickerBase/PickerBase.tsx b/app/component-library/components/Pickers/PickerBase/PickerBase.tsx index 83b6d219779..04dbbceaa02 100644 --- a/app/component-library/components/Pickers/PickerBase/PickerBase.tsx +++ b/app/component-library/components/Pickers/PickerBase/PickerBase.tsx @@ -2,39 +2,42 @@ // Third party dependencies. import React, { forwardRef } from 'react'; -import { TouchableOpacity, View } from 'react-native'; +import type { View } from 'react-native'; // External dependencies. import { useStyles } from '../../../hooks'; +import Pressable from '../../../components-temp/Pressable'; import Icon, { IconName, IconSize } from '../../Icons/Icon'; // Internal dependencies. import { PickerBaseProps } from './PickerBase.types'; import styleSheet from './PickerBase.styles'; -const PickerBase: React.ForwardRefRenderFunction = ( - { iconSize = IconSize.Md, style, dropdownIconStyle, children, ...props }, - ref, -) => { - const { styles, theme } = useStyles(styleSheet, { style, dropdownIconStyle }); - const { colors } = theme; +const PickerBase = forwardRef( + ( + { iconSize = IconSize.Md, style, dropdownIconStyle, children, ...props }, + ref, + ) => { + const { styles, theme } = useStyles(styleSheet, { + style, + dropdownIconStyle, + }); + const { colors } = theme; - return ( - - {children} - - - ); -}; + return ( + + {children} + + + ); + }, +); -export default forwardRef(PickerBase); +PickerBase.displayName = 'PickerBase'; + +export default PickerBase; diff --git a/app/component-library/components/Pickers/PickerBase/PickerBase.types.ts b/app/component-library/components/Pickers/PickerBase/PickerBase.types.ts index 65abdf96047..4eb5f1e5bb7 100644 --- a/app/component-library/components/Pickers/PickerBase/PickerBase.types.ts +++ b/app/component-library/components/Pickers/PickerBase/PickerBase.types.ts @@ -1,11 +1,12 @@ // Third party dependencies. -import { TouchableOpacityProps, ViewStyle } from 'react-native'; +import { ViewStyle } from 'react-native'; import { IconSize } from '../../Icons/Icon'; +import { PressableProps } from '../../../components-temp/Pressable'; /** * PickerBase component props. */ -export interface PickerBaseProps extends TouchableOpacityProps { +export interface PickerBaseProps extends PressableProps { /** * Callback to trigger when pressed. */