From 5e33f0df98abc3376c6414a2d4e71179e3776ce9 Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:34:32 +0000 Subject: [PATCH] chore(runway): cherry-pick fix: musd money hub 7.74.0 release blockers cp-7.74.0 (#29225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes various Money Hub release blockers for the 7.74.0 release. ## **Changelog** CHANGELOG entry: fixing money hub 7.74.0 release blockers ## **Related issues** Fixes: - [MUSD-688: Money hub's primary "Convert to mUSD" button must redirect to the single convert screen](https://consensyssoftware.atlassian.net/browse/MUSD-688) - [MUSD-689: Money Hub's "Max" and "Custom" buttons aren't disabled when there's a conversion in flight.](https://consensyssoftware.atlassian.net/browse/MUSD-689) - [MUSD-690: Successful conversions are redirected to the home screen when quick convert flag disabled](https://consensyssoftware.atlassian.net/browse/MUSD-690) - [MUSD-686: Skeleton loaders misaligned with content and UX expectations](https://consensyssoftware.atlassian.net/browse/MUSD-686) - [MUSD-692: Money Hub shows two bonus claim buttons when user has bonus but doesn't hold mUSD](https://consensyssoftware.atlassian.net/browse/MUSD-692) ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** Post-conversion redirect fixes ## **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 navigation behavior after mUSD conversions and updates Money Hub conversion CTAs/state gating, which can affect user flow and back stack behavior. Also refactors/loading-skeleton logic and empty-state actions, with moderate risk of regressions in conditional rendering and analytics-trigger timing. > > **Overview** > Fixes Money Hub mUSD post-conversion routing by updating `useMusdConfirmNavigation` to operate on the *parent* navigator stack (pop vs replace to `Routes.WALLET.CASH_TOKENS_FULL_VIEW`) when the Money Hub flag is enabled, avoiding stale confirmation screens remaining in history. > > Updates Money Hub convert UI to respect conversion-in-progress state: `MoneyConvertStablecoins` now disables row actions when there are unapproved/in-flight conversions and shows per-token pending status via `selectMusdConversionStatuses`. > > Improves Cash full view UX and metrics timing: token-list rendering is gated behind an InteractionManager “ready” signal only when the user has mUSD (showing a new content-area skeleton that mirrors the eventual layout), the primary "Convert to mUSD" CTA now launches the *custom amount* flow (`initiateCustomConversion` with `preferredPaymentToken`), and the empty state can hide the secondary Merkl "Claim bonus" button in Money Hub mode to prevent duplicate claim CTAs. Tests were updated/added accordingly. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1bb82abd7d5a207b07e66f042e522f78d921511c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../hooks/useMusdConfirmNavigation.test.ts | 186 +++++--------- .../UI/Earn/hooks/useMusdConfirmNavigation.ts | 62 +++-- .../MoneyConvertStablecoins.test.tsx | 19 ++ .../MoneyConvertStablecoins.tsx | 38 +++ .../CashTokensFullView.test.tsx | 83 ++++-- .../CashTokensFullView/CashTokensFullView.tsx | 80 +++--- .../CashTokensFullViewSkeleton.test.tsx | 82 +++++- .../CashTokensFullViewSkeleton.tsx | 241 ++++++++++++++---- .../Cash/CashGetMusdEmptyState.test.tsx | 36 +++ .../Sections/Cash/CashGetMusdEmptyState.tsx | 4 +- 10 files changed, 581 insertions(+), 250 deletions(-) diff --git a/app/components/UI/Earn/hooks/useMusdConfirmNavigation.test.ts b/app/components/UI/Earn/hooks/useMusdConfirmNavigation.test.ts index 150c7decf123..befc59dced19 100644 --- a/app/components/UI/Earn/hooks/useMusdConfirmNavigation.test.ts +++ b/app/components/UI/Earn/hooks/useMusdConfirmNavigation.test.ts @@ -1,14 +1,14 @@ import { act, renderHook } from '@testing-library/react-hooks'; +import { StackActions } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import Routes from '../../../../constants/navigation/Routes'; import { useMusdConfirmNavigation } from './useMusdConfirmNavigation'; -import { useMusdConversionTokens } from './useMusdConversionTokens'; -import { useTransactionPayIsMaxAmount } from '../../../Views/confirmations/hooks/pay/useTransactionPayData'; -import { AssetType } from '../../../Views/confirmations/types/token'; +import { selectMoneyHubEnabledFlag } from '../../Money/selectors/featureFlags'; const mockNavigate = jest.fn(); -const mockGoBack = jest.fn(); -const mockCanGoBack = jest.fn(); +const mockDispatch = jest.fn(); +const mockGetState = jest.fn(); +const mockGetParent = jest.fn(); jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -19,156 +19,92 @@ jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ navigate: mockNavigate, - goBack: mockGoBack, - canGoBack: mockCanGoBack, + getParent: mockGetParent, }), })); -jest.mock( - '../../../Views/confirmations/hooks/pay/useTransactionPayData', - () => ({ - useTransactionPayIsMaxAmount: jest.fn(), - }), -); - -jest.mock('./useMusdConversionTokens'); - -const mockUseTransactionPayIsMaxAmount = - useTransactionPayIsMaxAmount as jest.MockedFunction< - typeof useTransactionPayIsMaxAmount - >; -const mockUseMusdConversionTokens = - useMusdConversionTokens as jest.MockedFunction< - typeof useMusdConversionTokens - >; - -const createTokenWithBalance = ( - overrides: Partial = {}, -): AssetType => - ({ - address: '0xToken1', - chainId: '0x1', - symbol: 'USDC', - rawBalance: '0x1000', - ...overrides, - }) as AssetType; +const mockUseSelector = useSelector as jest.Mock; -describe('useMusdConfirmNavigation', () => { - const useSelectorMock = useSelector as jest.Mock; +const createParentState = (routeNames: string[]) => ({ + routes: routeNames.map((name) => ({ name, key: `${name}-key` })), +}); - beforeEach(() => { - jest.resetAllMocks(); - mockUseTransactionPayIsMaxAmount.mockReturnValue(false); - mockUseMusdConversionTokens.mockReturnValue({ - tokens: [ - createTokenWithBalance(), - createTokenWithBalance({ address: '0xToken2', symbol: 'USDT' }), - ], - filterAllowedTokens: jest.fn(), - isConversionToken: jest.fn(), - isMusdSupportedOnChain: jest.fn(), - hasConvertibleTokensByChainId: jest.fn(), - }); +const setupMoneyHubEnabled = (enabled: boolean) => { + mockUseSelector.mockImplementation((selector: unknown) => { + if (selector === selectMoneyHubEnabledFlag) return enabled; + return undefined; }); +}; - it('goes back when quick convert is enabled and navigation can go back', () => { - useSelectorMock.mockReturnValue(true); - mockCanGoBack.mockReturnValue(true); - - const { result } = renderHook(() => useMusdConfirmNavigation()); - - act(() => { - result.current.navigateOnConfirm(); +describe('useMusdConfirmNavigation', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupMoneyHubEnabled(false); + mockGetParent.mockReturnValue({ + dispatch: mockDispatch, + getState: mockGetState, }); - - expect(mockGoBack).toHaveBeenCalledTimes(1); - expect(mockNavigate).not.toHaveBeenCalled(); }); - it('navigates to wallet view when quick convert is enabled and cannot go back', () => { - useSelectorMock.mockReturnValue(true); - mockCanGoBack.mockReturnValue(false); - - const { result } = renderHook(() => useMusdConfirmNavigation()); - - act(() => { - result.current.navigateOnConfirm(); + describe('when Money Hub is enabled', () => { + beforeEach(() => { + setupMoneyHubEnabled(true); }); - expect(mockGoBack).not.toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW); - }); + it('pops the parent stack when CashTokensFullView is already below', () => { + mockGetState.mockReturnValue( + createParentState(['Home', Routes.WALLET.CASH_TOKENS_FULL_VIEW]), + ); - it('navigates to wallet view when quick convert is disabled', () => { - useSelectorMock.mockReturnValue(false); + const { result } = renderHook(() => useMusdConfirmNavigation()); - const { result } = renderHook(() => useMusdConfirmNavigation()); + act(() => { + result.current.navigateOnConfirm(); + }); - act(() => { - result.current.navigateOnConfirm(); + expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop()); + expect(mockNavigate).not.toHaveBeenCalled(); }); - expect(mockCanGoBack).not.toHaveBeenCalled(); - expect(mockGoBack).not.toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW); - }); + it('replaces the current route with CashTokensFullView when it is not in the stack', () => { + mockGetState.mockReturnValue(createParentState(['Home'])); - it('navigates to wallet view when max converting the last token', () => { - useSelectorMock.mockReturnValue(true); - mockCanGoBack.mockReturnValue(true); - mockUseTransactionPayIsMaxAmount.mockReturnValue(true); - mockUseMusdConversionTokens.mockReturnValue({ - tokens: [createTokenWithBalance()], - filterAllowedTokens: jest.fn(), - isConversionToken: jest.fn(), - isMusdSupportedOnChain: jest.fn(), - hasConvertibleTokensByChainId: jest.fn(), - }); + const { result } = renderHook(() => useMusdConfirmNavigation()); - const { result } = renderHook(() => useMusdConfirmNavigation()); + act(() => { + result.current.navigateOnConfirm(); + }); - act(() => { - result.current.navigateOnConfirm(); + expect(mockDispatch).toHaveBeenCalledWith( + StackActions.replace(Routes.WALLET.CASH_TOKENS_FULL_VIEW), + ); + expect(mockNavigate).not.toHaveBeenCalled(); }); - expect(mockGoBack).not.toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW); - }); + it('falls back to wallet view when parent navigation is unavailable', () => { + mockGetParent.mockReturnValue(null); - it('goes back when max converting with multiple tokens remaining', () => { - useSelectorMock.mockReturnValue(true); - mockCanGoBack.mockReturnValue(true); - mockUseTransactionPayIsMaxAmount.mockReturnValue(true); + const { result } = renderHook(() => useMusdConfirmNavigation()); - const { result } = renderHook(() => useMusdConfirmNavigation()); + act(() => { + result.current.navigateOnConfirm(); + }); - act(() => { - result.current.navigateOnConfirm(); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW); }); - - expect(mockGoBack).toHaveBeenCalledTimes(1); - expect(mockNavigate).not.toHaveBeenCalled(); }); - it('goes back when custom converting the last token with partial amount', () => { - useSelectorMock.mockReturnValue(true); - mockCanGoBack.mockReturnValue(true); - mockUseTransactionPayIsMaxAmount.mockReturnValue(false); - mockUseMusdConversionTokens.mockReturnValue({ - tokens: [createTokenWithBalance()], - filterAllowedTokens: jest.fn(), - isConversionToken: jest.fn(), - isMusdSupportedOnChain: jest.fn(), - hasConvertibleTokensByChainId: jest.fn(), - }); + describe('when Money Hub is disabled', () => { + it('navigates to wallet view', () => { + const { result } = renderHook(() => useMusdConfirmNavigation()); - const { result } = renderHook(() => useMusdConfirmNavigation()); + act(() => { + result.current.navigateOnConfirm(); + }); - act(() => { - result.current.navigateOnConfirm(); + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW); + expect(mockDispatch).not.toHaveBeenCalled(); }); - - expect(mockGoBack).toHaveBeenCalledTimes(1); - expect(mockNavigate).not.toHaveBeenCalled(); }); }); diff --git a/app/components/UI/Earn/hooks/useMusdConfirmNavigation.ts b/app/components/UI/Earn/hooks/useMusdConfirmNavigation.ts index e2b0f8004485..de69ba4bf38e 100644 --- a/app/components/UI/Earn/hooks/useMusdConfirmNavigation.ts +++ b/app/components/UI/Earn/hooks/useMusdConfirmNavigation.ts @@ -1,43 +1,49 @@ import { useCallback } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { useSelector } from 'react-redux'; +import { StackActions, useNavigation } from '@react-navigation/native'; import Routes from '../../../../constants/navigation/Routes'; -import { selectMusdQuickConvertEnabledFlag } from '../selectors/featureFlags'; -import { useMusdConversionTokens } from './useMusdConversionTokens'; -import { useTransactionPayIsMaxAmount } from '../../../Views/confirmations/hooks/pay/useTransactionPayData'; +import { selectMoneyHubEnabledFlag } from '../../Money/selectors/featureFlags'; +import { useSelector } from 'react-redux'; export const useMusdConfirmNavigation = () => { const navigation = useNavigation(); - const isMusdQuickConvertEnabled = useSelector( - selectMusdQuickConvertEnabledFlag, - ); - - const isMaxAmount = useTransactionPayIsMaxAmount(); - const { tokens: conversionTokens } = useMusdConversionTokens(); + const isMoneyHubEnabled = useSelector(selectMoneyHubEnabledFlag); + + // We must operate on the parent (MainNavigator) stack because the + // confirmation screen lives inside a nested EarnScreenStack. A plain + // navigation.navigate() from inside EarnScreenStack would push + // CashTokensFullView on top without removing EarnScreens, leaving the + // stale confirmation screen in the back stack. To prevent that: + // - pop: if CashTokensFullView is already below (entered from Money Hub) + // - replace: if it isn't (entered from TokenListItem or asset detail) + const handleMoneyHubNavigation = useCallback((): boolean => { + const parentNavigation = navigation.getParent(); + if (!parentNavigation) { + return false; + } - const isLastConvertibleToken = conversionTokens.length <= 1; + const parentState = parentNavigation.getState(); + const isCashTokensFullViewInStack = parentState.routes.some( + (route) => route.name === Routes.WALLET.CASH_TOKENS_FULL_VIEW, + ); + + if (isCashTokensFullViewInStack) { + parentNavigation.dispatch(StackActions.pop()); + } else { + parentNavigation.dispatch( + StackActions.replace(Routes.WALLET.CASH_TOKENS_FULL_VIEW), + ); + } + return true; + }, [navigation]); const navigateOnConfirm = useCallback(() => { - if (isMusdQuickConvertEnabled) { - if (isMaxAmount && isLastConvertibleToken) { - navigation.navigate(Routes.WALLET_VIEW); - return; - } - - if (navigation.canGoBack()) { - navigation.goBack(); - return; - } + if (isMoneyHubEnabled && handleMoneyHubNavigation()) { + return; } navigation.navigate(Routes.WALLET_VIEW); - }, [ - isMusdQuickConvertEnabled, - navigation, - isMaxAmount, - isLastConvertibleToken, - ]); + }, [handleMoneyHubNavigation, isMoneyHubEnabled, navigation]); return { navigateOnConfirm, diff --git a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.test.tsx b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.test.tsx index fe5e1893b45e..8433297d1e1c 100644 --- a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.test.tsx +++ b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.test.tsx @@ -1,10 +1,23 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; import MoneyConvertStablecoins from './MoneyConvertStablecoins'; import { MoneyConvertStablecoinsTestIds } from './MoneyConvertStablecoins.testIds'; import { strings } from '../../../../../../locales/i18n'; import { AssetType } from '../../../../Views/confirmations/types/token'; import { ConvertTokenRowTestIds } from '../../../Earn/components/Musd/ConvertTokenRow'; +import { + selectHasUnapprovedMusdConversion, + selectHasInFlightMusdConversion, + selectMusdConversionStatuses, +} from '../../../Earn/selectors/musdConversionStatus'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.Mock; jest.mock('../../../../../component-library/base-components/TagBase', () => ({ __esModule: true, @@ -100,6 +113,12 @@ const defaultProps = { describe('MoneyConvertStablecoins', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectHasUnapprovedMusdConversion) return false; + if (selector === selectHasInFlightMusdConversion) return false; + if (selector === selectMusdConversionStatuses) return {}; + return undefined; + }); }); describe('with eligible tokens', () => { diff --git a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx index a5ed3723061d..411a42fbeac2 100644 --- a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx +++ b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx @@ -30,6 +30,13 @@ import ConvertTokenRow from '../../../Earn/components/Musd/ConvertTokenRow'; import { AssetType } from '../../../../Views/confirmations/types/token'; import { MoneyConvertStablecoinsTestIds } from './MoneyConvertStablecoins.testIds'; import { CaipChainId } from '@metamask/utils'; +import { useSelector } from 'react-redux'; +import { + createTokenChainKey, + selectHasInFlightMusdConversion, + selectHasUnapprovedMusdConversion, + selectMusdConversionStatuses, +} from '../../../Earn/selectors/musdConversionStatus'; interface MoneyConvertStablecoinsProps { tokens: AssetType[]; @@ -134,6 +141,33 @@ const MoneyConvertStablecoins = ({ }: MoneyConvertStablecoinsProps) => { const hasTokens = tokens.length > 0; + const hasUnapprovedMusdConversion = useSelector( + selectHasUnapprovedMusdConversion, + ); + const hasInFlightMusdConversion = useSelector( + selectHasInFlightMusdConversion, + ); + + const conversionStatusesByTokenChainKey = useSelector( + selectMusdConversionStatuses, + ); + + const isConversionPending = (token: AssetType) => { + const tokenAddress = token.address; + const tokenChainId = token.chainId; + + const tokenChainKey = + tokenAddress && tokenChainId + ? createTokenChainKey(tokenAddress, tokenChainId) + : undefined; + + const txStatusInfo = tokenChainKey + ? conversionStatusesByTokenChainKey[tokenChainKey] + : undefined; + + return Boolean(txStatusInfo?.isPending); + }; + return ( @@ -164,6 +198,10 @@ const MoneyConvertStablecoins = ({ token={token} onMaxPress={onMaxPress} onEditPress={onEditPress} + areActionsDisabled={ + hasUnapprovedMusdConversion || hasInFlightMusdConversion + } + isConversionPending={isConversionPending(token)} /> ))} diff --git a/app/components/Views/CashTokensFullView/CashTokensFullView.test.tsx b/app/components/Views/CashTokensFullView/CashTokensFullView.test.tsx index f4868633e6bc..bd132da3d5fd 100644 --- a/app/components/Views/CashTokensFullView/CashTokensFullView.test.tsx +++ b/app/components/Views/CashTokensFullView/CashTokensFullView.test.tsx @@ -29,7 +29,10 @@ jest.mock('@react-navigation/native', () => ({ }), })); -const mockUseMusdBalance = jest.fn(() => ({ hasMusdBalanceOnAnyChain: false })); +const mockUseMusdBalance = jest.fn(() => ({ + hasMusdBalanceOnAnyChain: false, + tokenBalanceByChain: {}, +})); jest.mock('../../UI/Earn/hooks/useMusdBalance', () => ({ useMusdBalance: () => mockUseMusdBalance(), })); @@ -224,7 +227,10 @@ describe('CashTokensFullView', () => { beforeEach(() => { jest.clearAllMocks(); flushInteractionManager(); - mockUseMusdBalance.mockReturnValue({ hasMusdBalanceOnAnyChain: false }); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: false, + tokenBalanceByChain: {}, + }); mockUseMusdConversionTokens.mockReturnValue({ tokens: [] }); mockSelectMoneyHubEnabledFlag.mockReturnValue(false); @@ -253,14 +259,20 @@ describe('CashTokensFullView', () => { }); it('renders Get mUSD empty state when user has no mUSD', () => { - mockUseMusdBalance.mockReturnValue({ hasMusdBalanceOnAnyChain: false }); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: false, + tokenBalanceByChain: {}, + }); renderWithProvider(); expect(screen.getByTestId('cash-get-musd-empty-state')).toBeOnTheScreen(); expect(screen.getByText('Get mUSD')).toBeOnTheScreen(); }); it('renders Tokens with isFullView and showOnlyMusd when user has mUSD', () => { - mockUseMusdBalance.mockReturnValue({ hasMusdBalanceOnAnyChain: true }); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: true, + tokenBalanceByChain: { '0x1': '1.0' }, + }); renderWithProvider(); expect(screen.getByTestId('tokens-cash-view')).toBeOnTheScreen(); expect( @@ -346,7 +358,10 @@ describe('CashTokensFullView', () => { }); it('empty-state Buy button passes mUSD assetId to goToBuy', () => { - mockUseMusdBalance.mockReturnValue({ hasMusdBalanceOnAnyChain: false }); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: false, + tokenBalanceByChain: {}, + }); mockSelectMoneyHubEnabledFlag.mockReturnValue(true); renderWithProvider(); fireEvent.press(screen.getByText('Buy')); @@ -355,9 +370,7 @@ describe('CashTokensFullView', () => { }); }); - it('renders CashTokensFullViewSkeleton on first render before data is marked loaded', () => { - // Prevent InteractionManager's callback from running so the view stays - // in its loading state for the duration of the render. + it('renders header immediately and content skeleton before InteractionManager fires when user has mUSD', () => { jest.restoreAllMocks(); jest .spyOn(InteractionManager, 'runAfterInteractions') @@ -366,15 +379,45 @@ describe('CashTokensFullView', () => { done: jest.fn(), cancel: jest.fn(), })); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: true, + tokenBalanceByChain: { '0x1': '1.0' }, + }); renderWithProvider(); + expect(screen.getByText('Money')).toBeOnTheScreen(); expect( screen.getByTestId(CashTokensFullViewSkeletonTestIds.CONTAINER), ).toBeOnTheScreen(); }); + it('renders content immediately without skeleton when user has no mUSD', () => { + jest.restoreAllMocks(); + jest + .spyOn(InteractionManager, 'runAfterInteractions') + .mockImplementation(() => ({ + then: jest.fn(), + done: jest.fn(), + cancel: jest.fn(), + })); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: false, + tokenBalanceByChain: {}, + }); + + renderWithProvider(); + expect(screen.getByText('Money')).toBeOnTheScreen(); + expect(screen.getByTestId('cash-get-musd-empty-state')).toBeOnTheScreen(); + expect( + screen.queryByTestId(CashTokensFullViewSkeletonTestIds.CONTAINER), + ).not.toBeOnTheScreen(); + }); + it('wires RefreshControl onRefresh to useCashTokensRefresh.onRefresh on the Tokens branch', async () => { - mockUseMusdBalance.mockReturnValue({ hasMusdBalanceOnAnyChain: true }); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: true, + tokenBalanceByChain: { '0x1': '1.0' }, + }); const onRefresh = jest.fn().mockResolvedValue(undefined); mockUseCashTokensRefresh.mockReturnValue({ refreshing: false, onRefresh }); @@ -387,7 +430,10 @@ describe('CashTokensFullView', () => { }); it('wires RefreshControl onRefresh to useCashTokensRefresh.onRefresh on the empty-state branch', async () => { - mockUseMusdBalance.mockReturnValue({ hasMusdBalanceOnAnyChain: false }); + mockUseMusdBalance.mockReturnValue({ + hasMusdBalanceOnAnyChain: false, + tokenBalanceByChain: {}, + }); const onRefresh = jest.fn().mockResolvedValue(undefined); mockUseCashTokensRefresh.mockReturnValue({ refreshing: false, onRefresh }); @@ -475,11 +521,11 @@ describe('CashTokensFullView', () => { }); }); - it('calls initiateMaxConversion on first conversionToken via handleConvertPress', async () => { + it('calls initiateCustomConversion with preferredPaymentToken via handleConvertPress', async () => { mockSelectMoneyHubEnabledFlag.mockReturnValue(true); const token = { address: '0xabc', chainId: '0x1' } as AssetType; mockUseMusdConversionTokens.mockReturnValue({ tokens: [token] }); - mockInitiateMaxConversion.mockResolvedValue(undefined); + mockInitiateCustomConversion.mockResolvedValue(undefined); renderWithProvider(); @@ -487,7 +533,12 @@ describe('CashTokensFullView', () => { fireEvent.press(screen.getByText('Convert to mUSD')); }); - expect(mockInitiateMaxConversion).toHaveBeenCalledWith(token); + expect(mockInitiateCustomConversion).toHaveBeenCalledWith({ + preferredPaymentToken: { + address: '0xabc', + chainId: '0x1', + }, + }); }); it('logs error when handleConvertPress fails', async () => { @@ -495,7 +546,7 @@ describe('CashTokensFullView', () => { const token = { address: '0xabc', chainId: '0x1' } as AssetType; mockUseMusdConversionTokens.mockReturnValue({ tokens: [token] }); const error = new Error('convert CTA failed'); - mockInitiateMaxConversion.mockRejectedValue(error); + mockInitiateCustomConversion.mockRejectedValue(error); const loggerSpy = jest.spyOn( jest.requireMock('../../../util/Logger').default, 'error', @@ -525,8 +576,8 @@ describe('CashTokensFullView', () => { // so we test the early return by having tokens initially then not. // Actually, the early return is only hit if conversionTokens[0] is falsy. // Since the CTA only renders when hasConversionTokens, we just verify - // the convert press calls initiateMaxConversion above. - expect(mockInitiateMaxConversion).not.toHaveBeenCalled(); + // the convert press calls initiateCustomConversion above. + expect(mockInitiateCustomConversion).not.toHaveBeenCalled(); }); it('calls goToSwaps when Swap button is pressed', () => { diff --git a/app/components/Views/CashTokensFullView/CashTokensFullView.tsx b/app/components/Views/CashTokensFullView/CashTokensFullView.tsx index a11efc029f77..4c24ae3f2858 100644 --- a/app/components/Views/CashTokensFullView/CashTokensFullView.tsx +++ b/app/components/Views/CashTokensFullView/CashTokensFullView.tsx @@ -70,30 +70,30 @@ const CashTokensFullView = () => { const navigation = useNavigation(); const tw = useTailwind(); const { trackEvent, createEventBuilder } = useAnalytics(); - const { hasMusdBalanceOnAnyChain } = useMusdBalance(); + const { hasMusdBalanceOnAnyChain, tokenBalanceByChain } = useMusdBalance(); + + const numChainsWithMusdBalance = Object.keys(tokenBalanceByChain).length; + const { tokens: conversionTokens } = useMusdConversionTokens(); const isMoneyHubEnabled = useSelector(selectMoneyHubEnabledFlag); const hasConversionTokens = conversionTokens.length > 0; - // Loading signal: neither useMusdBalance nor useMusdConversionTokens expose - // an isLoading flag (they derive from synchronous Redux selectors). We mirror - // the Tokens component's hasInitialLoad pattern and flip loading off after - // the first InteractionManager tick so the Hub's dedicated skeleton shows on - // the first paint instead of falling through to TokenListSkeleton. - const [isLoading, setIsLoading] = useState(true); + const [isTokenListReady, setIsTokenListReady] = useState(false); useEffect(() => { const handle = InteractionManager.runAfterInteractions(() => { - setIsLoading(false); + setIsTokenListReady(true); }); return () => handle.cancel(); }, []); const screenViewedRef = useRef(false); + const isScreenReady = !hasMusdBalanceOnAnyChain || isTokenListReady; + useEffect(() => { - if (isLoading || screenViewedRef.current || !isMoneyHubEnabled) return; + if (!isScreenReady || screenViewedRef.current || !isMoneyHubEnabled) return; screenViewedRef.current = true; const hasConvertibleTokens = conversionTokens.length > 0; @@ -121,11 +121,11 @@ const CashTokensFullView = () => { .build(), ); }, [ - isLoading, conversionTokens, createEventBuilder, trackEvent, isMoneyHubEnabled, + isScreenReady, ]); const merklRefetchRef = useRef<(() => void) | null>(null); @@ -225,9 +225,8 @@ const CashTokensFullView = () => { .addProperties({ location: MONEY_EVENT_LOCATIONS.MONEY_HUB, button_type: 'text_button', - button_action: 'max', - redirects_to: - MUSD_EVENT_LOCATIONS.QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN, + button_action: 'custom', + redirects_to: MUSD_EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, asset_symbol: topToken.symbol, network_chain_id: topToken.chainId, network_name: topToken.chainId @@ -237,13 +236,23 @@ const CashTokensFullView = () => { .build(), ); - await initiateMaxConversion(topToken); + await initiateCustomConversion({ + preferredPaymentToken: { + address: topToken.address as Hex, + chainId: topToken.chainId as Hex, + }, + }); } catch (error) { Logger.error(error as Error, { message: '[CashTokensFullView] Failed to initiate convert CTA', }); } - }, [conversionTokens, createEventBuilder, initiateMaxConversion, trackEvent]); + }, [ + conversionTokens, + createEventBuilder, + initiateCustomConversion, + trackEvent, + ]); const handleSwapsPress = useCallback(() => { trackEvent( @@ -309,10 +318,6 @@ const CashTokensFullView = () => { ], ); - if (isLoading) { - return ; - } - return ( { {strings('homepage.sections.cash')} {hasMusdBalanceOnAnyChain ? ( - - } - /> + isTokenListReady ? ( + + } + /> + ) : ( + + ) ) : ( { } > - + {isMoneyHubEnabled ? bonusAndConvertSections : undefined} diff --git a/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.test.tsx b/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.test.tsx index 727c93916e3f..9b29d81f5c7b 100644 --- a/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.test.tsx +++ b/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.test.tsx @@ -1,14 +1,86 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { render, screen } from '@testing-library/react-native'; import CashTokensFullViewSkeleton, { CashTokensFullViewSkeletonTestIds, } from './CashTokensFullViewSkeleton'; +const { + CONTAINER, + TOKEN_ROW, + EMPTY_STATE_ROW, + BONUS_SECTION, + CONVERT_SECTION, +} = CashTokensFullViewSkeletonTestIds; + describe('CashTokensFullViewSkeleton', () => { it('renders the skeleton container', () => { - const { getByTestId } = render(); - expect( - getByTestId(CashTokensFullViewSkeletonTestIds.CONTAINER), - ).toBeOnTheScreen(); + render( + , + ); + expect(screen.getByTestId(CONTAINER)).toBeOnTheScreen(); + }); + + it('renders one token row skeleton per chain with mUSD balance', () => { + render( + , + ); + expect(screen.getAllByTestId(TOKEN_ROW)).toHaveLength(2); + expect(screen.queryByTestId(EMPTY_STATE_ROW)).not.toBeOnTheScreen(); + }); + + it('renders a single token row skeleton when user has mUSD on one chain', () => { + render( + , + ); + expect(screen.getAllByTestId(TOKEN_ROW)).toHaveLength(1); + expect(screen.queryByTestId(EMPTY_STATE_ROW)).not.toBeOnTheScreen(); + }); + + it('renders empty state row skeleton when user has no mUSD balance', () => { + render( + , + ); + expect(screen.getByTestId(EMPTY_STATE_ROW)).toBeOnTheScreen(); + expect(screen.queryByTestId(TOKEN_ROW)).not.toBeOnTheScreen(); + }); + + it('renders bonus and convert sections when MoneyHub is enabled', () => { + render( + , + ); + expect(screen.getByTestId(BONUS_SECTION)).toBeOnTheScreen(); + expect(screen.getByTestId(CONVERT_SECTION)).toBeOnTheScreen(); + }); + + it('omits bonus and convert sections when MoneyHub is disabled', () => { + render( + , + ); + expect(screen.queryByTestId(BONUS_SECTION)).not.toBeOnTheScreen(); + expect(screen.queryByTestId(CONVERT_SECTION)).not.toBeOnTheScreen(); }); }); diff --git a/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.tsx b/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.tsx index bc310e2fd01a..926ab6ca7969 100644 --- a/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.tsx +++ b/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.tsx @@ -1,68 +1,223 @@ import React from 'react'; import { ScrollView } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, + BoxAlignItems, BoxFlexDirection, - HeaderBase, + BoxJustifyContent, Skeleton, } from '@metamask/design-system-react-native'; export const CashTokensFullViewSkeletonTestIds = { CONTAINER: 'cash-tokens-full-view-skeleton', + TOKEN_ROW: 'skeleton-token-row', + EMPTY_STATE_ROW: 'skeleton-empty-state-row', + BONUS_SECTION: 'skeleton-bonus-section', + CONVERT_SECTION: 'skeleton-convert-section', }; +interface CashTokensFullViewSkeletonProps { + numChainsWithMusdBalance: number; + isMoneyHubEnabled: boolean; + conversionTokenCount: number; +} + /** - * First-paint loading skeleton for the Money Hub (CashTokensFullView). - * - * Mirrors the Hub's section layout (header / hero / bonus card / - * convert-stablecoins card / bottom CTA) so the initial render no longer - * flashes the generic TokenList skeleton before the real Hub renders. + * Mirrors a single TokenListItem row: 40px avatar circle, two text lines + * on the left (name + price), two text lines on the right (fiat + balance). */ -const CashTokensFullViewSkeleton = () => { - const tw = useTailwind(); +const TokenRowSkeleton = () => ( + + + + + + + + + + + +); - return ( - ( + + - + + + + + + + + +); + +/** + * Mirrors AssetOverviewClaimBonus: divider, header row with tag pill, + * two label + value rows, a full-width CTA button, and a closing divider. + */ +const BonusSectionSkeleton = () => ( + + + + - + + + + + + + + + + + + + + + + +); - - - - +/** + * Mirrors a single ConvertTokenRow: 32px token icon, name + balance text, + * and action button placeholders on the right. + */ +const ConvertTokenRowSkeleton = () => ( + + + + + + + + + + + + + +); - - - +/** + * Mirrors MoneyConvertStablecoins: heading, description, 2x2 feature tag + * pills (Dollar-backed, No lockups, No MetaMask fee, Daily bonus), + * optional convert-token rows, and a learn-more button. + */ +const ConvertSectionSkeleton = ({ tokenCount }: { tokenCount: number }) => ( + + + + + + + + + + + + + + + {tokenCount > 0 && ( + + {Array.from({ length: Math.min(tokenCount, 3) }, (_, index) => ( + + ))} + + )} + + + + +); - - - - - - - - - - - - - - - - - +/** + * Content-area loading skeleton for the Money Hub (CashTokensFullView). + * + * Accepts synchronous Redux-derived props so it can mirror the exact layout + * branch the real content will take: token rows vs empty state, and whether + * the MoneyHub bonus/convert sections appear. + */ +const CashTokensFullViewSkeleton = ({ + numChainsWithMusdBalance, + isMoneyHubEnabled, + conversionTokenCount, +}: CashTokensFullViewSkeletonProps) => { + const tw = useTailwind(); - - - - + return ( + + {numChainsWithMusdBalance > 0 ? ( + <> + {Array.from({ length: numChainsWithMusdBalance }, (_, index) => ( + + ))} + + ) : ( + + )} + {isMoneyHubEnabled && ( + <> + + + + )} + ); }; diff --git a/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.test.tsx b/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.test.tsx index cc2d603f4137..6ee701c507a3 100644 --- a/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.test.tsx +++ b/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.test.tsx @@ -249,6 +249,42 @@ describe('CashGetMusdEmptyState', () => { ).toBeNull(); }); + it('hides Claim bonus button when hideClaimButton is true even with claimable reward', () => { + mockUseMerklBonusClaim.mockReturnValue({ + claimableReward: '12.34', + lifetimeBonusClaimed: null, + hasPendingClaim: false, + isClaiming: false, + error: null, + claimRewards: mockClaimRewards, + refetch: jest.fn(), + }); + + renderWithProvider(); + + expect( + screen.queryByTestId(CashGetMusdEmptyStateSelectors.CLAIM_BONUS_BUTTON), + ).toBeNull(); + }); + + it('shows Claim bonus button when hideClaimButton is false and claimable reward exists', () => { + mockUseMerklBonusClaim.mockReturnValue({ + claimableReward: '12.34', + lifetimeBonusClaimed: null, + hasPendingClaim: false, + isClaiming: false, + error: null, + claimRewards: mockClaimRewards, + refetch: jest.fn(), + }); + + renderWithProvider(); + + expect( + screen.getByTestId(CashGetMusdEmptyStateSelectors.CLAIM_BONUS_BUTTON), + ).toBeOnTheScreen(); + }); + it('calls claimRewards and tracks analytics when Claim bonus is pressed', () => { mockUseMerklBonusClaim.mockReturnValue({ claimableReward: '1.00', diff --git a/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx b/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx index c19a7f29e076..ffd587379a37 100644 --- a/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx +++ b/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx @@ -59,6 +59,7 @@ import { useCashNavigation } from './useCashNavigation'; interface CashGetMusdEmptyStateProps { isFullView?: boolean; + hideClaimButton?: boolean; } /** @@ -69,6 +70,7 @@ interface CashGetMusdEmptyStateProps { */ const CashGetMusdEmptyState = ({ isFullView = false, + hideClaimButton = false, }: CashGetMusdEmptyStateProps) => { const tw = useTailwind(); const { toastRef } = useContext(ToastContext); @@ -339,7 +341,7 @@ const CashGetMusdEmptyState = ({ )} - {hasClaimableBonus ? ( + {hasClaimableBonus && !hideClaimButton ? (