Skip to content

Commit f25b4ba

Browse files
authored
feat: allow add account in swap flow (MetaMask#22718)
## **Description** This PR allows users to add an account to their rewards program, logic is like this: - GIVEN I have an active subscription but the current address is not opted in - AND the current address is eligible to be added (not a Bitcoin / Tron / hardware wallet / Snap account) - I can add the current address in one click / tap and then see the estimated points then users will see the rewards row but with a cta that allows them to add the active account address to the active subscription. ## **Changelog** CHANGELOG entry: allow add account in swap flow ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/RWDS-799 ## **Screenshots/Recordings** ### **After** <img width="461" height="45" alt="Screenshot-79" src="https://github.com/user-attachments/assets/973c8bb9-e2da-4aca-a880-405a04fed5d1" /> --- <img width="476" height="100" alt="Screenshot-82" src="https://github.com/user-attachments/assets/413aa101-6514-4d5e-b2fb-348dfcf7afd6" /> ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds a Rewards “Add account” CTA to the Bridge quote details, introduces a linking hook/component, updates rewards logic, and adds comprehensive tests, assets, and strings. > > - **Bridge | UI**: > - `QuoteDetailsCard`: Shows Rewards row; when not opted-in displays `AddRewardsAccount` CTA, otherwise renders `RewardPointsAnimation`. Adds `bridge-rewards-row` test ID and uses fade-in image mock. > - **Rewards**: > - New `useLinkAccountAddress` hook (metrics, toasts) and `AddRewardsAccount` component to link current account to Rewards. > - `useRewards`: gates by `getFirstSubscriptionId`, exposes `accountOptedIn`, shows row if opt-in is supported, subscribes to `RewardsController:accountLinked`, refines deps. > - **Tests**: > - New/expanded tests for `QuoteDetailsCard`, `useRewards`, `AddRewardsAccount`, and `useLinkAccountAddress`; updates BridgeView tests/mocks (adds `controllerMessenger.subscribe/unsubscribe`, image mocks). > - **Assets/Locales**: > - Adds rewards points SVG icon and `rewards.link_account_group.link_account_address_error` string. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bfa1421. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 5c22aa6 commit f25b4ba

12 files changed

Lines changed: 1801 additions & 115 deletions

File tree

app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ jest.mock('../../../../../core/Engine', () => {
8989
'../../../../../util/test/keyringControllerTestUtils',
9090
);
9191
return {
92+
controllerMessenger: {
93+
call: jest.fn(),
94+
subscribe: jest.fn(),
95+
unsubscribe: jest.fn(),
96+
},
9297
context: {
9398
SwapsController: {
9499
fetchAggregatorMetadataWithCache: jest.fn(),
@@ -272,6 +277,21 @@ jest.mock('../../../../../util/address', () => ({
272277
isHardwareAccount: jest.fn(),
273278
}));
274279

280+
jest.mock('react-native-fade-in-image', () => {
281+
const React = jest.requireActual('react');
282+
const { View } = jest.requireActual('react-native');
283+
return {
284+
__esModule: true,
285+
default: ({
286+
children,
287+
placeholderStyle,
288+
}: {
289+
children: React.ReactNode;
290+
placeholderStyle?: unknown;
291+
}) => React.createElement(View, { style: placeholderStyle }, children),
292+
};
293+
});
294+
275295
describe('BridgeView', () => {
276296
const token2Address = '0x0000000000000000000000000000000000000002' as Hex;
277297

app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap

Lines changed: 2 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -476,9 +476,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1`
476476
testID="badge-wrapper-badge"
477477
>
478478
<View>
479-
<View
480-
useNativeDriver={true}
481-
>
479+
<View>
482480
<View
483481
fadeIn={true}
484482
source={
@@ -504,35 +502,6 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1`
504502
]
505503
}
506504
/>
507-
<View
508-
collapsable={false}
509-
style={
510-
{
511-
"bottom": 0,
512-
"left": 0,
513-
"opacity": 1,
514-
"position": "absolute",
515-
"right": 0,
516-
"top": 0,
517-
}
518-
}
519-
>
520-
<View
521-
style={
522-
[
523-
{
524-
"borderRadius": 16,
525-
"height": 32,
526-
"width": 32,
527-
},
528-
{
529-
"backgroundColor": "#eee",
530-
},
531-
undefined,
532-
]
533-
}
534-
/>
535-
</View>
536505
</View>
537506
</View>
538507
<View
@@ -2138,9 +2107,7 @@ exports[`BridgeView renders 1`] = `
21382107
testID="badge-wrapper-badge"
21392108
>
21402109
<View>
2141-
<View
2142-
useNativeDriver={true}
2143-
>
2110+
<View>
21442111
<View
21452112
fadeIn={true}
21462113
source={
@@ -2166,35 +2133,6 @@ exports[`BridgeView renders 1`] = `
21662133
]
21672134
}
21682135
/>
2169-
<View
2170-
collapsable={false}
2171-
style={
2172-
{
2173-
"bottom": 0,
2174-
"left": 0,
2175-
"opacity": 1,
2176-
"position": "absolute",
2177-
"right": 0,
2178-
"top": 0,
2179-
}
2180-
}
2181-
>
2182-
<View
2183-
style={
2184-
[
2185-
{
2186-
"borderRadius": 16,
2187-
"height": 32,
2188-
"width": 32,
2189-
},
2190-
{
2191-
"backgroundColor": "#eee",
2192-
},
2193-
undefined,
2194-
]
2195-
}
2196-
/>
2197-
</View>
21982136
</View>
21992137
</View>
22002138
<View

app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@ jest.mock('rive-react-native', () => {
2727
};
2828
});
2929

30+
jest.mock('react-native-fade-in-image', () => {
31+
const React = jest.requireActual('react');
32+
const { View } = jest.requireActual('react-native');
33+
return {
34+
__esModule: true,
35+
default: ({
36+
children,
37+
placeholderStyle,
38+
}: {
39+
children: React.ReactNode;
40+
placeholderStyle?: unknown;
41+
}) => React.createElement(View, { style: placeholderStyle }, children),
42+
};
43+
});
44+
3045
const mockNavigate = jest.fn();
3146
jest.mock('@react-navigation/native', () => ({
3247
...jest.requireActual('@react-navigation/native'),
@@ -67,9 +82,33 @@ jest.mock('../../hooks/useBridgeQuoteData', () => ({
6782
jest.mock('../../../../../core/Engine', () => ({
6883
controllerMessenger: {
6984
call: jest.fn(),
85+
subscribe: jest.fn(),
86+
unsubscribe: jest.fn(),
7087
},
7188
}));
7289

90+
// Mock formatChainIdToCaip for AddRewardsAccount component
91+
jest.mock('@metamask/bridge-controller', () => ({
92+
...jest.requireActual('@metamask/bridge-controller'),
93+
formatChainIdToCaip: jest.fn((chainId: string) => {
94+
// If already in CAIP format, return as-is
95+
if (chainId.includes(':')) {
96+
return chainId as `${string}:${string}`;
97+
}
98+
// Otherwise, convert to CAIP format
99+
return `eip155:${chainId}` as `${string}:${string}`;
100+
}),
101+
}));
102+
103+
// Mock useLinkAccountAddress for AddRewardsAccount component
104+
jest.mock('../../../../UI/Rewards/hooks/useLinkAccountAddress', () => ({
105+
useLinkAccountAddress: jest.fn(() => ({
106+
linkAccountAddress: jest.fn(),
107+
isLoading: false,
108+
isError: false,
109+
})),
110+
}));
111+
73112
// Mock the bridge selectors
74113
jest.mock('../../../../../core/redux/slices/bridge', () => ({
75114
...jest.requireActual('../../../../../core/redux/slices/bridge'),
@@ -486,6 +525,9 @@ describe('QuoteDetailsCard', () => {
486525
if (method === 'RewardsController:isRewardsFeatureEnabled') {
487526
return Promise.resolve(true);
488527
}
528+
if (method === 'RewardsController:getFirstSubscriptionId') {
529+
return Promise.resolve('subscription-id-1');
530+
}
489531
if (method === 'RewardsController:getHasAccountOptedIn') {
490532
return Promise.resolve(true);
491533
}
@@ -516,6 +558,9 @@ describe('QuoteDetailsCard', () => {
516558
if (method === 'RewardsController:isRewardsFeatureEnabled') {
517559
return Promise.resolve(true);
518560
}
561+
if (method === 'RewardsController:getFirstSubscriptionId') {
562+
return Promise.resolve('subscription-id-1');
563+
}
519564
if (method === 'RewardsController:getHasAccountOptedIn') {
520565
return Promise.resolve(true);
521566
}
@@ -568,30 +613,42 @@ describe('QuoteDetailsCard', () => {
568613
});
569614
});
570615

571-
it('does not display rewards row when user has not opted in', async () => {
616+
it('displays AddRewardsAccount when user has not opted in', async () => {
572617
// Given rewards feature is enabled but user has not opted in
573618
mockEngine.controllerMessenger.call.mockImplementation(
574619
(method: string) => {
575620
if (method === 'RewardsController:isRewardsFeatureEnabled') {
576621
return Promise.resolve(true);
577622
}
623+
if (method === 'RewardsController:getFirstSubscriptionId') {
624+
return Promise.resolve('subscription-id-1');
625+
}
578626
if (method === 'RewardsController:getHasAccountOptedIn') {
579627
return Promise.resolve(false);
580628
}
629+
if (method === 'RewardsController:isOptInSupported') {
630+
return Promise.resolve(true);
631+
}
581632
return Promise.resolve(null);
582633
},
583634
);
584635

585636
// When rendering the component
586-
const { queryByText } = renderScreen(
637+
const { getByText, getByTestId, queryByTestId } = renderScreen(
587638
QuoteDetailsCard,
588639
{ name: Routes.BRIDGE.ROOT },
589640
{ state: testState },
590641
);
591642

592-
// Then the rewards row should not be displayed
643+
// Then the rewards row should be displayed
593644
await waitFor(() => {
594-
expect(queryByText(strings('bridge.points'))).toBeNull();
645+
expect(getByText(strings('bridge.points'))).toBeOnTheScreen();
646+
});
647+
648+
// And AddRewardsAccount should be shown instead of RewardsAnimations
649+
await waitFor(() => {
650+
expect(getByTestId('bridge-add-rewards-account')).toBeOnTheScreen();
651+
expect(queryByTestId('mock-rive-animation')).toBeNull();
595652
});
596653
});
597654

@@ -602,6 +659,9 @@ describe('QuoteDetailsCard', () => {
602659
if (method === 'RewardsController:isRewardsFeatureEnabled') {
603660
return Promise.resolve(true);
604661
}
662+
if (method === 'RewardsController:getFirstSubscriptionId') {
663+
return Promise.resolve('subscription-id-1');
664+
}
605665
if (method === 'RewardsController:getHasAccountOptedIn') {
606666
return Promise.resolve(true);
607667
}
@@ -632,6 +692,9 @@ describe('QuoteDetailsCard', () => {
632692
if (method === 'RewardsController:isRewardsFeatureEnabled') {
633693
return Promise.resolve(true);
634694
}
695+
if (method === 'RewardsController:getFirstSubscriptionId') {
696+
return Promise.resolve('subscription-id-1');
697+
}
635698
if (method === 'RewardsController:getHasAccountOptedIn') {
636699
return Promise.resolve(true);
637700
}
@@ -668,6 +731,9 @@ describe('QuoteDetailsCard', () => {
668731
if (method === 'RewardsController:isRewardsFeatureEnabled') {
669732
return Promise.resolve(true);
670733
}
734+
if (method === 'RewardsController:getFirstSubscriptionId') {
735+
return Promise.resolve('subscription-id-1');
736+
}
671737
if (method === 'RewardsController:getHasAccountOptedIn') {
672738
return Promise.resolve(true);
673739
}
@@ -702,6 +768,9 @@ describe('QuoteDetailsCard', () => {
702768
if (method === 'RewardsController:isRewardsFeatureEnabled') {
703769
return Promise.resolve(true);
704770
}
771+
if (method === 'RewardsController:getFirstSubscriptionId') {
772+
return Promise.resolve('subscription-id-1');
773+
}
705774
if (method === 'RewardsController:getHasAccountOptedIn') {
706775
return Promise.resolve(true);
707776
}
@@ -733,6 +802,9 @@ describe('QuoteDetailsCard', () => {
733802
if (method === 'RewardsController:isRewardsFeatureEnabled') {
734803
return Promise.resolve(true);
735804
}
805+
if (method === 'RewardsController:getFirstSubscriptionId') {
806+
return Promise.resolve('subscription-id-1');
807+
}
736808
if (method === 'RewardsController:getHasAccountOptedIn') {
737809
return Promise.resolve(true);
738810
}
@@ -764,6 +836,9 @@ describe('QuoteDetailsCard', () => {
764836
if (method === 'RewardsController:isRewardsFeatureEnabled') {
765837
return Promise.resolve(true);
766838
}
839+
if (method === 'RewardsController:getFirstSubscriptionId') {
840+
return Promise.resolve('subscription-id-1');
841+
}
767842
if (method === 'RewardsController:getHasAccountOptedIn') {
768843
return Promise.resolve(true);
769844
}
@@ -812,6 +887,9 @@ describe('QuoteDetailsCard', () => {
812887
if (method === 'RewardsController:isRewardsFeatureEnabled') {
813888
return Promise.resolve(true);
814889
}
890+
if (method === 'RewardsController:getFirstSubscriptionId') {
891+
return Promise.resolve('subscription-id-1');
892+
}
815893
if (method === 'RewardsController:getHasAccountOptedIn') {
816894
return Promise.resolve(true);
817895
}
@@ -826,14 +904,15 @@ describe('QuoteDetailsCard', () => {
826904
);
827905

828906
// When rendering the component
829-
const { getByText } = renderScreen(
907+
const { getByText, getByTestId } = renderScreen(
830908
QuoteDetailsCard,
831909
{ name: Routes.BRIDGE.ROOT },
832910
{ state: testState },
833911
);
834912

835913
// Rewards row should be shown
836914
await waitFor(() => {
915+
expect(getByTestId('bridge-rewards-row')).toBeOnTheScreen();
837916
expect(getByText(strings('bridge.points'))).toBeOnTheScreen();
838917
});
839918

0 commit comments

Comments
 (0)