Skip to content

Commit 47741a3

Browse files
PatrykLuckametamaskbotwachunei
authored
feat: add CashTokensFullView and integrate into MainNavigator (MetaMask#27123)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** 1. **What is the reason for the change?** The redesigned homepage needs a dedicated **Cash** section as the first section, surfacing mUSD (MetaMask USD) and guiding users to convert stablecoins and claim bonuses. 2. **What is the improvement/solution?** - **Cash section (homepage):** When mUSD conversion is enabled and user is geo-eligible, the first section is **"mUSD"**. It shows aggregated mUSD balance (Linea + Ethereum), annualized bonus copy (e.g. "Get 3% annualized bonus…" with the percentage in green), and a "Claim bonus" CTA on the row when a bonus is claimable. Tapping the section header navigates to the Cash token list. When the user has no mUSD, the section shows a **Get mUSD** empty state: the same annualized copy, a tappable mUSD token row that navigates to Token Details (Mainnet mUSD, same destination as trending tokens), and a "Get mUSD" button that routes to the Buy flow (when mUSD is buyable) or the Convert flow (when the user has convertible stablecoins e.g. USDC). - **Cash token list screen:** New full-view screen (`CashTokensFullView`) that shows only mUSD positions across supported networks (Ethereum Mainnet, Linea). When the user has no mUSD, the screen renders the same Get mUSD empty state (handled by `CashTokensFullView` via `useMusdBalance`); when the user has mUSD, it renders `Tokens` with `showOnlyMusd`. Same network filter as the main token list; no add-token or sort. No mUSD-specific empty-state logic inside `Tokens`. - **mUSD isolation:** mUSD is removed from the main Tokens section and from the generic full token list; it appears only in the Cash section and Cash full view. - **Implementation details:** `Tokens` supports a `showOnlyMusd` prop (filter list to mUSD, hide add/sort in control bar); `TokenListControlBar` supports `showAddToken` and `hideSort`. Empty state component `CashGetMusdEmptyState` is used on the homepage (in `CashSection`) and in `CashTokensFullView`; token row uses `NavigationService` to navigate to Token Details. New route `CASH_TOKENS_FULL_VIEW` and screen registration. Uses design-system components. Cash section does not expose a refresh ref (no-op removed). ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Added a Cash section on the homepage that shows aggregated mUSD balance, annualized bonus copy for stablecoin holders, and a dedicated Cash token list view with network filter. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-527 ## **Manual testing steps** ```gherkin Feature: Homepage Cash section and Cash token list Scenario: User with mUSD conversion enabled and geo-eligible sees Cash section first Given the user has mUSD conversion feature enabled and is in an eligible region When the user opens the redesigned homepage Then the Cash section appears as the first section with title "Cash" And if the user has convertible stablecoins, the annualized bonus copy is shown (e.g. "Get 3% annualized bonus...") And if the user has mUSD balance, the aggregated mUSD row is shown with balance and optional "Claim bonus" Scenario: User navigates to Cash token list from section header Given the user is on the homepage with Cash section visible When the user taps the Cash section header (or the ">" affordance) Then the app navigates to the Cash token list screen And the screen shows only mUSD positions (per network) or the cash empty state when none And the network filter is visible and works; add-token and sort buttons are not shown Scenario: User with network filter applied sees mUSD when opening Cash list Given the user has a network filter applied (e.g. single network) and has mUSD on that network When the user opens the Cash token list from the homepage Then mUSD positions for the enabled network(s) are shown And the user can change the network filter from the control bar ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** https://github.com/user-attachments/assets/ed532349-cfcc-4495-9b38-6e97fbaec30f <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds new navigation routes and conditional token filtering (including excluding mUSD from main lists) gated by feature flags/geo eligibility, which could affect token visibility and analytics. Also changes fiat formatting to use `currencyDisplay: 'narrowSymbol'`, which may alter currency rendering across the app. > > **Overview** > Introduces a new **Cash (mUSD)** surface: a `CashSection` is added as the first homepage section when mUSD conversion is enabled and geo-eligible, showing either an aggregated mUSD row (with optional *Claim bonus*) or a *Get mUSD* empty state that deep-links to mUSD details and routes users into buy/convert flows with new `home_cash_section` analytics location. > > Adds a dedicated `CashTokensFullView` route/screen and extends `Tokens`/`TokenListControlBar` to support an mUSD-only list (`showOnlyMusd`) that hides add-token/sort and uses cash-specific empty state messaging; when the Cash section is rendered, mUSD is filtered out of the main tokens lists (including popular tokens) to avoid duplication. > > Updates mUSD-related token list items to show a non-clickable green `"3% bonus"` label for mUSD when no claimable reward exists (only when conversion + geo eligibility are true), expands/adjusts unit tests and snapshots accordingly, and tweaks `formatFiat` to prefer `Intl` narrow currency symbols. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cc3ad61. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: metamaskbot <metamaskbot@users.noreply.github.com> Co-authored-by: Pedro Pablo Aste Kompen <wachunei@gmail.com>
1 parent 0a3bbfa commit 47741a3

35 files changed

Lines changed: 1948 additions & 72 deletions

app/components/Nav/Main/MainNavigator.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import AddAsset from '../../Views/AddAsset/AddAsset';
3131
import NftFullView from '../../Views/NftFullView';
3232
import TokensFullView from '../../Views/TokensFullView';
3333
import DeFiFullView from '../../Views/DeFiFullView';
34+
import CashTokensFullView from '../../Views/CashTokensFullView';
3435
import TrendingTokensFullView from '../../UI/Trending/Views/TrendingTokensFullView/TrendingTokensFullView';
3536
import RWATokensFullView from '../../UI/Trending/Views/RWATokensFullView/RWATokensFullView';
3637
import { RevealPrivateCredential } from '../../Views/RevealPrivateCredential';
@@ -995,6 +996,11 @@ const MainNavigator = () => {
995996
component={DeFiFullView}
996997
options={{ headerShown: false, ...slideFromRightAnimation }}
997998
/>
999+
<Stack.Screen
1000+
name={Routes.WALLET.CASH_TOKENS_FULL_VIEW}
1001+
component={CashTokensFullView}
1002+
options={{ headerShown: false, ...slideFromRightAnimation }}
1003+
/>
9981004
<Stack.Screen name="AddAsset" component={AddAsset} />
9991005
<Stack.Screen
10001006
name="ConfirmAddAsset"

app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@ exports[`MainNavigator Tab Bar Visibility hides tab bar when browser is active 1
6060
}
6161
}
6262
/>
63+
<Screen
64+
component={[Function]}
65+
name="CashTokensFullView"
66+
options={
67+
{
68+
"animationEnabled": true,
69+
"cardStyleInterpolator": [Function],
70+
"headerShown": false,
71+
}
72+
}
73+
/>
6374
<Screen
6475
component={[Function]}
6576
name="AddAsset"
@@ -454,6 +465,17 @@ exports[`MainNavigator Tab Bar Visibility shows tab bar when not in browser 1`]
454465
}
455466
}
456467
/>
468+
<Screen
469+
component={[Function]}
470+
name="CashTokensFullView"
471+
options={
472+
{
473+
"animationEnabled": true,
474+
"cardStyleInterpolator": [Function],
475+
"headerShown": false,
476+
}
477+
}
478+
/>
457479
<Screen
458480
component={[Function]}
459481
name="AddAsset"
@@ -848,6 +870,17 @@ exports[`MainNavigator matches rendered snapshot 1`] = `
848870
}
849871
}
850872
/>
873+
<Screen
874+
component={[Function]}
875+
name="CashTokensFullView"
876+
options={
877+
{
878+
"animationEnabled": true,
879+
"cardStyleInterpolator": [Function],
880+
"headerShown": false,
881+
}
882+
}
883+
/>
851884
<Screen
852885
component={[Function]}
853886
name="AddAsset"

app/components/UI/Earn/constants/events/musdEvents.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ const EVENT_PROVIDERS = {
44

55
const EVENT_LOCATIONS = {
66
HOME_SCREEN: 'home',
7+
/** Cash section on homepage (aggregated mUSD row or empty state "Get mUSD") */
8+
HOME_CASH_SECTION: 'home_cash_section',
79
TOKEN_LIST_ITEM: 'token_list_item',
810
ASSET_OVERVIEW: 'asset_overview',
911
CONVERSION_EDUCATION_SCREEN: 'conversion_education_screen',

app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ import {
2424
selectMusdQuickConvertEnabledFlag,
2525
selectStablecoinLendingEnabledFlag,
2626
} from '../../../Earn/selectors/featureFlags';
27-
import { MUSD_CONVERSION_APY } from '../../../Earn/constants/musd';
27+
import {
28+
MUSD_CONVERSION_APY,
29+
MUSD_TOKEN_ADDRESS,
30+
} from '../../../Earn/constants/musd';
2831
import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences';
2932
import { MUSD_CONVERSION_NAVIGATION_OVERRIDE } from '../../../Earn/types/musd.types';
3033

@@ -1248,10 +1251,12 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => {
12481251
});
12491252

12501253
describe('Merkl Claim Bonus', () => {
1251-
// Use an address that isTokenEligibleForMerklRewards would accept
1254+
// Use mUSD address so isMusdToken(asset.address) is true and we show "3% bonus" when not claimable
12521255
const claimableAsset = {
12531256
...defaultAsset,
1254-
address: '0x8d652c6d4A8F3Db96Cd866C1a9220B1447F29898',
1257+
address: MUSD_TOKEN_ADDRESS,
1258+
symbol: 'mUSD',
1259+
name: 'MetaMask USD',
12551260
chainId: '0x1',
12561261
};
12571262

@@ -1280,11 +1285,12 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => {
12801285
expect(getByText(strings('earn.claim_bonus'))).toBeOnTheScreen();
12811286
});
12821287

1283-
it('hides "Claim bonus" CTA when claimableReward is null', () => {
1288+
it('shows green "3% bonus" when mUSD and claimableReward is null', () => {
12841289
prepareMocks({
12851290
asset: claimableAsset,
12861291
pricePercentChange1d: 2.0,
12871292
claimableReward: null,
1293+
isMusdConversionEnabled: true,
12881294
});
12891295

12901296
const { queryByText, getByText } = renderWithProvider(
@@ -1298,6 +1304,69 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => {
12981304
);
12991305

13001306
expect(queryByText(strings('earn.claim_bonus'))).toBeNull();
1307+
expect(
1308+
getByText(
1309+
strings('earn.musd_conversion.percentage_bonus', {
1310+
percentage: MUSD_CONVERSION_APY,
1311+
}),
1312+
),
1313+
).toBeOnTheScreen();
1314+
});
1315+
1316+
it('shows normal percentage when mUSD but conversion flow is disabled', () => {
1317+
prepareMocks({
1318+
asset: claimableAsset,
1319+
pricePercentChange1d: 2.0,
1320+
claimableReward: null,
1321+
isMusdConversionEnabled: false,
1322+
});
1323+
1324+
const { queryByText, getByText } = renderWithProvider(
1325+
<TokenListItem
1326+
assetKey={assetKey}
1327+
showRemoveMenu={jest.fn()}
1328+
setShowScamWarningModal={jest.fn()}
1329+
privacyMode={false}
1330+
shouldShowTokenListItemCta={mockShouldShowTokenListItemCta}
1331+
/>,
1332+
);
1333+
1334+
expect(
1335+
queryByText(
1336+
strings('earn.musd_conversion.percentage_bonus', {
1337+
percentage: MUSD_CONVERSION_APY,
1338+
}),
1339+
),
1340+
).toBeNull();
1341+
expect(getByText('+2.00%')).toBeOnTheScreen();
1342+
});
1343+
1344+
it('shows normal percentage when mUSD but user is geo-blocked', () => {
1345+
prepareMocks({
1346+
asset: claimableAsset,
1347+
pricePercentChange1d: 2.0,
1348+
claimableReward: null,
1349+
isMusdConversionEnabled: true,
1350+
isGeoEligible: false,
1351+
});
1352+
1353+
const { queryByText, getByText } = renderWithProvider(
1354+
<TokenListItem
1355+
assetKey={assetKey}
1356+
showRemoveMenu={jest.fn()}
1357+
setShowScamWarningModal={jest.fn()}
1358+
privacyMode={false}
1359+
shouldShowTokenListItemCta={mockShouldShowTokenListItemCta}
1360+
/>,
1361+
);
1362+
1363+
expect(
1364+
queryByText(
1365+
strings('earn.musd_conversion.percentage_bonus', {
1366+
percentage: MUSD_CONVERSION_APY,
1367+
}),
1368+
),
1369+
).toBeNull();
13011370
expect(getByText('+2.00%')).toBeOnTheScreen();
13021371
});
13031372

app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ import { TokenI } from '../../types';
2626
import { ScamWarningIcon } from './ScamWarningIcon/ScamWarningIcon';
2727
import { FlashListAssetKey } from '../TokenList';
2828
import {
29+
selectIsMusdConversionFlowEnabledFlag,
2930
selectMusdQuickConvertEnabledFlag,
3031
selectStablecoinLendingEnabledFlag,
3132
} from '../../../Earn/selectors/featureFlags';
33+
import { useMusdConversionEligibility } from '../../../Earn/hooks/useMusdConversionEligibility';
3234
import { useTokenPricePercentageChange } from '../../hooks/useTokenPricePercentageChange';
3335
import { selectAsset } from '../../../../../selectors/assets/assets-list';
3436
import Tag from '../../../../../component-library/components/Tags/Tag';
@@ -146,6 +148,11 @@ export const TokenListItem = React.memo(
146148
selectMusdQuickConvertEnabledFlag,
147149
);
148150

151+
const isMusdConversionFlowEnabled = useSelector(
152+
selectIsMusdConversionFlowEnabledFlag,
153+
);
154+
const { isEligible: isMusdGeoEligible } = useMusdConversionEligibility();
155+
149156
const { getEarnToken } = useEarnTokens();
150157

151158
const earnToken = getEarnToken(asset as TokenI);
@@ -294,6 +301,22 @@ export const TokenListItem = React.memo(
294301
};
295302
}
296303

304+
// mUSD with no claimable bonus: show green "3% bonus" (not clickable)
305+
if (
306+
isMusdConversionFlowEnabled &&
307+
isMusdGeoEligible &&
308+
asset &&
309+
isMusdToken(asset.address)
310+
) {
311+
return {
312+
text: strings('earn.musd_conversion.percentage_bonus', {
313+
percentage: MUSD_CONVERSION_APY,
314+
}),
315+
color: TextColor.Success,
316+
onPress: undefined,
317+
};
318+
}
319+
297320
if (shouldShowConvertToMusdCta) {
298321
return {
299322
text: strings('earn.musd_conversion.get_a_percentage_musd_bonus', {
@@ -336,6 +359,9 @@ export const TokenListItem = React.memo(
336359

337360
return { text, color, onPress: undefined };
338361
}, [
362+
asset,
363+
isMusdConversionFlowEnabled,
364+
isMusdGeoEligible,
339365
hasClaimableBonus,
340366
shouldShowConvertToMusdCta,
341367
isStablecoinLendingEnabled,

app/components/UI/Tokens/TokenList/TokenListItemV2/TokenListItemV2.test.tsx

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ import {
3030
selectMusdQuickConvertEnabledFlag,
3131
selectStablecoinLendingEnabledFlag,
3232
} from '../../../Earn/selectors/featureFlags';
33-
import { MUSD_CONVERSION_APY } from '../../../Earn/constants/musd';
33+
import {
34+
MUSD_CONVERSION_APY,
35+
MUSD_TOKEN_ADDRESS,
36+
} from '../../../Earn/constants/musd';
3437
import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences';
3538
import { MUSD_CONVERSION_NAVIGATION_OVERRIDE } from '../../../Earn/types/musd.types';
3639

@@ -1220,7 +1223,9 @@ describe('TokenListItemV2 - Component Rendering Tests for Coverage', () => {
12201223
describe('Merkl Claim Bonus', () => {
12211224
const claimableAsset = {
12221225
...defaultAsset,
1223-
address: '0x8d652c6d4A8F3Db96Cd866C1a9220B1447F29898',
1226+
address: MUSD_TOKEN_ADDRESS,
1227+
symbol: 'mUSD',
1228+
name: 'MetaMask USD',
12241229
};
12251230
const assetKey: FlashListAssetKey = {
12261231
address: claimableAsset.address,
@@ -1342,11 +1347,12 @@ describe('TokenListItemV2 - Component Rendering Tests for Coverage', () => {
13421347
expect(mockClaimRewards).toHaveBeenCalledTimes(1);
13431348
});
13441349

1345-
it('falls back to percentage when claimableReward is null', () => {
1350+
it('shows green "3% bonus" when mUSD and claimableReward is null', () => {
13461351
prepareMocks({
13471352
asset: claimableAsset,
13481353
pricePercentChange1d: 1.5,
13491354
claimableReward: null,
1355+
isMusdConversionEnabled: true,
13501356
});
13511357

13521358
const { queryByText, getByText } = renderWithProvider(
@@ -1360,6 +1366,69 @@ describe('TokenListItemV2 - Component Rendering Tests for Coverage', () => {
13601366
);
13611367

13621368
expect(queryByText(strings('earn.claim_bonus'))).toBeNull();
1369+
expect(
1370+
getByText(
1371+
strings('earn.musd_conversion.percentage_bonus', {
1372+
percentage: MUSD_CONVERSION_APY,
1373+
}),
1374+
),
1375+
).toBeOnTheScreen();
1376+
});
1377+
1378+
it('shows normal percentage when mUSD but conversion flow is disabled', () => {
1379+
prepareMocks({
1380+
asset: claimableAsset,
1381+
pricePercentChange1d: 1.5,
1382+
claimableReward: null,
1383+
isMusdConversionEnabled: false,
1384+
});
1385+
1386+
const { queryByText, getByText } = renderWithProvider(
1387+
<TokenListItemV2
1388+
assetKey={assetKey}
1389+
showRemoveMenu={jest.fn()}
1390+
setShowScamWarningModal={jest.fn()}
1391+
privacyMode={false}
1392+
shouldShowTokenListItemCta={mockshouldShowTokenListItemCta}
1393+
/>,
1394+
);
1395+
1396+
expect(
1397+
queryByText(
1398+
strings('earn.musd_conversion.percentage_bonus', {
1399+
percentage: MUSD_CONVERSION_APY,
1400+
}),
1401+
),
1402+
).toBeNull();
1403+
expect(getByText('+1.50%')).toBeOnTheScreen();
1404+
});
1405+
1406+
it('shows normal percentage when mUSD but user is geo-blocked', () => {
1407+
prepareMocks({
1408+
asset: claimableAsset,
1409+
pricePercentChange1d: 1.5,
1410+
claimableReward: null,
1411+
isMusdConversionEnabled: true,
1412+
isGeoEligible: false,
1413+
});
1414+
1415+
const { queryByText, getByText } = renderWithProvider(
1416+
<TokenListItemV2
1417+
assetKey={assetKey}
1418+
showRemoveMenu={jest.fn()}
1419+
setShowScamWarningModal={jest.fn()}
1420+
privacyMode={false}
1421+
shouldShowTokenListItemCta={mockshouldShowTokenListItemCta}
1422+
/>,
1423+
);
1424+
1425+
expect(
1426+
queryByText(
1427+
strings('earn.musd_conversion.percentage_bonus', {
1428+
percentage: MUSD_CONVERSION_APY,
1429+
}),
1430+
),
1431+
).toBeNull();
13631432
expect(getByText('+1.50%')).toBeOnTheScreen();
13641433
});
13651434
});

0 commit comments

Comments
 (0)