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**
## **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)
---------
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')}
-
-
- refetch()}
- testID={CardHomeSelectors.TRY_AGAIN_BUTTON}
+
+
+
+ {strings('card.card_home.error_title')}
+
+
- {strings('card.card_home.try_again')}
-
+ {strings('card.card_home.error_description')}
+
+
+ refetch()}
+ testID={CardHomeSelectors.TRY_AGAIN_BUTTON}
+ >
+ {strings('card.card_home.try_again')}
+
+
);
@@ -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"
>
-
+
-
{selectedTokens.map((token) => {
const tokenKey = getTokenKey(token);
+ const assetId = formatAddressToAssetId(
+ token.address,
+ token.chainId,
+ );
+ const tokenQuoteData = assetId
+ ? batchSellQuoteData.tokenData[assetId]
+ : undefined;
+ const priceImpact = tokenQuoteData?.priceImpact;
return (
handleOpenHighPriceImpactInfo(priceImpact)
+ : undefined
+ }
onPercentChange={handlePercentChange}
onSlippagePress={handleSlippagePress}
onRemovePress={handleRemoveToken}
@@ -357,11 +446,22 @@ export function BatchSellReview() {
variant={ButtonVariant.Primary}
size={ButtonSize.Lg}
isFullWidth
- isDisabled={!HAS_QUOTES}
- onPress={handleOpenFinalReview}
+ isDisabled={
+ batchSellQuoteData.needsNewQuote
+ ? false
+ : !batchSellQuoteData.hasAnyQuote ||
+ batchSellQuoteData.hasPendingQuoteRows
+ }
+ onPress={
+ batchSellQuoteData.needsNewQuote
+ ? handleGetNewQuote
+ : handleOpenFinalReview
+ }
testID={BatchSellReviewSelectorsIDs.REVIEW_BUTTON}
>
- {strings('bridge.batch_sell_review')}
+ {batchSellQuoteData.needsNewQuote
+ ? strings('quote_expired_modal.get_new_quote')
+ : strings('bridge.batch_sell_review')}
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.
*/