diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c94beae0bc7..6996d572538 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,12 +10,9 @@ on: required: true type: string # android, ios, or both source_branch: - description: >- - Branch, tag, or SHA for prepare, node-modules setup, and build checkout (empty uses the default triggering ref). - When build_number is set, the bump is applied locally on each build runner after checkout. - required: false + description: 'Branch, tag, or SHA to build' + required: true type: string - default: '' build_number: description: >- Optional (workflow_call only). From generate-build-version.yml. When non-empty, each matrix @@ -43,10 +40,10 @@ on: description: 'platform input (android, ios, or both)' value: ${{ inputs.platform }} source_branch_input: - description: 'source_branch input (empty means default ref was used)' + description: 'source_branch input passed to this workflow' value: ${{ inputs.source_branch }} checkout_ref: - description: 'Git ref used for checkout (source_branch or triggering ref)' + description: 'Git ref used for checkout (same as source_branch)' value: ${{ jobs.emit-build-metadata.outputs.checkout_ref }} built_commit_sha: description: 'Resolved commit SHA at checkout_ref after build succeeded' @@ -62,6 +59,11 @@ on: value: ${{ jobs.emit-build-metadata.outputs.ios_version_code }} workflow_dispatch: inputs: + source_branch: + description: 'Branch, tag, or SHA to build' + required: true + type: string + default: 'main' build_name: required: true type: choice @@ -113,18 +115,18 @@ jobs: signing_aws_secret: ${{ steps.config.outputs.signing_aws_secret }} signing_android_keystore_path: ${{ steps.config.outputs.signing_android_keystore_path }} script_name: ${{ steps.config.outputs.script_name }} - checkout_ref_for_setup: ${{ inputs.source_branch || github.ref_name }} + checkout_ref_for_setup: ${{ inputs.source_branch }} steps: - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 if: ${{ inputs.runner_provider == 'namespace' }} with: fetch-depth: 1 - ref: ${{ inputs.source_branch || github.ref_name }} + ref: ${{ inputs.source_branch }} - uses: actions/checkout@v4 if: ${{ inputs.runner_provider != 'namespace' }} with: fetch-depth: 1 - ref: ${{ inputs.source_branch || github.ref_name }} + ref: ${{ inputs.source_branch }} - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -191,12 +193,12 @@ jobs: - uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 if: ${{ inputs.runner_provider == 'namespace' }} with: - ref: ${{ inputs.source_branch || github.ref_name }} + ref: ${{ inputs.source_branch }} submodules: recursive - uses: actions/checkout@v4 if: ${{ inputs.runner_provider != 'namespace' }} with: - ref: ${{ inputs.source_branch || github.ref_name }} + ref: ${{ inputs.source_branch }} submodules: recursive - name: Apply build number locally @@ -434,7 +436,7 @@ jobs: # Must match Apply build config / artifact naming so build.sh loads the same # builds.yml entry (e.g. main-e2e-bs-with-srp), not generic main-e2e (sim-only). BUILD_CONFIG_NAME: ${{ inputs.build_name }} - GIT_BRANCH: ${{ inputs.source_branch || github.ref_name }} + GIT_BRANCH: ${{ inputs.source_branch }} # React Native 0.81's ReactAndroid/build.gradle.kts requests CMake 3.30.5 # via `System.getenv("CMAKE_VERSION") ?: "3.30.5"`. The self-hosted runner # only ships CMake 3.22.1 in /opt/android-sdk/cmake/ and AGP cannot auto- diff --git a/.github/workflows/expo-dev-build.yml b/.github/workflows/expo-dev-build.yml index 81c45e98120..1ace1d5b3fe 100644 --- a/.github/workflows/expo-dev-build.yml +++ b/.github/workflows/expo-dev-build.yml @@ -40,5 +40,6 @@ jobs: with: build_name: main-dev-expo platform: both + source_branch: ${{ github.ref_name }} runner_provider: ${{ inputs.runner_provider }} secrets: inherit diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.tsx index 38b2b715223..d525a391f7b 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.tsx +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.tsx @@ -69,8 +69,8 @@ const MultichainAddressRow = ({ setIconState('copy'); }, 400); - // Show toast if ref provided - if (copyParams.toastRef?.current) { + // Show legacy row-managed toast only when both ref and message are provided. + if (copyParams.toastRef?.current && copyParams.toastMessage) { copyParams.toastRef.current.showToast({ variant: ToastVariants.Plain, labelOptions: [{ label: copyParams.toastMessage }], diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.types.ts b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.types.ts index a9834a7f3bc..3961cfbc1e1 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.types.ts +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddressRow/MultichainAddressRow.types.ts @@ -28,14 +28,13 @@ export interface CopyParams { */ callback: () => Promise; /** - * Technically optional to keep types simple but toast ref for showing toast notification - * should always be present if copyParams is used. This ensures consistent behavior + * Optional toast ref for legacy callers that need the row to show a toast. */ toastRef?: React.RefObject; /** - * Required toast message. Specify what is being copied e.g. "Address copied", "Private key copied", etc + * Toast message used when toastRef is provided. */ - toastMessage: string; + toastMessage?: string; } export interface MultichainAddressRowProps { diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 7be93d518b8..54dc4d65556 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -117,6 +117,7 @@ import { import { PredictScreenStack, PredictModalStack, + PredictPreviewSheetProvider, selectPredictEnabledFlag, } from '../../UI/Predict'; import { @@ -831,73 +832,87 @@ const HomeTabs = () => { }; return ( - - {/* Home Tab */} - - - {/* Explore Tab (w/ hidden browser) */} - <> + /* + * PredictPreviewSheetProvider is mounted here (above Tab.Navigator) so its + * BottomSheet renders inside the full-viewport Home Stack.Screen card. + * BottomSheet uses `absolute inset-0` (see + * @metamask/design-system-react-native) and would be clipped by an + * individual tab's content area if mounted lower in the tree. + * + * A nested provider in PredictScreenStack still shadows this one for + * usage; the registration stack in PredictPreviewSheetContext keeps only + * the innermost (most recently mounted) provider active for state-based + * Retry toasts so we don't double-fire when both are mounted. + */ + + + {/* Home Tab */} - [Routes.TRENDING_VIEW, Routes.BROWSER.HOME].includes( - rootScreenName, - ), - }} - component={ExploreHome} + name={Routes.WALLET.HOME} + options={options.home} + component={WalletTabStackFlow} /> - {children}} - /> - - {/* Trade Tab */} - + {/* Explore Tab (w/ hidden browser) */} + <> + + [Routes.TRENDING_VIEW, Routes.BROWSER.HOME].includes( + rootScreenName, + ), + }} + component={ExploreHome} + /> + {children}} + /> + - {/* Activity Tab (replaced by Money when feature flag is on) */} - {isMoneyHomeScreenEnabled ? ( + {/* Trade Tab */} - ) : ( + + {/* Activity Tab (replaced by Money when feature flag is on) */} + {isMoneyHomeScreenEnabled ? ( + + ) : ( + {children}} + /> + )} + + {/* Rewards Tab */} {children}} + name={Routes.REWARDS_VIEW} + options={options.rewards} + component={RewardsHome} + layout={({ children }) => UnmountOnBlurComponent(children)} /> - )} - - {/* Rewards Tab */} - UnmountOnBlurComponent(children)} - /> - + + ); }; diff --git a/app/components/Nav/Main/MainNavigator.test.tsx b/app/components/Nav/Main/MainNavigator.test.tsx index 69b2585ba50..df3ab890a9a 100644 --- a/app/components/Nav/Main/MainNavigator.test.tsx +++ b/app/components/Nav/Main/MainNavigator.test.tsx @@ -36,12 +36,20 @@ jest.mock('../../UI/Perps', () => ({ selectPerpsEnabledFlag: (state: unknown) => mockSelectPerpsEnabledFlag(state), })); -jest.mock('../../UI/Predict', () => ({ - PredictScreenStack: () => 'PredictScreenStack', - PredictModalStack: () => 'PredictModalStack', - selectPredictEnabledFlag: (state: unknown) => - mockSelectPredictEnabledFlag(state), -})); +jest.mock('../../UI/Predict', () => { + const { Fragment } = jest.requireActual('react'); + return { + PredictScreenStack: () => 'PredictScreenStack', + PredictModalStack: () => 'PredictModalStack', + PredictPreviewSheetProvider: ({ + children, + }: { + children: React.ReactNode; + }) => jest.requireActual('react').createElement(Fragment, null, children), + selectPredictEnabledFlag: (state: unknown) => + mockSelectPredictEnabledFlag(state), + }; +}); jest.mock('../../UI/MarketInsights', () => ({ MarketInsightsView: () => 'MarketInsightsView', diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index bec350ef54d..be42e7d32bd 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -63,6 +63,7 @@ jest.mock('@tanstack/react-query', () => ({ })); import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { strings } from '../../../../../../locales/i18n'; import { Alert, Linking } from 'react-native'; import { useSelector } from 'react-redux'; import React from 'react'; @@ -95,6 +96,7 @@ import { selectIsCardAuthenticated, selectCardholderAccounts, selectCardUserLocation, + selectCardHomeDataStatus, } from '../../../../../selectors/cardController'; import { useIsSwapEnabledForPriorityToken } from '../../hooks/useIsSwapEnabledForPriorityToken'; import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; @@ -299,6 +301,37 @@ jest.mock('../../hooks/useIsSwapEnabledForPriorityToken', () => ({ useIsSwapEnabledForPriorityToken: jest.fn(), })); +const mockStartMoneyAccountLinkFlow = jest.fn(); +const mockUseMoneyAccountCardLinkage = jest.fn(() => ({ + hasMoneyAccountRequirements: false, + isCardAuthenticated: false, + primaryMoneyAccount: undefined, + moneyAccountCardToken: null, + canLink: false, + status: 'idle' as const, + isLinking: false, + error: null, + startLinkFlow: mockStartMoneyAccountLinkFlow, + openLinkCardSheet: jest.fn(), + confirmLinkInBackground: jest.fn(), + reset: jest.fn(), +})); + +jest.mock('../../hooks/useMoneyAccountCardLinkage', () => ({ + __esModule: true, + useMoneyAccountCardLinkage: () => mockUseMoneyAccountCardLinkage(), + default: () => mockUseMoneyAccountCardLinkage(), +})); + +const mockUseMoneyAccountBalance = jest.fn(() => ({ + apyPercent: undefined as number | undefined, +})); + +jest.mock('../../../Money/hooks/useMoneyAccountBalance', () => ({ + __esModule: true, + default: () => mockUseMoneyAccountBalance(), +})); + const mockFetchCardDetailsToken = jest.fn(); const mockClearCardDetailsImageUrl = jest.fn(); const mockOnCardDetailsImageLoad = jest.fn(); @@ -512,7 +545,7 @@ const mockIsSolanaChainId = isSolanaChainId as jest.MockedFunction< >; jest.mock('../../../../../../locales/i18n', () => ({ - strings: (key: string) => { + strings: (key: string, params?: Record) => { const strings: { [key: string]: string } = { 'card.card_home.spending_with': 'Spending with', 'card.card_home.add_funds': 'Add funds', @@ -583,8 +616,28 @@ jest.mock('../../../../../../locales/i18n', () => ({ 'Earn 1% back on all spending', 'card.card_home.manage_card_options.cashback_description_metal': 'Earn 3% back on all spending', + 'money.metamask_card.link_title': 'Link MetaMask Card', + 'money.metamask_card.link_card': 'Link card', + 'money.metamask_card.link_subtitle_no_apy': + 'Spend your Money balance and earn on purchases.', }; - return strings[key] || key; + const value = strings[key]; + if (value) return value; + if (key === 'money.metamask_card.link_subtitle') { + const apy = (params as { apy?: number | string } | undefined)?.apy; + return `Spend your Money balance and earn on purchases. Plus, up to ${apy}% APY on your balance.`; + } + if (key === 'money.metamask_card.link_bullet_cashback') { + const percentage = ( + params as { percentage?: number | string } | undefined + )?.percentage; + return `Get ${percentage}% mUSD back`; + } + if (key === 'money.metamask_card.link_bullet_apy') { + const apy = (params as { apy?: number | string } | undefined)?.apy; + return `Earn up to ${apy}% APY`; + } + return key; }, })); @@ -626,6 +679,7 @@ function setupMockSelectors( isAuthenticated: boolean; userLocation: 'us' | 'international'; isMetalCardCheckoutEnabled: boolean; + cardHomeDataStatus: 'idle' | 'loading' | 'success' | 'error'; }>, ) { const defaults = { @@ -638,6 +692,7 @@ function setupMockSelectors( isAuthenticated: false, userLocation: 'international' as const, isMetalCardCheckoutEnabled: true, + cardHomeDataStatus: 'success' as const, }; const config = { ...defaults, ...overrides }; @@ -652,6 +707,7 @@ function setupMockSelectors( if (selector === selectCardholderAccounts) return config.cardholderAccounts; if (selector === selectIsCardAuthenticated) return config.isAuthenticated; if (selector === selectCardUserLocation) return config.userLocation; + if (selector === selectCardHomeDataStatus) return config.cardHomeDataStatus; if (selector === selectMetalCardCheckoutFeatureFlag) return config.isMetalCardCheckoutEnabled; @@ -6178,4 +6234,181 @@ describe('CardHome Component', () => { expect(screen.queryByTestId(CardHomeSelectors.LOGOUT_ITEM)).toBeNull(); }); }); + + describe('Link Money Account CTA', () => { + const setupLinkageMock = ( + overrides: Partial<{ canLink: boolean }> = {}, + ) => { + mockUseMoneyAccountCardLinkage.mockReturnValue({ + hasMoneyAccountRequirements: true, + isCardAuthenticated: true, + primaryMoneyAccount: undefined, + moneyAccountCardToken: null, + canLink: true, + status: 'idle' as const, + isLinking: false, + error: null, + startLinkFlow: mockStartMoneyAccountLinkFlow, + openLinkCardSheet: jest.fn(), + confirmLinkInBackground: jest.fn(), + reset: jest.fn(), + ...overrides, + }); + }; + + it('renders the CTA when canLink is true and cardHomeDataStatus is success', () => { + setupMockSelectors({ cardHomeDataStatus: 'success' }); + setupLinkageMock(); + + render(); + + expect( + screen.getByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_TOP), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_BOTTOM), + ).toBeOnTheScreen(); + expect( + screen.getByText(strings('money.metamask_card.link_title')), + ).toBeOnTheScreen(); + expect( + screen.getByText(strings('money.metamask_card.link_card')), + ).toBeOnTheScreen(); + }); + + it('does not render the CTA when canLink is false', () => { + setupMockSelectors({ cardHomeDataStatus: 'success' }); + setupLinkageMock({ canLink: false }); + + render(); + + expect( + screen.queryByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_TOP), + ).not.toBeOnTheScreen(); + expect( + screen.queryByText(strings('money.metamask_card.link_title')), + ).not.toBeOnTheScreen(); + }); + + it('keeps the CTA visible during a background refresh (stale-while-revalidate)', () => { + setupMockSelectors({ cardHomeDataStatus: 'loading' }); + setupLinkageMock(); + + render(); + + expect( + screen.getByTestId(CardHomeSelectors.LINK_MONEY_ACCOUNT_DIVIDER_TOP), + ).toBeOnTheScreen(); + }); + + it('calls startLinkFlow with Routes.CARD.HOME when the Link card button is pressed', () => { + setupMockSelectors({ cardHomeDataStatus: 'success' }); + setupLinkageMock(); + + render(); + + fireEvent.press( + screen.getByText(strings('money.metamask_card.link_card')), + ); + + expect(mockStartMoneyAccountLinkFlow).toHaveBeenCalledWith({ + screen: Routes.CARD.HOME, + }); + }); + + it('calls startLinkFlow when the title row (header) is pressed', () => { + setupMockSelectors({ cardHomeDataStatus: 'success' }); + setupLinkageMock(); + + render(); + + fireEvent.press( + screen.getByText(strings('money.metamask_card.link_title')), + ); + + expect(mockStartMoneyAccountLinkFlow).toHaveBeenCalledWith({ + screen: Routes.CARD.HOME, + }); + }); + + it('advertises 1% mUSD back for virtual cardholders', () => { + setupMockSelectors({ cardHomeDataStatus: 'success' }); + setupLinkageMock(); + mockUseMoneyAccountBalance.mockReturnValue({ apyPercent: 4 }); + + render(); + + expect(screen.getByText('Get 1% mUSD back')).toBeOnTheScreen(); + expect(screen.queryByText('Get 3% mUSD back')).not.toBeOnTheScreen(); + }); + + it('advertises 3% mUSD back for metal cardholders', () => { + setupMockSelectors({ cardHomeDataStatus: 'success' }); + setupLinkageMock(); + setupLoadCardDataMock(); + (useCardHomeData as jest.Mock).mockReturnValue({ + data: { + primaryFundingAsset: mockPrimaryFundingAsset, + fundingAssets: [mockPrimaryFundingAsset], + availableFundingAssets: [mockPrimaryFundingAsset], + card: { + id: 'card-123', + status: 'ACTIVE', + lastFour: '1234', + type: CardType.METAL, + }, + account: null, + alerts: [], + actions: [{ type: 'add_funds', enabled: true }], + }, + primaryToken: mockPrimaryAssetWithBalance, + availableTokens: [mockPrimaryAssetWithBalance], + fundingTokens: [mockPrimaryAssetWithBalance], + balanceMap: createMockAssetBalancesMap({ + balanceFiat: '$1,000.00', + asset: { symbol: 'USDC', image: 'usdc-image-url' }, + balanceFormatted: '1000.000000 USDC', + rawTokenBalance: 1000, + rawFiatNumber: 1000, + }), + isLoading: false, + isError: false, + refetch: mockRefetchAllData, + }); + mockUseMoneyAccountBalance.mockReturnValue({ apyPercent: 4 }); + + render(); + + expect(screen.getByText('Get 3% mUSD back')).toBeOnTheScreen(); + expect(screen.queryByText('Get 1% mUSD back')).not.toBeOnTheScreen(); + }); + + it('renders the no-APY subtitle and omits the APY bullet when apyPercent is undefined', () => { + setupMockSelectors({ cardHomeDataStatus: 'success' }); + setupLinkageMock(); + mockUseMoneyAccountBalance.mockReturnValue({ apyPercent: undefined }); + + render(); + + expect( + screen.getByText(strings('money.metamask_card.link_subtitle_no_apy')), + ).toBeOnTheScreen(); + expect(screen.queryByText(/Earn up to .* APY/)).not.toBeOnTheScreen(); + }); + + it('interpolates apyPercent into the subtitle and APY bullet when defined', () => { + setupMockSelectors({ cardHomeDataStatus: 'success' }); + setupLinkageMock(); + mockUseMoneyAccountBalance.mockReturnValue({ apyPercent: 4 }); + + render(); + + expect( + screen.getByText( + strings('money.metamask_card.link_subtitle', { apy: 4 }), + ), + ).toBeOnTheScreen(); + expect(screen.getByText('Earn up to 4% APY')).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts b/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts index cd3abde52a5..0283ba73f04 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts +++ b/app/components/UI/Card/Views/CardHome/CardHome.testIds.ts @@ -31,4 +31,6 @@ export const CardHomeSelectors = { FREEZE_CARD_TOGGLE: 'freeze-card-toggle', VIEW_PIN_BUTTON: 'view-pin-button', CARD_WALLET_ADDRESS: 'card-wallet-address', + LINK_MONEY_ACCOUNT_DIVIDER_TOP: 'link-money-account-divider-top', + LINK_MONEY_ACCOUNT_DIVIDER_BOTTOM: 'link-money-account-divider-bottom', }; diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 00891ee0243..42073cdd024 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -45,6 +45,9 @@ import { selectMetalCardCheckoutFeatureFlag } from '../../../../../selectors/fea import { useIsSwapEnabledForPriorityToken } from '../../hooks/useIsSwapEnabledForPriorityToken'; import { useCardHomeData } from '../../hooks/useCardHomeData'; import { useCardCapabilities } from '../../hooks/useCardCapabilities'; +import { useMoneyAccountCardLinkage } from '../../hooks/useMoneyAccountCardLinkage'; +import useMoneyAccountBalance from '../../../Money/hooks/useMoneyAccountBalance'; +import MoneyMetaMaskCard from '../../../Money/components/MoneyMetaMaskCard'; import { ToastContext, ToastVariants, @@ -55,6 +58,7 @@ import { CardScreenshotDeterrent } from '../../components/CardScreenshotDeterren import AnimatedSpinner from '../../../AnimatedSpinner'; import Routes from '../../../../../constants/navigation/Routes'; import { TOKEN_RATE_UNDEFINED } from '../../../Tokens/constants'; +import { CardType } from '../../types'; import { isSpendingLimitSupportedToken } from '../../constants'; import { CardHomeSelectors } from './CardHome.testIds'; import CardAlertSection from './components/CardAlertSection'; @@ -110,6 +114,16 @@ const CardHome = () => { const { initiateProvisioning, isProvisioning, canAddToWallet } = useCardProvisioning(data); + // --- Money Account linkage --- + const { canLink: canLinkMoneyAccount, startLinkFlow: startMoneyAccountLink } = + useMoneyAccountCardLinkage(); + const { apyPercent: moneyAccountApyPercent } = useMoneyAccountBalance(); + const hasMetalCard = data?.card?.type === CardType.METAL; + const handleLinkMoneyAccountCard = useCallback( + () => startMoneyAccountLink({ screen: Routes.CARD.HOME }), + [startMoneyAccountLink], + ); + useCardHomeAnalytics({ data, isLoading, @@ -370,6 +384,30 @@ const CardHome = () => { )} + {canLinkMoneyAccount && ( + <> + + + + + + + )} + { expect(mockLinkMoneyAccountCard).not.toHaveBeenCalled(); expect(mockShowToast).toHaveBeenCalledTimes(1); expect(mockShowToast.mock.calls[0][0]).toMatchObject({ - labelOptions: [{ label: "Couldn't link card", isBold: true }], + labelOptions: [ + { label: 'Something went wrong linking your card', isBold: true }, + ], }); }); @@ -654,7 +656,7 @@ describe('useMoneyAccountCardLinkage', () => { expect(mockNavigate).not.toHaveBeenCalled(); }); - it('shows the Predict-style pending toast with a Spinner startAccessory before the success toast', async () => { + it('shows the single-line pending toast with a Spinner startAccessory before the success toast', async () => { let resolveLink: () => void = () => undefined; mockLinkMoneyAccountCard.mockReturnValueOnce( new Promise((resolve) => { @@ -672,11 +674,7 @@ describe('useMoneyAccountCardLinkage', () => { const pendingCall = mockShowToast.mock.calls[0]?.[0]; expect(pendingCall).toMatchObject({ hasNoTimeout: true, - labelOptions: [ - { label: 'Linking card', isBold: true }, - { label: '\n', isBold: false }, - { label: 'Approving spending limit…', isBold: false }, - ], + labelOptions: [{ label: 'Linking your card', isBold: true }], }); expect(pendingCall?.startAccessory).toBeDefined(); @@ -687,11 +685,7 @@ describe('useMoneyAccountCardLinkage', () => { const successCall = mockShowToast.mock.calls[1]?.[0]; expect(successCall).toMatchObject({ - labelOptions: [ - { label: 'Card linked successfully', isBold: true }, - { label: '\n', isBold: false }, - { label: 'You can now spend while you earn', isBold: false }, - ], + labelOptions: [{ label: 'Your card is ready to use', isBold: true }], hasNoTimeout: false, }); }); @@ -747,7 +741,9 @@ describe('useMoneyAccountCardLinkage', () => { const errorCall = mockShowToast.mock.calls.at(-1)?.[0]; expect(errorCall).toMatchObject({ - labelOptions: [{ label: "Couldn't link card", isBold: true }], + labelOptions: [ + { label: 'Something went wrong linking your card', isBold: true }, + ], hasNoTimeout: false, }); }); @@ -787,7 +783,9 @@ describe('useMoneyAccountCardLinkage', () => { expect(mockLinkMoneyAccountCard).not.toHaveBeenCalled(); expect(mockShowToast).toHaveBeenCalledTimes(1); expect(mockShowToast.mock.calls[0][0]).toMatchObject({ - labelOptions: [{ label: "Couldn't link card", isBold: true }], + labelOptions: [ + { label: 'Something went wrong linking your card', isBold: true }, + ], }); }); }); diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx index 49f33584d7e..a6c6c8fb6d6 100644 --- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx +++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx @@ -138,18 +138,13 @@ export const useMoneyAccountCardLinkage = label: strings('money.metamask_card.link_pending_title'), isBold: true, }, - { label: '\n', isBold: false }, - { - label: strings('money.metamask_card.link_pending_description'), - isBold: false, - }, ], iconName: IconName.Loading, hasNoTimeout: true, startAccessory: ( @@ -165,11 +160,6 @@ export const useMoneyAccountCardLinkage = label: strings('money.metamask_card.link_success_title'), isBold: true, }, - { label: '\n', isBold: false }, - { - label: strings('money.metamask_card.link_success_description'), - isBold: false, - }, ], iconName: IconName.Confirmation, iconColor: theme.colors.success.default, @@ -183,7 +173,7 @@ export const useMoneyAccountCardLinkage = labelOptions: [ { label: strings('money.metamask_card.link_error'), isBold: true }, ], - iconName: IconName.Danger, + iconName: IconName.Error, iconColor: theme.colors.error.default, backgroundColor: theme.colors.error.muted, hasNoTimeout: false, diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx index 230950da973..d1ec397d7dc 100644 --- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx +++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx @@ -900,12 +900,6 @@ const EarnInputView = () => { navigateToLearnMoreModal, ]); - useEffect(() => { - navigation.setOptions({ - headerShown: false, - }); - }, [navigation]); - const headerTitle = useMemo(() => { const isLending = earnToken?.experience?.type === EARN_EXPERIENCES.STABLECOIN_LENDING; diff --git a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.styles.ts b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.styles.ts index a5dd4acbcac..58d6d4363e6 100644 --- a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.styles.ts +++ b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.styles.ts @@ -2,9 +2,10 @@ import { StyleSheet } from 'react-native'; const styleSheet = () => StyleSheet.create({ - illustration: { - width: '100%', - height: '100%', + cardImage: { + width: 150, + height: 95, + borderRadius: 5, }, }); diff --git a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx index cc10f7ed915..63b3ff30a3e 100644 --- a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx +++ b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx @@ -6,9 +6,11 @@ import { MoneyLinkCardSheetTestIds } from './MoneyLinkCardSheet.testIds'; import { strings } from '../../../../../../locales/i18n'; import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountCardLinkage'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; +import { selectCardHomeData } from '../../../../../selectors/cardController'; +import { CardType } from '../../../Card/types'; +import mmCardRegular from '../../../../../images/mm_card_regular.png'; +import mmCardMetal from '../../../../../images/mm_card_metal.png'; -// The real sheet ref invokes the post-close callback after the dismiss -// animation. We bypass animation in tests by invoking the callback inline. const mockOnCloseBottomSheet = jest.fn((cb?: () => void) => cb?.()); const mockGoBack = jest.fn(); @@ -31,6 +33,10 @@ jest.mock('../../hooks/useMoneyAccountBalance', () => ({ default: jest.fn(), })); +jest.mock('../../../../../selectors/cardController', () => ({ + selectCardHomeData: jest.fn(), +})); + jest.mock('@metamask/design-system-react-native', () => { const actual = jest.requireActual('@metamask/design-system-react-native'); const ReactActual = jest.requireActual('react'); @@ -61,6 +67,7 @@ const mockUseMoneyAccountCardLinkage = >; const mockUseMoneyAccountBalance = useMoneyAccountBalance as jest.MockedFunction; +const mockSelectCardHomeData = selectCardHomeData as unknown as jest.Mock; describe('MoneyLinkCardSheet', () => { let mockConfirmLinkInBackground: jest.Mock; @@ -74,6 +81,9 @@ describe('MoneyLinkCardSheet', () => { mockUseMoneyAccountBalance.mockReturnValue({ apyPercent: 4, } as unknown as ReturnType); + mockSelectCardHomeData.mockReturnValue({ + card: { type: CardType.VIRTUAL }, + }); }); it('renders the container', () => { @@ -128,18 +138,62 @@ describe('MoneyLinkCardSheet', () => { expect(queryByText(/{{apy}}/)).toBeNull(); }); - it('falls back to 0% APY while the vault APY query has not resolved yet', () => { + it('falls back to no-APY copy when the vault APY query has not resolved yet', () => { mockUseMoneyAccountBalance.mockReturnValue({ apyPercent: undefined, } as unknown as ReturnType); - const { getByText } = renderWithProvider(); + const { getByText, queryByText } = renderWithProvider( + , + ); expect( getByText( - strings('money.metamask_card.link_card_sheet_description', { apy: 0 }), + strings('money.metamask_card.link_card_sheet_description_no_apy'), ), ).toBeOnTheScreen(); + // The APY-bearing copy must not appear when there is no APY. + expect(queryByText(/APY/)).toBeNull(); + }); + + describe('card illustration adapts to user card type', () => { + const getCardImageSource = ( + root: ReturnType, + ) => { + const illustration = root.getByTestId( + MoneyLinkCardSheetTestIds.ILLUSTRATION, + ); + const image = illustration.findByProps({ resizeMode: 'contain' }); + return image.props.source; + }; + + it('renders the metal card image when the user has a metal card', () => { + mockSelectCardHomeData.mockReturnValue({ + card: { type: CardType.METAL }, + }); + + const root = renderWithProvider(); + + expect(getCardImageSource(root)).toBe(mmCardMetal); + }); + + it('renders the virtual card image when the user has a virtual card', () => { + mockSelectCardHomeData.mockReturnValue({ + card: { type: CardType.VIRTUAL }, + }); + + const root = renderWithProvider(); + + expect(getCardImageSource(root)).toBe(mmCardRegular); + }); + + it('renders the virtual card image when there is no card data available', () => { + mockSelectCardHomeData.mockReturnValue(null); + + const root = renderWithProvider(); + + expect(getCardImageSource(root)).toBe(mmCardRegular); + }); }); it('dismisses the sheet and dispatches confirmLinkInBackground when the CTA is pressed', () => { diff --git a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx index 01a27b47289..a78923bb2cf 100644 --- a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx +++ b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useRef } from 'react'; import { Image } from 'react-native'; import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; import { BottomSheet, BottomSheetFooter, @@ -15,9 +16,12 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import { useStyles } from '../../../../../component-library/hooks'; +import { selectCardHomeData } from '../../../../../selectors/cardController'; import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountCardLinkage'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; -import musdCoinImage from '../../../../../images/mm_usd.png'; +import { CardType } from '../../../Card/types'; +import mmCardRegular from '../../../../../images/mm_card_regular.png'; +import mmCardMetal from '../../../../../images/mm_card_metal.png'; import styleSheet from './MoneyLinkCardSheet.styles'; import { MoneyLinkCardSheetTestIds } from './MoneyLinkCardSheet.testIds'; @@ -36,6 +40,8 @@ const MoneyLinkCardSheet = () => { const { styles } = useStyles(styleSheet, {}); const { confirmLinkInBackground } = useMoneyAccountCardLinkage(); const { apyPercent } = useMoneyAccountBalance(); + const cardHomeData = useSelector(selectCardHomeData); + const isMetalCard = cardHomeData?.card?.type === CardType.METAL; const handleGoBack = useCallback(() => { navigation.goBack(); @@ -51,6 +57,13 @@ const MoneyLinkCardSheet = () => { }); }, [confirmLinkInBackground]); + const description = + apyPercent === undefined + ? strings('money.metamask_card.link_card_sheet_description_no_apy') + : strings('money.metamask_card.link_card_sheet_description', { + apy: apyPercent, + }); + return ( { onClose={handleClose} closeButtonProps={{ testID: MoneyLinkCardSheetTestIds.CLOSE_BUTTON }} /> - + @@ -88,9 +100,7 @@ const MoneyLinkCardSheet = () => { twClassName="text-center" testID={MoneyLinkCardSheetTestIds.DESCRIPTION} > - {strings('money.metamask_card.link_card_sheet_description', { - apy: apyPercent ?? 0, - })} + {description} diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx index 2e13aad4a28..00fe8430e4c 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx @@ -217,6 +217,95 @@ describe('MoneyMetaMaskCard', () => { fireEvent.press(getByText(strings('money.metamask_card.link_title'))); expect(mockHeader).toHaveBeenCalled(); }); + + describe('hideCardImage', () => { + it('does not render the card image when hideCardImage is true', () => { + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.LINK_CARD_IMAGE), + ).not.toBeOnTheScreen(); + }); + + it('still renders cashback / APY bullets and the Link card button when hideCardImage is true', () => { + const { getByTestId, getByText } = render( + , + ); + + expect( + getByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_CASHBACK), + ).toBeOnTheScreen(); + expect( + getByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_APY), + ).toBeOnTheScreen(); + expect(getByText('Get 1% mUSD back')).toBeOnTheScreen(); + expect(getByText('Earn up to 4% APY')).toBeOnTheScreen(); + expect( + getByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON), + ).toBeOnTheScreen(); + }); + }); + + describe('apy undefined (no-APY copy)', () => { + it('renders link_subtitle_no_apy when apy is undefined', () => { + const { getByText, queryByText } = render( + , + ); + + expect( + getByText(strings('money.metamask_card.link_subtitle_no_apy')), + ).toBeOnTheScreen(); + expect( + queryByText(strings('money.metamask_card.link_subtitle', { apy: 0 })), + ).not.toBeOnTheScreen(); + }); + + it('omits the APY bullet when apy is undefined', () => { + const { queryByTestId, getByTestId } = render( + , + ); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_APY), + ).not.toBeOnTheScreen(); + expect( + getByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_CASHBACK), + ).toBeOnTheScreen(); + }); + + it('combines hideCardImage and apy undefined into the Card Home variant', () => { + const { getByText, queryByTestId } = render( + , + ); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.LINK_CARD_IMAGE), + ).not.toBeOnTheScreen(); + expect( + queryByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_APY), + ).not.toBeOnTheScreen(); + expect( + getByText(strings('money.metamask_card.link_subtitle_no_apy')), + ).toBeOnTheScreen(); + expect(getByText('Get 1% mUSD back')).toBeOnTheScreen(); + }); + }); }); describe('mode="manage"', () => { diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx index bc2a93eccc7..b68fbe9adec 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx @@ -50,9 +50,16 @@ interface MoneyMetaMaskCardProps { cardBalance?: string; /** * Live vault APY used to interpolate the link-mode subtitle and the APY - * bullet. Falls back to 0 when undefined so the copy stays grammatical. + * bullet. When `undefined`, the component falls back to APY-less copy + * (drops the APY clause from the subtitle and omits the APY bullet). */ apy?: number; + /** + * Link mode only: when true, the card image is omitted and the bullets are + * stacked vertically. Used by Card Home where the card image is already + * shown elsewhere on the screen. + */ + hideCardImage?: boolean; } const CardRow = ({ @@ -129,55 +136,80 @@ const LinkContent = ({ onLinkPress, showMetalCard, apy, + hideCardImage, }: { onLinkPress: () => void; showMetalCard: boolean; - apy: number; -}) => ( - - - {strings('money.metamask_card.link_subtitle', { apy })} - - - - - - - + apy: number | undefined; + hideCardImage: boolean; +}) => { + const hasApy = apy !== undefined; + const subtitle = hasApy + ? strings('money.metamask_card.link_subtitle', { apy }) + : strings('money.metamask_card.link_subtitle_no_apy'); + const cashbackBullet = ( + + ); + const apyBullet = hasApy ? ( + + ) : null; + + return ( + + + {subtitle} + + {hideCardImage ? ( + + {cashbackBullet} + {apyBullet} + + ) : ( + + + + {cashbackBullet} + {apyBullet} + + + )} + - - -); + ); +}; const ManageRow = ({ imageSource, @@ -288,6 +320,7 @@ const MoneyMetaMaskCard = ({ showMetalCard = false, cardBalance, apy, + hideCardImage = false, }: MoneyMetaMaskCardProps) => { const handleLinkPress = useCallback(() => onLinkPress?.(), [onLinkPress]); const handleManagePress = useCallback( @@ -301,7 +334,8 @@ const MoneyMetaMaskCard = ({ ); } else if (mode === 'manage') { diff --git a/app/components/UI/Predict/assets/world-cup-main-feed-banner.png b/app/components/UI/Predict/assets/world-cup-main-feed-banner.png index cb408e32646..a0ef125f434 100644 Binary files a/app/components/UI/Predict/assets/world-cup-main-feed-banner.png and b/app/components/UI/Predict/assets/world-cup-main-feed-banner.png differ diff --git a/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.test.tsx b/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.test.tsx index cb2ae959c6d..cff1ec16a63 100644 --- a/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.test.tsx +++ b/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.test.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react-native'; import PredictCryptoUpDownChart, { + CRYPTO_UP_DOWN_FORMAT_TIME, CRYPTO_UP_DOWN_FORMAT_VALUE, } from './PredictCryptoUpDownChart'; import { useCryptoUpDownChartData } from '../../hooks/useCryptoUpDownChartData'; +import { usePredictOrderbook } from '../../hooks/usePredictOrderbook'; import { Recurrence, type PredictMarket, @@ -14,6 +16,10 @@ jest.mock('../../hooks/useCryptoUpDownChartData', () => ({ useCryptoUpDownChartData: jest.fn(), })); +jest.mock('../../hooks/usePredictOrderbook', () => ({ + usePredictOrderbook: jest.fn(), +})); + jest.mock('../../../Charts/LivelineChart', () => { const { View } = jest.requireActual('react-native'); const { forwardRef } = jest.requireActual('react'); @@ -61,6 +67,7 @@ const createMockMarket = (): PredictMarket & { series: PredictSeries } => describe('PredictCryptoUpDownChart', () => { const mockUseCryptoUpDownChartData = useCryptoUpDownChartData as jest.Mock; + const mockUsePredictOrderbook = usePredictOrderbook as jest.Mock; beforeEach(() => { jest.clearAllMocks(); @@ -71,6 +78,11 @@ describe('PredictCryptoUpDownChart', () => { isLive: true, window: 300, }); + mockUsePredictOrderbook.mockReturnValue({ + orderbook: null, + loading: false, + isConnected: false, + }); }); it('does not render LivelineChart when height is 0', () => { @@ -81,7 +93,7 @@ describe('PredictCryptoUpDownChart', () => { expect(screen.queryByTestId('mock-liveline-chart')).not.toBeOnTheScreen(); }); - it('renders LivelineChart with correct props when data is available and height is greater than 0', () => { + it('forwards chart configuration props to LivelineChart when chart data is available', () => { const market = createMockMarket(); render(); @@ -107,6 +119,7 @@ describe('PredictCryptoUpDownChart', () => { expect(chart.props.badge).toBe(true); expect(chart.props.padding).toEqual({ top: 8, bottom: 48 }); expect(chart.props.formatValue).toBe(CRYPTO_UP_DOWN_FORMAT_VALUE); + expect(chart.props.formatTime).toBe(CRYPTO_UP_DOWN_FORMAT_TIME); }); it('passes a custom chart color to LivelineChart', () => { @@ -337,6 +350,92 @@ describe('PredictCryptoUpDownChart', () => { expect(onCurrentPriceChange).not.toHaveBeenCalled(); }); + describe('orderbook wiring', () => { + const marketWithYesToken = (): PredictMarket & { series: PredictSeries } => + ({ + ...createMockMarket(), + outcomes: [ + { + id: 'outcome-1', + providerId: 'polymarket', + marketId: 'market-1', + title: 'Up', + description: '', + image: '', + status: 'open', + tokens: [ + { id: 'yes-token-id', title: 'Up', price: 0.5, status: 'open' }, + { id: 'no-token-id', title: 'Down', price: 0.5, status: 'open' }, + ], + volume: 0, + groupItemTitle: '', + }, + ], + }) as unknown as PredictMarket & { series: PredictSeries }; + + it("invokes usePredictOrderbook with the YES outcome token's id", () => { + const market = marketWithYesToken(); + + render(); + + expect(mockUsePredictOrderbook).toHaveBeenCalledWith('yes-token-id'); + }); + + it('invokes usePredictOrderbook with undefined when the market has no outcomes', () => { + const market = createMockMarket(); + + render(); + + expect(mockUsePredictOrderbook).toHaveBeenCalledWith(undefined); + }); + + it('forwards the orderbook prop to LivelineChart when the hook returns data', () => { + const market = marketWithYesToken(); + const orderbook = { + bids: [[0.45, 100] as [number, number]], + asks: [[0.55, 100] as [number, number]], + }; + mockUsePredictOrderbook.mockReturnValue({ + orderbook, + loading: false, + isConnected: true, + }); + + render(); + + const container = screen.getByTestId( + 'predict-crypto-up-down-chart-container', + ); + fireEvent(container, 'layout', { + nativeEvent: { layout: { height: 300 } }, + }); + + const chart = screen.getByTestId('mock-liveline-chart'); + expect(chart.props.orderbook).toBe(orderbook); + }); + + it('passes orderbook=undefined to LivelineChart when the hook returns null', () => { + const market = marketWithYesToken(); + mockUsePredictOrderbook.mockReturnValue({ + orderbook: null, + loading: true, + isConnected: false, + }); + + render(); + + const container = screen.getByTestId( + 'predict-crypto-up-down-chart-container', + ); + fireEvent(container, 'layout', { + nativeEvent: { layout: { height: 300 } }, + }); + + const chart = screen.getByTestId('mock-liveline-chart'); + expect(chart.props.orderbook).toBeUndefined(); + }); + }); + describe('CRYPTO_UP_DOWN_FORMAT_VALUE', () => { // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func const formatValue = new Function('v', CRYPTO_UP_DOWN_FORMAT_VALUE) as ( @@ -346,6 +445,7 @@ describe('PredictCryptoUpDownChart', () => { it.each([ [0, '$0.00'], [0.05, '$0.05'], + [0.5, '$0.50'], [1, '$1.00'], [999.5, '$999.50'], [1000, '$1,000.00'], @@ -358,4 +458,34 @@ describe('PredictCryptoUpDownChart', () => { expect(formatValue(input)).toBe(expected); }); }); + + describe('CRYPTO_UP_DOWN_FORMAT_TIME', () => { + // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func + const formatTime = new Function('t', CRYPTO_UP_DOWN_FORMAT_TIME) as ( + t: number, + ) => string; + + // Tests are TZ-agnostic: inputs are constructed from local-time Date + // objects so the formatter's `getHours()` (local time) round-trips to + // the expected 12-hour `h:mm:ss` output regardless of the test + // machine's timezone. + const toUnixSeconds = ( + year: number, + month: number, + day: number, + hours: number, + minutes: number, + seconds: number, + ) => new Date(year, month, day, hours, minutes, seconds).getTime() / 1000; + + it.each([ + ['midnight local', toUnixSeconds(2024, 0, 1, 0, 0, 0), '12:00:00'], + ['noon local', toUnixSeconds(2024, 0, 1, 12, 0, 0), '12:00:00'], + ['1:30:45 PM local', toUnixSeconds(2024, 0, 1, 13, 30, 45), '1:30:45'], + ['9:05:07 AM local', toUnixSeconds(2024, 0, 1, 9, 5, 7), '9:05:07'], + ['11:59:59 PM local', toUnixSeconds(2024, 0, 1, 23, 59, 59), '11:59:59'], + ])('formats %s as %p', (_label, input, expected) => { + expect(formatTime(input)).toBe(expected); + }); + }); }); diff --git a/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.tsx b/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.tsx index 20b37f378c2..318dfc21a65 100644 --- a/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.tsx +++ b/app/components/UI/Predict/components/PredictCryptoUpDownChart/PredictCryptoUpDownChart.tsx @@ -1,19 +1,19 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Box } from '@metamask/design-system-react-native'; -import { - LivelineChart, - type LivelineChartRef, -} from '../../../Charts/LivelineChart'; +import { LivelineChart } from '../../../Charts/LivelineChart'; import { useCryptoUpDownChartData } from '../../hooks/useCryptoUpDownChartData'; +import { usePredictOrderbook } from '../../hooks/usePredictOrderbook'; import type { PredictCryptoUpDownChartProps } from './PredictCryptoUpDownChart.types'; /** * USD currency formatter body for `LivelineChart` axis/tooltip values, e.g. - * `1234567.89` → `"$1,234,567.89"`. Serialised as a JS function body string - * because functions cannot cross the RN ↔ WebView JSON bridge — the WebView - * reconstructs it via `new Function('v', CRYPTO_UP_DOWN_FORMAT_VALUE)`. - * Exact output is locked by a regression test in - * `PredictCryptoUpDownChart.test.tsx` since drift only surfaces on device. + * `1234567.89` → `"$1,234,567.89"`. Keeps two decimals to match the CTA + * price display on the details and feed cards (see PR #30342). Serialised + * as a JS function body string because functions cannot cross the RN ↔ + * WebView JSON bridge — the WebView reconstructs it via + * `new Function('v', CRYPTO_UP_DOWN_FORMAT_VALUE)`. Exact output is locked + * by a regression test in `PredictCryptoUpDownChart.test.tsx` since drift + * only surfaces on device. */ export const CRYPTO_UP_DOWN_FORMAT_VALUE = "const sign = v < 0 ? '-' : ''; " + @@ -21,6 +21,19 @@ export const CRYPTO_UP_DOWN_FORMAT_VALUE = "parts[0] = parts[0].replace(/\\B(?=(\\d{3})+(?!\\d))/g, ','); " + "return sign + '$' + parts.join('.')"; +/** + * 12-hour `h:mm:ss` time formatter body for `LivelineChart` time-axis + * labels (e.g. `8:48:30`). Compact enough to fit the live 30s window + * without label overlap. Same bridge constraint as + * `CRYPTO_UP_DOWN_FORMAT_VALUE`. + */ +export const CRYPTO_UP_DOWN_FORMAT_TIME = + 'const d = new Date(t * 1000); ' + + 'const h = d.getHours() % 12 || 12; ' + + "const m = String(d.getMinutes()).padStart(2, '0'); " + + "const s = String(d.getSeconds()).padStart(2, '0'); " + + "return h + ':' + m + ':' + s"; + const PredictCryptoUpDownChart: React.FC = ({ market, targetPrice, @@ -28,7 +41,6 @@ const PredictCryptoUpDownChart: React.FC = ({ color = 'rgb(245, 158, 11)', height: explicitHeight, }) => { - const chartRef = useRef(null); const [measuredHeight, setMeasuredHeight] = useState(0); const { data, @@ -37,6 +49,9 @@ const PredictCryptoUpDownChart: React.FC = ({ window: chartWindow, } = useCryptoUpDownChartData(market, targetPrice); + const outcomeTokenId = market.outcomes?.[0]?.tokens?.[0]?.id; + const { orderbook } = usePredictOrderbook(outcomeTokenId); + const chartHeight = explicitHeight ?? measuredHeight; // Override liveline's momentum so the price badge (and direction arrows) color @@ -71,7 +86,6 @@ const PredictCryptoUpDownChart: React.FC = ({ > {chartHeight > 0 && ( = ({ referenceLine={ targetPrice ? { value: targetPrice, label: 'Target' } : undefined } + // Coalesce null → undefined so JSON.stringify in the WebView + // bridge omits the key entirely when there is no book yet. null + // would otherwise serialize and clobber any prior orderbook in + // the WebView. + orderbook={orderbook ?? undefined} formatValue={CRYPTO_UP_DOWN_FORMAT_VALUE} + formatTime={CRYPTO_UP_DOWN_FORMAT_TIME} /> )} diff --git a/app/components/UI/Predict/components/PredictCryptoUpDownDetails/PredictCryptoUpDownDetails.tsx b/app/components/UI/Predict/components/PredictCryptoUpDownDetails/PredictCryptoUpDownDetails.tsx index 8d1bb5cf119..a521619852a 100644 --- a/app/components/UI/Predict/components/PredictCryptoUpDownDetails/PredictCryptoUpDownDetails.tsx +++ b/app/components/UI/Predict/components/PredictCryptoUpDownDetails/PredictCryptoUpDownDetails.tsx @@ -48,8 +48,14 @@ import PredictCryptoUpDownChart from '../PredictCryptoUpDownChart'; import PredictMarketDetailsActions from '../../views/PredictMarketDetails/components/PredictMarketDetailsActions'; import { useOpenOutcomes } from '../../views/PredictMarketDetails/hooks/useOpenOutcomes'; +// Chart sizing tuned for the Figma layout: the chart should occupy roughly +// the middle half of the viewport so the dot stays centred, the price +// summary stays visible above, and the action buttons stay visible below. +// Bounds clamp the chart on very short (e.g. landscape) or very tall +// (e.g. iPad) viewports. const CHART_HEIGHT_MIN = 420; const CHART_HEIGHT_MAX = 560; +const CHART_HEIGHT_VIEWPORT_FRACTION = 0.55; const MARKET_ROLLOVER_TIMEOUT_MAX_MS = 2_147_483_647; const NOOP = () => undefined; const DEFAULT_CRYPTO_ACCENT_COLOR = 'rgb(245, 158, 11)'; @@ -118,7 +124,10 @@ const PredictCryptoUpDownDetails: React.FC = ({ const { height: windowHeight } = useWindowDimensions(); const chartAreaHeight = Math.min( CHART_HEIGHT_MAX, - Math.max(CHART_HEIGHT_MIN, Math.round(windowHeight * 0.55)), + Math.max( + CHART_HEIGHT_MIN, + Math.round(windowHeight * CHART_HEIGHT_VIEWPORT_FRACTION), + ), ); const [selectedMarket, setSelectedMarket] = useState(market); diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx index 66fbe3ee82f..22be213b5fd 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx @@ -238,6 +238,31 @@ describe('PredictMarketMultiple', () => { expect(getByText('75%')).toBeOnTheScreen(); }); + it('renders outcomes provided by the feed model without price-based filtering', () => { + const pinnedOutcomeMarket: PredictMarket = { + ...mockMarket, + outcomes: [ + { + ...mockMarket.outcomes[0], + id: 'pinned-outcome', + groupItemTitle: 'Pinned Outcome', + tokens: [ + { id: 'token-yes', title: 'Yes', price: 1 }, + { id: 'token-no', title: 'No', price: 0 }, + ], + }, + ], + }; + + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('Pinned Outcome')).toBeOnTheScreen(); + expect(getByText('>99%')).toBeOnTheScreen(); + }); + it('handle market with recurrence', () => { const marketWithRecurrence: PredictMarket = { ...mockMarket, diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx index 3dab46dbe3b..378ace5f16c 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx @@ -85,10 +85,7 @@ const PredictMarketMultiple: React.FC = ({ navigation, }); - // filter resolved outcomes - const filteredOutcomes = market.outcomes.filter( - (outcome) => outcome.tokens[0].price !== 0 && outcome.tokens[0].price !== 1, - ); + const displayOutcomes = market.outcomes; const getOutcomePercentage = ( outcomePrices?: number[], @@ -217,7 +214,7 @@ const PredictMarketMultiple: React.FC = ({ - {filteredOutcomes.slice(0, 3).map((outcome) => { + {displayOutcomes.slice(0, 3).map((outcome) => { const outcomeLabels = outcome.tokens.map((token) => token.title); return ( = ({ numberOfLines={1} style={tw.style('flex-shrink min-w-0')} > - {filteredOutcomes.length > 3 - ? `+${filteredOutcomes.length - 3} ${ - filteredOutcomes.length - 3 === 1 + {displayOutcomes.length > 3 + ? `+${displayOutcomes.length - 3} ${ + displayOutcomes.length - 3 === 1 ? strings('predict.outcomes_singular') : strings('predict.outcomes_plural') }` diff --git a/app/components/UI/Predict/components/PredictWorldCupMainFeedBanner/PredictWorldCupMainFeedBanner.test.tsx b/app/components/UI/Predict/components/PredictWorldCupMainFeedBanner/PredictWorldCupMainFeedBanner.test.tsx index 526e949205b..4ac7e58dcce 100644 --- a/app/components/UI/Predict/components/PredictWorldCupMainFeedBanner/PredictWorldCupMainFeedBanner.test.tsx +++ b/app/components/UI/Predict/components/PredictWorldCupMainFeedBanner/PredictWorldCupMainFeedBanner.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { StyleSheet } from 'react-native'; +import { Dimensions, StyleSheet } from 'react-native'; import { fireEvent, render } from '@testing-library/react-native'; import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; @@ -7,6 +7,7 @@ import Routes from '../../../../../constants/navigation/Routes'; import { DEFAULT_PREDICT_WORLD_CUP_FLAG } from '../../constants/flags'; import { PredictEventValues } from '../../constants/eventNames'; import PredictWorldCupMainFeedBanner, { + getPredictWorldCupBannerImageAspectRatio, getPredictWorldCupBannerSource, } from './PredictWorldCupMainFeedBanner'; import { PredictWorldCupMainFeedBannerSelectorsIDs } from './PredictWorldCupMainFeedBanner.testIds'; @@ -19,19 +20,6 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -jest.mock('react-native', () => { - const actualReactNative = jest.requireActual('react-native'); - return { - ...actualReactNative, - useWindowDimensions: jest.fn(() => ({ - width: 393, - height: 852, - scale: 3, - fontScale: 1, - })), - }; -}); - const mockUseNavigation = useNavigation as jest.Mock; const mockUseSelector = useSelector as jest.Mock; const mockNavigate = jest.fn(); @@ -75,18 +63,24 @@ describe('PredictWorldCupMainFeedBanner', () => { ).toBeOnTheScreen(); }); - it('uses the remote banner image URL when configured', () => { + it('uses the remote banner image URL and configured dimensions when configured', () => { const bannerImageUrl = 'https://example.com/world-cup-banner.png'; mockUseSelector.mockReturnValue({ ...enabledConfig, - bannerImageUrl, + bannerImage: { + url: bannerImageUrl, + width: 300, + height: 100, + }, }); const { getByTestId } = render(); const image = getByTestId(PredictWorldCupMainFeedBannerSelectorsIDs.IMAGE); expect(image.props.source).toStrictEqual({ uri: bannerImageUrl }); - expect(StyleSheet.flatten(image.props.style).height).toBeGreaterThan(0); + expect(StyleSheet.flatten(image.props.style).height).toBeCloseTo( + (Dimensions.get('window').width - 32) / 3, + ); }); it('does not render when the main feed banner is disabled', () => { @@ -159,10 +153,39 @@ describe('PredictWorldCupMainFeedBanner', () => { }); }); +describe('getPredictWorldCupBannerImageAspectRatio', () => { + it('returns configured image aspect ratio when dimensions are provided', () => { + expect( + getPredictWorldCupBannerImageAspectRatio({ + url: 'https://example.com/banner.png', + width: 300, + height: 100, + }), + ).toBe(3); + }); + + it('returns default image aspect ratio when dimensions are missing', () => { + expect(getPredictWorldCupBannerImageAspectRatio()).toBe(2); + }); + + it('returns default image aspect ratio when dimensions are invalid', () => { + expect( + getPredictWorldCupBannerImageAspectRatio({ + url: 'https://example.com/banner.png', + width: 0, + height: -200, + }), + ).toBe(2); + }); +}); + describe('getPredictWorldCupBannerSource', () => { it('returns a trimmed remote URI source before the fallback image source', () => { expect( - getPredictWorldCupBannerSource(' https://example.com/banner.png ', 1), + getPredictWorldCupBannerSource( + { url: ' https://example.com/banner.png ', width: 400, height: 200 }, + 1, + ), ).toStrictEqual({ uri: 'https://example.com/banner.png' }); }); diff --git a/app/components/UI/Predict/components/PredictWorldCupMainFeedBanner/PredictWorldCupMainFeedBanner.tsx b/app/components/UI/Predict/components/PredictWorldCupMainFeedBanner/PredictWorldCupMainFeedBanner.tsx index baccde05d72..b92e2f68e63 100644 --- a/app/components/UI/Predict/components/PredictWorldCupMainFeedBanner/PredictWorldCupMainFeedBanner.tsx +++ b/app/components/UI/Predict/components/PredictWorldCupMainFeedBanner/PredictWorldCupMainFeedBanner.tsx @@ -3,11 +3,23 @@ import { Image, ImageSourcePropType, Pressable, + View, useWindowDimensions, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + ButtonIcon, + ButtonIconSize, + ButtonIconVariant, + FontWeight, + IconName, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import Engine from '../../../../../core/Engine'; import { selectPredictWorldCupConfig } from '../../selectors/featureFlags'; @@ -17,16 +29,16 @@ import { PredictWorldCupMainFeedBannerSelectorsIDs } from './PredictWorldCupMain import worldCupMainFeedBannerImage from '../../assets/world-cup-main-feed-banner.png'; -const WORLD_CUP_BANNER_ASPECT_RATIO = 360 / 177; +const WORLD_CUP_BANNER_DEFAULT_IMAGE_ASPECT_RATIO = 2; const WORLD_CUP_BANNER_HORIZONTAL_MARGIN = 16; const WORLD_CUP_BANNER_HORIZONTAL_MARGIN_TOTAL = WORLD_CUP_BANNER_HORIZONTAL_MARGIN * 2; export const getPredictWorldCupBannerSource = ( - bannerImageUrl?: string, + bannerImage?: PredictWorldCupConfig['bannerImage'], fallbackImageSource?: ImageSourcePropType, ): ImageSourcePropType | undefined => { - const trimmedBannerImageUrl = bannerImageUrl?.trim(); + const trimmedBannerImageUrl = bannerImage?.url.trim(); if (trimmedBannerImageUrl) { return { uri: trimmedBannerImageUrl }; @@ -35,6 +47,16 @@ export const getPredictWorldCupBannerSource = ( return fallbackImageSource; }; +export const getPredictWorldCupBannerImageAspectRatio = ( + bannerImage?: PredictWorldCupConfig['bannerImage'], +): number => { + if (bannerImage && bannerImage.width > 0 && bannerImage.height > 0) { + return bannerImage.width / bannerImage.height; + } + + return WORLD_CUP_BANNER_DEFAULT_IMAGE_ASPECT_RATIO; +}; + interface PredictWorldCupMainFeedBannerProps { fallbackImageSource?: ImageSourcePropType | null; } @@ -60,7 +82,10 @@ const PredictWorldCupMainFeedBanner: React.FC< windowWidth - WORLD_CUP_BANNER_HORIZONTAL_MARGIN_TOTAL, 0, ); - const bannerHeight = bannerWidth / WORLD_CUP_BANNER_ASPECT_RATIO; + const bannerImageAspectRatio = getPredictWorldCupBannerImageAspectRatio( + predictWorldCupConfig.bannerImage, + ); + const bannerImageHeight = bannerWidth / bannerImageAspectRatio; const resolvedFallbackImageSource = fallbackImageSource === undefined @@ -71,7 +96,7 @@ const PredictWorldCupMainFeedBanner: React.FC< () => shouldRenderBanner(predictWorldCupConfig) ? getPredictWorldCupBannerSource( - predictWorldCupConfig.bannerImageUrl, + predictWorldCupConfig.bannerImage, resolvedFallbackImageSource, ) : undefined, @@ -114,12 +139,38 @@ const PredictWorldCupMainFeedBanner: React.FC< style={tw.style('mx-4 pb-3')} testID={PredictWorldCupMainFeedBannerSelectorsIDs.CONTAINER} > - + + + + + + {strings('predict.world_cup.banner_title')} + + + {strings('predict.world_cup.banner_description')} + + + + + ); }; diff --git a/app/components/UI/Predict/contexts/PredictPreviewSheetContext.test.tsx b/app/components/UI/Predict/contexts/PredictPreviewSheetContext.test.tsx index cd4a0e67aab..764a59435b8 100644 --- a/app/components/UI/Predict/contexts/PredictPreviewSheetContext.test.tsx +++ b/app/components/UI/Predict/contexts/PredictPreviewSheetContext.test.tsx @@ -616,6 +616,168 @@ describe('PredictPreviewSheetContext', () => { }); }); + describe('multi-provider dedup', () => { + // Mirrors production reality: HomeTabs mounts a sheet-mode provider above + // Tab.Navigator (so the BottomSheet's parent is the full-viewport stack + // card), and PredictScreenStack mounts another sheet-mode provider when + // the user navigates into the Predict tab. Both stay mounted while inside + // Predict. The toast effect must fire from the topmost (most recently + // mounted, innermost in the tree) provider only. + + it('fires the failure toast only from the topmost (most recently mounted) sheet-mode provider', () => { + const outer = render( + + + , + ); + // Outer "remembers" buy params from a prior open + dismiss. + fireEvent.press(outer.getByTestId('open-buy')); + fireEvent.press(outer.getByTestId('dismiss-sheet')); + + const inner = render( + + + , + ); + // Inner also remembers buy params from a prior open + dismiss. + fireEvent.press(inner.getByTestId('open-buy')); + fireEvent.press(inner.getByTestId('dismiss-sheet')); + + mockActiveOrder = { error: 'order/failed' }; + outer.rerender( + + + , + ); + inner.rerender( + + + , + ); + + expect(mockToastShowToast).toHaveBeenCalledTimes(1); + + outer.unmount(); + inner.unmount(); + }); + + it('outer provider becomes active after inner unmounts and fires for the next error transition', () => { + const outer = render( + + + , + ); + fireEvent.press(outer.getByTestId('open-buy')); + fireEvent.press(outer.getByTestId('dismiss-sheet')); + + const inner = render( + + + , + ); + fireEvent.press(inner.getByTestId('open-buy')); + fireEvent.press(inner.getByTestId('dismiss-sheet')); + + // First error transition — only inner (topmost) fires. + mockActiveOrder = { error: 'order/failed' }; + outer.rerender( + + + , + ); + inner.rerender( + + + , + ); + expect(mockToastShowToast).toHaveBeenCalledTimes(1); + + // Inner unmounts — outer becomes the topmost provider. + inner.unmount(); + + // Drive a fresh falsy -> truthy transition for the outer provider so + // its `previousErrorRef` flips correctly. + mockActiveOrder = null; + outer.rerender( + + + , + ); + mockActiveOrder = { error: 'order/failed-2' }; + outer.rerender( + + + , + ); + + expect(mockToastShowToast).toHaveBeenCalledTimes(2); + + outer.unmount(); + }); + + it('outer (sheet-mode) provider fires when the inner provider is mounted with disableBottomSheet', () => { + const outer = render( + + + , + ); + fireEvent.press(outer.getByTestId('open-buy')); + fireEvent.press(outer.getByTestId('dismiss-sheet')); + + // Disabled provider mounts but does NOT register, so outer remains + // the topmost (and only) sheet-mode provider. + const navInner = render( + + + , + ); + + mockActiveOrder = { error: 'order/failed' }; + outer.rerender( + + + , + ); + + expect(mockToastShowToast).toHaveBeenCalledTimes(1); + + navInner.unmount(); + outer.unmount(); + }); + + it('shouldSuppressLegacyOrderFailureToast tracks the topmost provider after unmount order', () => { + const outer = render( + + + , + ); + // Outer alone, no opens — suppression off. + expect(shouldSuppressLegacyOrderFailureToast()).toBe(false); + + fireEvent.press(outer.getByTestId('open-buy')); + expect(shouldSuppressLegacyOrderFailureToast()).toBe(true); + + // Inner mounts on top of outer — its `lastBuyParamsRef` is null, + // so suppression flips back to false until inner has its own open. + const inner = render( + + + , + ); + expect(shouldSuppressLegacyOrderFailureToast()).toBe(false); + + fireEvent.press(inner.getByTestId('open-buy')); + expect(shouldSuppressLegacyOrderFailureToast()).toBe(true); + + // Unmounting inner falls back to outer (still has params). + inner.unmount(); + expect(shouldSuppressLegacyOrderFailureToast()).toBe(true); + + outer.unmount(); + expect(shouldSuppressLegacyOrderFailureToast()).toBe(false); + }); + }); + describe('failure toast auto-clear timer', () => { beforeEach(() => { jest.useFakeTimers(); @@ -695,13 +857,30 @@ describe('PredictPreviewSheetContext', () => { expect(shouldSuppressLegacyOrderFailureToast()).toBe(false); }); - it('returns true while provider is mounted and false after unmount', () => { + it('returns false while provider is mounted but no sheet has been opened yet', () => { + // Suppression is gated on the topmost provider's `lastBuyParamsRef` so + // the legacy toast keeps firing for tabs/flows where the user has not + // initiated a sheet (e.g. order failure surfaces from elsewhere). const { unmount } = render( , ); + expect(shouldSuppressLegacyOrderFailureToast()).toBe(false); + + unmount(); + }); + + it('returns true after openBuySheet is called and false after unmount', () => { + const { unmount } = render( + + + , + ); + + fireEvent.press(screen.getByTestId('open-buy')); + expect(shouldSuppressLegacyOrderFailureToast()).toBe(true); unmount(); @@ -734,12 +913,16 @@ describe('PredictPreviewSheetContext', () => { }); it('stays true when disableBottomSheet provider unmounts while sheet-mode provider is still mounted', () => { - const { unmount: unmountSheet } = render( + const sheetRender = render( , ); - const { unmount: unmountNav } = render( + // Open a buy sheet on the sheet-mode provider so it has remembered + // params (`hasBuyParams()` now gates suppression). + fireEvent.press(sheetRender.getByTestId('open-buy')); + + const navRender = render( , @@ -748,10 +931,10 @@ describe('PredictPreviewSheetContext', () => { expect(shouldSuppressLegacyOrderFailureToast()).toBe(true); // Unmounting the navigate-mode provider must not clear the sheet-mode one - unmountNav(); + navRender.unmount(); expect(shouldSuppressLegacyOrderFailureToast()).toBe(true); - unmountSheet(); + sheetRender.unmount(); expect(shouldSuppressLegacyOrderFailureToast()).toBe(false); }); }); diff --git a/app/components/UI/Predict/contexts/PredictPreviewSheetContext.tsx b/app/components/UI/Predict/contexts/PredictPreviewSheetContext.tsx index 32c15b7220b..3b4ca17e8a4 100644 --- a/app/components/UI/Predict/contexts/PredictPreviewSheetContext.tsx +++ b/app/components/UI/Predict/contexts/PredictPreviewSheetContext.tsx @@ -51,23 +51,53 @@ import { usePredictActiveOrder } from '../hooks/usePredictActiveOrder'; import { PredictDismissalMethod } from '../constants/eventNames'; import { parseAnalyticsProperties } from '../utils/analytics'; -// Reference counter instead of booleans — multiple providers can be mounted -// simultaneously (e.g. PredictScreenStack + HomepageDiscoveryTabs), so a -// single flag would race on mount/unmount. -let _providerSheetModeCount = 0; +// Registration stack of sheet-mode providers — multiple providers can be +// mounted simultaneously (e.g. HomeTabs + PredictScreenStack when the user +// navigates from Explore into Predict), so a single counter cannot tell us +// which one is "active". The top of the stack (most recently mounted, i.e. +// innermost in the tree) is the only provider that should fire its +// state-based Retry toast — earlier-mounted providers stay silent to avoid +// duplicate toasts for the same `activeOrder.error` transition. +interface SheetModeProviderEntry { + id: number; + hasBuyParams: () => boolean; +} + +let _sheetModeProviders: SheetModeProviderEntry[] = []; +let _nextSheetModeProviderId = 0; + +function registerSheetModeProvider(hasBuyParams: () => boolean): number { + const id = ++_nextSheetModeProviderId; + _sheetModeProviders = [..._sheetModeProviders, { id, hasBuyParams }]; + return id; +} + +function unregisterSheetModeProvider(id: number): void { + _sheetModeProviders = _sheetModeProviders.filter((entry) => entry.id !== id); +} + +function isActiveSheetModeProvider(id: number): boolean { + return _sheetModeProviders[_sheetModeProviders.length - 1]?.id === id; +} /** - * Returns true when at least one `PredictPreviewSheetProvider` in sheet mode - * is active. Used by `usePredictToastRegistrations` to decide whether to - * suppress the legacy order-failure toast — when a sheet-mode provider is - * present, its own state-based Retry toast handles the failure and the plain - * toast would be a duplicate. + * Returns true only when the active (top-of-stack) sheet-mode provider has + * remembered buy params and will therefore surface its own Retry toast. + * Used by `usePredictToastRegistrations` to decide whether to suppress the + * legacy order-failure toast. * - * Note: a provider mounted with `disableBottomSheet` does NOT count, because - * it never shows the Retry sheet and the legacy toast must still fire. + * Checking `hasBuyParams()` (rather than just "any provider mounted") + * avoids suppressing the legacy toast when no sheet-mode provider is + * positioned to fire — e.g. the active provider is HomeTabs but the user + * just initiated the order via a `disableBottomSheet` provider that + * shadowed it (so the outer never had `openBuySheet` called on it). + * + * Note: a provider mounted with `disableBottomSheet` does NOT register, + * because it never shows the Retry sheet. */ export function shouldSuppressLegacyOrderFailureToast(): boolean { - return _providerSheetModeCount > 0; + const top = _sheetModeProviders[_sheetModeProviders.length - 1]; + return Boolean(top?.hasBuyParams()); } const SellSheetHeader: React.FC<{ params: PredictSellPreviewParams }> = ({ @@ -224,16 +254,29 @@ export const PredictPreviewSheetProvider: React.FC< */ const clearErrorTimerRef = useRef | null>(null); + /** + * Module-level registration id for this provider instance. Set on mount + * (when not disabled) and used to guard the failure-toast effect so only + * the topmost (most recently mounted) provider fires. + */ + const providerIdRef = useRef(null); + const hasBuyParams = useCallback(() => lastBuyParamsRef.current !== null, []); + useEffect(() => { - if (!disableBottomSheet) _providerSheetModeCount += 1; + if (!disableBottomSheet) { + providerIdRef.current = registerSheetModeProvider(hasBuyParams); + } return () => { - if (!disableBottomSheet) _providerSheetModeCount -= 1; + if (providerIdRef.current !== null) { + unregisterSheetModeProvider(providerIdRef.current); + providerIdRef.current = null; + } if (clearErrorTimerRef.current) { clearTimeout(clearErrorTimerRef.current); clearErrorTimerRef.current = null; } }; - }, [disableBottomSheet]); + }, [disableBottomSheet, hasBuyParams]); const openBuySheet = useCallback( (params: PredictBuyPreviewParams) => { @@ -315,6 +358,19 @@ export const PredictPreviewSheetProvider: React.FC< return; } + // When multiple sheet-mode providers are mounted simultaneously (e.g. + // HomeTabs + PredictScreenStack while the user is inside the Predict + // stack), only the topmost (most recently mounted, innermost in the + // tree) provider should fire the toast — earlier-mounted providers + // also hold their own `lastBuyParamsRef` and would otherwise duplicate + // the toast (and the `clearOrderError` timer). + if ( + providerIdRef.current === null || + !isActiveSheetModeProvider(providerIdRef.current) + ) { + return; + } + const lastParams = lastBuyParamsRef.current; // Use `closeButtonOptions` (with `ButtonVariants.Link`) rather than // `linkButtonOptions` so the Retry sits inline on the right of the row diff --git a/app/components/UI/Predict/controllers/PredictController-method-action-types.ts b/app/components/UI/Predict/controllers/PredictController-method-action-types.ts index 9823b5e9a7f..2f04135ef7a 100644 --- a/app/components/UI/Predict/controllers/PredictController-method-action-types.ts +++ b/app/components/UI/Predict/controllers/PredictController-method-action-types.ts @@ -171,6 +171,20 @@ export type PredictControllerSubscribeToMarketPricesAction = { handler: PredictController['subscribeToMarketPrices']; }; +/** + * Subscribes to real-time orderbook (depth) updates for a single outcome + * token via WebSocket. The first emission is seeded from a REST snapshot by + * the provider so consumers render immediately. + * + * @param tokenId - The outcome token ID to subscribe to orderbook updates for + * @param callback - Function invoked with each OrderbookSnapshot (bids desc, asks asc) + * @returns Unsubscribe function to clean up the subscription + */ +export type PredictControllerSubscribeToOrderbookAction = { + type: `PredictController:subscribeToOrderbook`; + handler: PredictController['subscribeToOrderbook']; +}; + /** * Subscribes to real-time crypto price updates via RTDS WebSocket. * @@ -314,6 +328,7 @@ export type PredictControllerMethodActions = | PredictControllerRefreshEligibilityAction | PredictControllerSubscribeToGameUpdatesAction | PredictControllerSubscribeToMarketPricesAction + | PredictControllerSubscribeToOrderbookAction | PredictControllerSubscribeToCryptoPricesAction | PredictControllerGetConnectionStatusAction | PredictControllerClearOrderErrorAction diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index db0e18d1ba2..eec6a10e429 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -2198,6 +2198,9 @@ describe('PredictController', () => { expect(result.markets[1].id).toBe('highlight-2'); expect(result.markets[2].id).toBe('regular-1'); expect(result.markets[3].id).toBe('regular-2'); + expect(result.markets[0].isHighlighted).toBe(true); + expect(result.markets[1].isHighlighted).toBe(true); + expect(result.markets[2].isHighlighted).toBeUndefined(); expect(result.nextCursor).toBe('next-cursor'); expect(mockPolymarketProvider.getMarketsByIds).toHaveBeenCalledWith([ 'highlight-1', @@ -2375,6 +2378,7 @@ describe('PredictController', () => { expect(result.markets[0].id).toBe('duplicate-market'); expect(result.markets[1].id).toBe('regular-1'); expect(result.markets[2].id).toBe('regular-2'); + expect(result.markets[0].isHighlighted).toBe(true); }, { mocks: { @@ -2642,6 +2646,8 @@ describe('PredictController', () => { expect(result.markets[0].id).toBe('highlight-2'); expect(result.markets[1].id).toBe('highlight-1'); expect(result.markets[2].id).toBe('regular-1'); + expect(result.markets[0].isHighlighted).toBe(true); + expect(result.markets[1].isHighlighted).toBeUndefined(); }, { mocks: { @@ -2690,6 +2696,7 @@ describe('PredictController', () => { expect(result.markets).toHaveLength(2); expect(result.markets[0].id).toBe('highlight-2'); expect(result.markets[1].id).toBe('regular-1'); + expect(result.markets[0].isHighlighted).toBe(true); expect( result.markets.find((m) => m.id === 'highlight-1'), ).toBeUndefined(); @@ -8041,6 +8048,43 @@ describe('PredictController', () => { }); }); + describe('subscribeToOrderbook', () => { + it('delegates to provider and returns unsubscribe function', () => { + withController(({ controller }) => { + const mockUnsubscribe = jest.fn(); + const mockCallback = jest.fn(); + mockPolymarketProvider.subscribeToOrderbook = jest + .fn() + .mockReturnValue(mockUnsubscribe); + + const unsubscribe = controller.subscribeToOrderbook( + 'token1', + mockCallback, + ); + + expect( + mockPolymarketProvider.subscribeToOrderbook, + ).toHaveBeenCalledWith('token1', mockCallback); + expect(unsubscribe).toBe(mockUnsubscribe); + }); + }); + + it('returns no-op function when provider lacks method', () => { + withController(({ controller }) => { + delete (mockPolymarketProvider as { subscribeToOrderbook?: unknown }) + .subscribeToOrderbook; + + const unsubscribe = controller.subscribeToOrderbook( + 'token1', + jest.fn(), + ); + + expect(unsubscribe).toBeDefined(); + expect(unsubscribe()).toBeUndefined(); + }); + }); + }); + describe('subscribeToCryptoPrices', () => { it('delegates to provider and returns unsubscribe function', () => { withController(({ controller }) => { diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 75c01f2891c..1fd8feda8ff 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -98,6 +98,7 @@ import { PrepareWithdrawParams, PreviewOrderParams, PriceUpdateCallback, + OrderbookCallback, Result, SearchMarketsParams, Side, @@ -385,6 +386,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'subscribeToCryptoPrices', 'subscribeToGameUpdates', 'subscribeToMarketPrices', + 'subscribeToOrderbook', 'trackActivityViewed', 'trackBannerAction', 'trackBetslipDismissed', @@ -622,9 +624,12 @@ export class PredictController extends BaseController< (await this.provider.getMarketsByIds?.(highlightedMarketIds)) ?? []; - const highlightedMarkets = fetchedHighlightedMarkets.filter( - (market) => market.status === 'open', - ); + const highlightedMarkets = fetchedHighlightedMarkets + .filter((market) => market.status === 'open') + .map((market) => ({ + ...market, + isHighlighted: true, + })); const highlightedIdSet = new Set( highlightedMarkets.map((m) => m.id), @@ -1747,6 +1752,26 @@ export class PredictController extends BaseController< return provider.subscribeToMarketPrices(tokenIds, callback); } + /** + * Subscribes to real-time orderbook (depth) updates for a single outcome + * token via WebSocket. The first emission is seeded from a REST snapshot by + * the provider so consumers render immediately. + * + * @param tokenId - The outcome token ID to subscribe to orderbook updates for + * @param callback - Function invoked with each OrderbookSnapshot (bids desc, asks asc) + * @returns Unsubscribe function to clean up the subscription + */ + public subscribeToOrderbook( + tokenId: string, + callback: OrderbookCallback, + ): () => void { + const provider = this.provider; + if (!provider?.subscribeToOrderbook) { + return () => undefined; + } + return provider.subscribeToOrderbook(tokenId, callback); + } + /** * Subscribes to real-time crypto price updates via RTDS WebSocket. * diff --git a/app/components/UI/Predict/hooks/index.ts b/app/components/UI/Predict/hooks/index.ts index 96ddc3ac68c..e406c29deb9 100644 --- a/app/components/UI/Predict/hooks/index.ts +++ b/app/components/UI/Predict/hooks/index.ts @@ -17,6 +17,12 @@ export { type UseLiveMarketPricesResult, } from './useLiveMarketPrices'; +export { + usePredictOrderbook, + type UsePredictOrderbookOptions, + type UsePredictOrderbookResult, +} from './usePredictOrderbook'; + export { usePredictTabs, type FeedTab, diff --git a/app/components/UI/Predict/hooks/useCryptoUpDownChartData.test.ts b/app/components/UI/Predict/hooks/useCryptoUpDownChartData.test.ts index 90286066a2b..a82d5c70b90 100644 --- a/app/components/UI/Predict/hooks/useCryptoUpDownChartData.test.ts +++ b/app/components/UI/Predict/hooks/useCryptoUpDownChartData.test.ts @@ -3,10 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react-native'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useCryptoUpDownChartData } from './useCryptoUpDownChartData'; import type { CryptoPriceUpdate, PredictMarket, PredictSeries } from '../types'; -import type { - LivelineChartRef, - LivelinePoint, -} from '../../Charts/LivelineChart/LivelineChart.types'; +import type { LivelinePoint } from '../../Charts/LivelineChart/LivelineChart.types'; const mockCryptoPriceHistoryOptions = jest.fn(); const mockUseLiveCryptoPrices = jest.fn(); @@ -82,13 +79,6 @@ const createMarket = (overrides: Partial = {}): TestMarket => ({ ...overrides, }); -const createMockChartRef = () => ({ - current: { - appendPoint: jest.fn(), - clearData: jest.fn(), - } as LivelineChartRef, -}); - describe('useCryptoUpDownChartData', () => { let liveUpdateHandler: ((update: CryptoPriceUpdate) => void) | undefined; let historicalData: LivelinePoint[]; @@ -149,7 +139,6 @@ describe('useCryptoUpDownChartData', () => { it('returns loading true when no live data has arrived', () => { const { Wrapper } = createWrapper(); const market = createMarket(); - const chartRef = createMockChartRef(); const { result } = renderHook(() => useCryptoUpDownChartData(market), { wrapper: Wrapper, @@ -163,7 +152,6 @@ describe('useCryptoUpDownChartData', () => { it('adds live data points to the returned chart data', () => { const { Wrapper } = createWrapper(); const market = createMarket(); - const chartRef = createMockChartRef(); historicalData = []; const { result } = renderHook(() => useCryptoUpDownChartData(market), { @@ -183,7 +171,6 @@ describe('useCryptoUpDownChartData', () => { }); }); - expect(chartRef.current.appendPoint).not.toHaveBeenCalled(); expect(result.current.data).toEqual([ { time: 100, value: 51000 }, { time: 110, value: 51500 }, @@ -195,7 +182,6 @@ describe('useCryptoUpDownChartData', () => { it('preserves second-based live timestamps', () => { const { Wrapper } = createWrapper(); const market = createMarket(); - const chartRef = createMockChartRef(); historicalData = []; const { result } = renderHook(() => useCryptoUpDownChartData(market), { @@ -216,7 +202,6 @@ describe('useCryptoUpDownChartData', () => { it('converts millisecond-based live timestamps to fractional seconds', () => { const { Wrapper } = createWrapper(); const market = createMarket(); - const chartRef = createMockChartRef(); historicalData = []; const { result } = renderHook(() => useCryptoUpDownChartData(market), { @@ -240,7 +225,6 @@ describe('useCryptoUpDownChartData', () => { jest.setSystemTime(new Date(1700000000000)); const { Wrapper } = createWrapper(); const market = createMarket(); - const chartRef = createMockChartRef(); historicalData = []; const { result } = renderHook(() => useCryptoUpDownChartData(market), { @@ -269,10 +253,32 @@ describe('useCryptoUpDownChartData', () => { ]); }); + it('parses millisecond timestamps from live updates', () => { + jest.setSystemTime(new Date(1700000000000)); + const { Wrapper } = createWrapper(); + const market = createMarket(); + historicalData = []; + + const { result } = renderHook(() => useCryptoUpDownChartData(market), { + wrapper: Wrapper, + }); + + act(() => { + liveUpdateHandler?.({ + symbol: 'btcusdt', + price: 51000, + timestamp: 1700000000123, + }); + }); + + expect(result.current.data).toEqual([ + { time: 1700000000.123, value: 51000 }, + ]); + }); + it('evicts live points outside the 30-second chart retention buffer', () => { const { Wrapper } = createWrapper(); const market = createMarket(); - const chartRef = createMockChartRef(); historicalData = []; const { result } = renderHook(() => useCryptoUpDownChartData(market), { @@ -312,7 +318,6 @@ describe('useCryptoUpDownChartData', () => { id: 'market-2', endDate: '2025-12-31T23:59:59.000Z', }); - const chartRef = createMockChartRef(); historicalData = []; const { result, rerender } = renderHook( @@ -343,7 +348,6 @@ describe('useCryptoUpDownChartData', () => { const market = createMarket({ endDate: '2026-01-01T00:00:30.000Z', }); - const chartRef = createMockChartRef(); historicalData = []; const { result } = renderHook(() => useCryptoUpDownChartData(market), { @@ -409,7 +413,6 @@ describe('useCryptoUpDownChartData', () => { id: 'market-2', endDate: '2026-01-01T00:00:30.000Z', }); - const chartRef = createMockChartRef(); historicalData = []; const { result, rerender } = renderHook( @@ -439,7 +442,6 @@ describe('useCryptoUpDownChartData', () => { it('seeds live mode with historical data before live updates arrive', async () => { const { Wrapper } = createWrapper(); const market = createMarket(); - const chartRef = createMockChartRef(); const { result } = renderHook(() => useCryptoUpDownChartData(market), { wrapper: Wrapper, @@ -461,7 +463,6 @@ describe('useCryptoUpDownChartData', () => { recurrence: '4h', }, }); - const chartRef = createMockChartRef(); const { result } = renderHook( () => @@ -598,10 +599,154 @@ describe('useCryptoUpDownChartData', () => { }); }); + it('returns historical recurrence-window data without subscribing when live updates are disabled', async () => { + const { Wrapper } = createWrapper(); + const market = createMarket({ + series: { + id: 'series-4h', + slug: 'btc-series-4h', + title: 'BTC Series 4h', + recurrence: '4h', + }, + }); + const { result } = renderHook( + () => + useCryptoUpDownChartData(market, undefined, { + liveUpdatesEnabled: false, + }), + { wrapper: Wrapper }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(historicalData); + }); + expect(result.current.isLive).toBe(false); + expect(result.current.window).toBe(14400); + expect(mockUseLiveCryptoPrices).toHaveBeenLastCalledWith( + '', + expect.any(Function), + ); + }); + + it('can fetch a historical coin lookback window independent of the live market start', async () => { + const { Wrapper } = createWrapper(); + const market = createMarket({ + id: 'new-market', + endDate: '2026-01-01T00:05:00.000Z', + }); + + renderHook( + () => + useCryptoUpDownChartData(market, undefined, { + liveUpdatesEnabled: false, + historicalWindow: { + startDate: '2025-12-31T23:55:00.000Z', + endDate: '2026-01-01T00:00:00.000Z', + }, + }), + { wrapper: Wrapper }, + ); + + expect(mockCryptoPriceHistoryOptions).toHaveBeenCalledWith({ + symbol: 'BTC', + eventStartTime: '2025-12-31T23:55:00.000Z', + variant: 'fiveminute', + endDate: '2026-01-01T00:00:00.000Z', + }); + }); + + it('keeps prior real coin history visible during live market rollover refetch', async () => { + const { Wrapper } = createWrapper(); + const market = createMarket(); + const nextMarket = createMarket({ + id: 'next-market', + endDate: '2026-01-01T00:05:30.000Z', + }); + const previousCoinHistory = [ + { time: 100, value: 50000 }, + { time: 200, value: 51000 }, + ]; + let resolveNextQuery: ((value: LivelinePoint[]) => void) | undefined; + + historicalData = previousCoinHistory; + + const { result, rerender } = renderHook( + ({ + activeMarket, + historicalWindow, + }: { + activeMarket: TestMarket; + historicalWindow: { startDate: string; endDate: string }; + }) => + useCryptoUpDownChartData(activeMarket, undefined, { + liveUpdatesEnabled: false, + historicalWindow, + }), + { + initialProps: { + activeMarket: market, + historicalWindow: { + startDate: '2025-12-31T23:55:00.000Z', + endDate: '2026-01-01T00:00:00.000Z', + }, + }, + wrapper: Wrapper, + }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(previousCoinHistory); + }); + + mockCryptoPriceHistoryOptions.mockImplementationOnce( + ({ + symbol, + eventStartTime, + variant, + endDate, + }: { + symbol: string; + eventStartTime: string; + variant: string; + endDate?: string; + }) => ({ + queryKey: [ + 'predict', + 'cryptoPriceHistory', + symbol, + eventStartTime, + variant, + endDate ?? '', + 'pending-rollover', + ], + queryFn: () => + new Promise((resolve) => { + resolveNextQuery = resolve; + }), + }), + ); + + rerender({ + activeMarket: nextMarket, + historicalWindow: { + startDate: '2026-01-01T00:00:00.000Z', + endDate: '2026-01-01T00:05:00.000Z', + }, + }); + + expect(result.current.data).toEqual(previousCoinHistory); + + await act(async () => { + resolveNextQuery?.([ + { time: 300, value: 52000 }, + { time: 400, value: 53000 }, + ]); + }); + }); + it('keeps historical data available after live updates arrive', async () => { const { Wrapper } = createWrapper(); const market = createMarket(); - const chartRef = createMockChartRef(); const { result } = renderHook(() => useCryptoUpDownChartData(market), { wrapper: Wrapper, @@ -629,7 +774,6 @@ describe('useCryptoUpDownChartData', () => { it('falls back to the target price at event start when history is unavailable', () => { const { Wrapper } = createWrapper(); const market = createMarket(); - const chartRef = createMockChartRef(); historicalData = []; mockGetEventStartTime.mockReturnValue('1970-01-01T00:01:40.000Z'); @@ -656,7 +800,6 @@ describe('useCryptoUpDownChartData', () => { it('does not draw an assumed target-to-live line when opened late without history', () => { const { Wrapper } = createWrapper(); const market = createMarket(); - const chartRef = createMockChartRef(); historicalData = []; mockGetEventStartTime.mockReturnValue('1970-01-01T00:01:40.000Z'); @@ -687,7 +830,6 @@ describe('useCryptoUpDownChartData', () => { it('does not draw a target fallback after a pre-start live point', () => { const { Wrapper } = createWrapper(); const market = createMarket(); - const chartRef = createMockChartRef(); historicalData = []; mockGetEventStartTime.mockReturnValue('1970-01-01T00:01:40.000Z'); @@ -710,7 +852,6 @@ describe('useCryptoUpDownChartData', () => { it('keeps the target price fallback if target price later becomes unavailable', () => { const { Wrapper } = createWrapper(); const market = createMarket(); - const chartRef = createMockChartRef(); historicalData = []; mockGetEventStartTime.mockReturnValue('1970-01-01T00:01:40.000Z'); const initialProps: { targetPrice?: number } = { targetPrice: 50000 }; @@ -743,7 +884,6 @@ describe('useCryptoUpDownChartData', () => { it('uses target price fallback with partial historical data', async () => { const { Wrapper } = createWrapper(); const market = createMarket(); - const chartRef = createMockChartRef(); historicalData = [{ time: 105, value: 50100 }]; mockGetEventStartTime.mockReturnValue('1970-01-01T00:01:40.000Z'); @@ -763,7 +903,6 @@ describe('useCryptoUpDownChartData', () => { it('records the update that freezes live data once the end date passes', () => { const { Wrapper } = createWrapper(); const market = createMarket({ endDate: '2026-01-01T00:00:05.000Z' }); - const chartRef = createMockChartRef(); historicalData = []; const { result } = renderHook(() => useCryptoUpDownChartData(market), { @@ -792,7 +931,6 @@ describe('useCryptoUpDownChartData', () => { }); }); - expect(chartRef.current.appendPoint).not.toHaveBeenCalled(); expect(result.current.data).toEqual([ { time: 100, value: 50000 }, { time: 110, value: 52000 }, @@ -802,7 +940,6 @@ describe('useCryptoUpDownChartData', () => { it('keeps live data when the end date passes before the next live update', () => { const { Wrapper } = createWrapper(); const market = createMarket({ endDate: '2026-01-01T00:00:05.000Z' }); - const chartRef = createMockChartRef(); historicalData = []; const { result, rerender } = renderHook( diff --git a/app/components/UI/Predict/hooks/useFeaturedCarouselData.test.ts b/app/components/UI/Predict/hooks/useFeaturedCarouselData.test.ts index c294607edd4..d7cc96d662a 100644 --- a/app/components/UI/Predict/hooks/useFeaturedCarouselData.test.ts +++ b/app/components/UI/Predict/hooks/useFeaturedCarouselData.test.ts @@ -3,7 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react-native'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import Engine from '../../../../core/Engine'; import { useFeaturedCarouselData } from './useFeaturedCarouselData'; -import { type PredictMarket, Recurrence } from '../types'; +import { type PredictMarket, type PredictOutcome, Recurrence } from '../types'; let mockUpDownEnabled = true; @@ -26,13 +26,29 @@ const mockGetCarouselMarkets = Engine.context.PredictController const createWrapper = () => { const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, + defaultOptions: { queries: { cacheTime: 0, retry: false } }, }); const Wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client: queryClient }, children); return { Wrapper, queryClient }; }; +const createMockOutcome = ( + overrides: Partial = {}, +): PredictOutcome => ({ + id: 'outcome-1', + providerId: 'polymarket', + marketId: 'market-1', + title: 'Yes', + description: '', + image: '', + status: 'open', + tokens: [{ id: 't1', title: 'Yes', price: 0.65 }], + volume: 100000, + groupItemTitle: 'Yes', + ...overrides, +}); + const createMockMarket = ( overrides: Partial = {}, ): PredictMarket => ({ @@ -46,20 +62,7 @@ const createMockMarket = ( recurrence: Recurrence.NONE, category: 'crypto', tags: [], - outcomes: [ - { - id: 'outcome-1', - providerId: 'polymarket', - marketId: 'market-1', - title: 'Yes', - description: '', - image: '', - status: 'open', - tokens: [{ id: 't1', title: 'Yes', price: 0.65 }], - volume: 100000, - groupItemTitle: 'Yes', - }, - ], + outcomes: [createMockOutcome()], liquidity: 1500000, volume: 1500000, ...overrides, @@ -71,9 +74,14 @@ describe('useFeaturedCarouselData', () => { mockUpDownEnabled = true; }); - it('returns loading state initially', () => { + it('returns loading state initially', async () => { const { Wrapper } = createWrapper(); - mockGetCarouselMarkets.mockReturnValue(new Promise(() => undefined)); + let resolveMarkets!: (markets: PredictMarket[]) => void; + mockGetCarouselMarkets.mockReturnValue( + new Promise((resolve) => { + resolveMarkets = resolve; + }), + ); const { result } = renderHook(() => useFeaturedCarouselData(), { wrapper: Wrapper, @@ -81,6 +89,14 @@ describe('useFeaturedCarouselData', () => { expect(result.current.isLoading).toBe(true); expect(result.current.markets).toHaveLength(0); + + await act(async () => { + resolveMarkets([]); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); }); it('returns parsed markets after fetch', async () => { @@ -123,6 +139,31 @@ describe('useFeaturedCarouselData', () => { expect(result.current.markets).toEqual([parentMarket]); }); + it('filters stale carousel markets', async () => { + const { Wrapper } = createWrapper(); + const liveMarket = createMockMarket({ id: 'live-market' }); + const staleMarket = createMockMarket({ + id: 'stale-market', + outcomes: [ + createMockOutcome({ + id: 'stale-high', + tokens: [{ id: 'stale-high-token', title: 'Yes', price: 0.99 }], + }), + ], + }); + mockGetCarouselMarkets.mockResolvedValue([staleMarket, liveMarket]); + + const { result } = renderHook(() => useFeaturedCarouselData(), { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.markets).toEqual([liveMarket]); + }); + it('returns error when controller throws', async () => { const { Wrapper } = createWrapper(); mockGetCarouselMarkets.mockRejectedValue(new Error('Network error')); diff --git a/app/components/UI/Predict/hooks/useFeaturedCarouselData.ts b/app/components/UI/Predict/hooks/useFeaturedCarouselData.ts index d742c51a20c..7bd07e894ef 100644 --- a/app/components/UI/Predict/hooks/useFeaturedCarouselData.ts +++ b/app/components/UI/Predict/hooks/useFeaturedCarouselData.ts @@ -8,6 +8,7 @@ import { selectPredictUpDownEnabledFlag } from '../selectors/featureFlags'; import type { PredictMarket } from '../types'; import { isCryptoUpDown } from '../utils/cryptoUpDown'; import { filterStandaloneMarkets } from '../utils/feed'; +import { getVisiblePredictMarkets } from '../utils/marketStaleness'; import { ensureError } from '../utils/predictErrorHandler'; export interface UseFeaturedCarouselDataResult { @@ -41,7 +42,9 @@ export const useFeaturedCarouselData = (): UseFeaturedCarouselDataResult => { }, [query.error]); const markets = useMemo(() => { - const data = filterStandaloneMarkets(query.data ?? []); + const data = getVisiblePredictMarkets( + filterStandaloneMarkets(query.data ?? []), + ); if (upDownEnabled) { return data; } diff --git a/app/components/UI/Predict/hooks/usePredictMarketData.test.tsx b/app/components/UI/Predict/hooks/usePredictMarketData.test.tsx index f508590d63f..3b665824f9d 100644 --- a/app/components/UI/Predict/hooks/usePredictMarketData.test.tsx +++ b/app/components/UI/Predict/hooks/usePredictMarketData.test.tsx @@ -7,7 +7,7 @@ import { import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import Engine from '../../../../core/Engine'; import { usePredictMarketData } from './usePredictMarketData'; -import { PredictMarket, Recurrence } from '../types'; +import { PredictMarket, PredictOutcome, Recurrence } from '../types'; import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants'; // Mock dependencies @@ -130,6 +130,32 @@ describe('usePredictMarketData', () => { }, ]; + const createOutcome = (id: string, price: number): PredictOutcome => ({ + ...mockMarketData[0].outcomes[0], + id, + title: id, + tokens: [ + { + ...mockMarketData[0].outcomes[0].tokens[0], + id: `${id}-token`, + price, + }, + ], + }); + + const createMarket = ( + id: string, + outcomes = [createOutcome(`${id}-outcome`, 0.5)], + overrides: Partial = {}, + ): PredictMarket => ({ + ...mockMarketData[0], + id, + slug: id, + title: id, + outcomes, + ...overrides, + }); + beforeEach(() => { jest.clearAllMocks(); @@ -247,6 +273,64 @@ describe('usePredictMarketData', () => { expect(result.current.hasMore).toBe(true); }); + it('filters stale markets and keeps pagination metadata unchanged', async () => { + const staleMarket = createMarket('stale-market', [ + createOutcome('stale-high', 0.99), + createOutcome('stale-low', 0.01), + ]); + const liveMarket = createMarket('live-market'); + const partialMarket = createMarket('partial-market', [ + createOutcome('partial-dead', 0.99), + createOutcome('partial-live', 0.45), + ]); + mockGetMarkets.mockResolvedValue({ + markets: [staleMarket, liveMarket, partialMarket], + nextCursor: 'next-cursor', + }); + + const { result } = renderHook(() => usePredictMarketData({ pageSize: 20 })); + + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); + + expect(result.current.marketData.map((market) => market.id)).toEqual([ + 'live-market', + 'partial-market', + ]); + expect(result.current.marketData[1].outcomes).toEqual([ + createOutcome('partial-live', 0.45), + ]); + expect(result.current.hasMore).toBe(true); + }); + + it('keeps highlighted stale markets pinned before live markets', async () => { + const liveMarket = createMarket('live-market'); + const highlightedMarket = createMarket( + 'highlighted-market', + [createOutcome('highlighted-dead', 0.99)], + { isHighlighted: true }, + ); + mockGetMarkets.mockResolvedValue({ + markets: [liveMarket, highlightedMarket], + nextCursor: null, + }); + + const { result } = renderHook(() => usePredictMarketData()); + + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); + + expect(result.current.marketData.map((market) => market.id)).toEqual([ + 'highlighted-market', + 'live-market', + ]); + expect(result.current.marketData[0].outcomes).toEqual([ + createOutcome('highlighted-dead', 0.99), + ]); + }); + it('uses raw page offsets when loading more after child cards are filtered', async () => { const firstRawPage = Array.from({ length: 20 }, (_, index) => ({ ...mockMarketData[0], diff --git a/app/components/UI/Predict/hooks/usePredictMarketData.tsx b/app/components/UI/Predict/hooks/usePredictMarketData.tsx index 37e8a8b3f9e..bae413abdda 100644 --- a/app/components/UI/Predict/hooks/usePredictMarketData.tsx +++ b/app/components/UI/Predict/hooks/usePredictMarketData.tsx @@ -14,6 +14,7 @@ import { PREDICT_CONSTANTS } from '../constants/errors'; import { ensureError } from '../utils/predictErrorHandler'; import { PredictCategory, PredictMarket } from '../types'; import { filterStandaloneMarkets } from '../utils/feed'; +import { getVisiblePredictMarkets } from '../utils/marketStaleness'; export interface UsePredictMarketDataOptions { category?: PredictCategory; @@ -141,7 +142,9 @@ export const usePredictMarketData = ( nextCursorRef.current = nextCursor; setHasMore(Boolean(nextCursor)); - const visibleMarkets = filterStandaloneMarkets(markets); + const visibleMarkets = getVisiblePredictMarkets( + filterStandaloneMarkets(markets), + ); if (isLoadMore) { setMarketData((prevData) => { diff --git a/app/components/UI/Predict/hooks/usePredictOrderbook.test.ts b/app/components/UI/Predict/hooks/usePredictOrderbook.test.ts new file mode 100644 index 00000000000..d232fa40950 --- /dev/null +++ b/app/components/UI/Predict/hooks/usePredictOrderbook.test.ts @@ -0,0 +1,181 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { usePredictOrderbook } from './usePredictOrderbook'; +import Engine from '../../../../core/Engine'; +import type { OrderbookCallback, OrderbookSnapshot } from '../types'; + +jest.mock('../../../../core/Engine', () => ({ + context: { + PredictController: { + subscribeToOrderbook: jest.fn(), + }, + }, +})); + +const buildSnapshot = ( + overrides: Partial = {}, +): OrderbookSnapshot => ({ + tokenId: 'token1', + bids: [ + { price: 0.5, size: 100 }, + { price: 0.45, size: 50 }, + ], + asks: [ + { price: 0.55, size: 80 }, + { price: 0.6, size: 30 }, + ], + timestamp: 1700000000, + ...overrides, +}); + +describe('usePredictOrderbook', () => { + const mockSubscribeToOrderbook = Engine.context.PredictController + .subscribeToOrderbook as jest.Mock; + const mockUnsubscribe = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockSubscribeToOrderbook.mockReturnValue(mockUnsubscribe); + }); + + describe('subscription management', () => { + it('subscribes via the PredictController when a tokenId is provided', () => { + renderHook(() => usePredictOrderbook('token1')); + + expect(mockSubscribeToOrderbook).toHaveBeenCalledWith( + 'token1', + expect.any(Function), + ); + }); + + it('does not subscribe when tokenId is undefined', () => { + renderHook(() => usePredictOrderbook(undefined)); + + expect(mockSubscribeToOrderbook).not.toHaveBeenCalled(); + }); + + it('does not subscribe when enabled is false', () => { + renderHook(() => usePredictOrderbook('token1', { enabled: false })); + + expect(mockSubscribeToOrderbook).not.toHaveBeenCalled(); + }); + + it('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => usePredictOrderbook('token1')); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + }); + + it('resubscribes exactly once when tokenId changes', () => { + const { rerender } = renderHook( + ({ tokenId }) => usePredictOrderbook(tokenId), + { initialProps: { tokenId: 'token1' } }, + ); + + expect(mockSubscribeToOrderbook).toHaveBeenCalledTimes(1); + + rerender({ tokenId: 'token2' }); + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + expect(mockSubscribeToOrderbook).toHaveBeenCalledTimes(2); + expect(mockSubscribeToOrderbook).toHaveBeenLastCalledWith( + 'token2', + expect.any(Function), + ); + }); + }); + + describe('initial state and skip behavior', () => { + it('returns null orderbook and loading=true on first render with a tokenId', () => { + // Block snapshot delivery by holding the callback before flushing. + mockSubscribeToOrderbook.mockImplementation(() => mockUnsubscribe); + + const { result } = renderHook(() => usePredictOrderbook('token1')); + + expect(result.current.orderbook).toBeNull(); + expect(result.current.loading).toBe(true); + }); + + it('marks loading=false when no tokenId is provided', () => { + const { result } = renderHook(() => usePredictOrderbook(undefined)); + + expect(result.current.loading).toBe(false); + }); + + it('marks loading=false when enabled is false', () => { + const { result } = renderHook(() => + usePredictOrderbook('token1', { enabled: false }), + ); + + expect(result.current.loading).toBe(false); + }); + }); + + describe('snapshot delivery', () => { + it('maps OrderbookSnapshot to tuple-shaped OrderbookData preserving sort order', () => { + let capturedCallback: OrderbookCallback = jest.fn(); + mockSubscribeToOrderbook.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result } = renderHook(() => usePredictOrderbook('token1')); + + act(() => { + capturedCallback(buildSnapshot()); + }); + + expect(result.current.orderbook).toEqual({ + bids: [ + [0.5, 100], + [0.45, 50], + ], + asks: [ + [0.55, 80], + [0.6, 30], + ], + }); + expect(result.current.loading).toBe(false); + }); + + it('resets orderbook to null when tokenId changes to avoid stale data', () => { + let capturedCallback: OrderbookCallback = jest.fn(); + mockSubscribeToOrderbook.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { result, rerender } = renderHook( + ({ tokenId }) => usePredictOrderbook(tokenId), + { initialProps: { tokenId: 'token1' } }, + ); + + act(() => { + capturedCallback(buildSnapshot()); + }); + expect(result.current.orderbook).not.toBeNull(); + + rerender({ tokenId: 'token2' }); + + expect(result.current.orderbook).toBeNull(); + expect(result.current.loading).toBe(true); + }); + + it('does not call setState when a snapshot arrives after unmount', () => { + let capturedCallback: OrderbookCallback = jest.fn(); + mockSubscribeToOrderbook.mockImplementation((_, callback) => { + capturedCallback = callback; + return mockUnsubscribe; + }); + + const { unmount } = renderHook(() => usePredictOrderbook('token1')); + unmount(); + + // No throw and no warning even though setState would be invalid here. + expect(() => { + capturedCallback(buildSnapshot()); + }).not.toThrow(); + }); + }); +}); diff --git a/app/components/UI/Predict/hooks/usePredictOrderbook.ts b/app/components/UI/Predict/hooks/usePredictOrderbook.ts new file mode 100644 index 00000000000..1e14012df04 --- /dev/null +++ b/app/components/UI/Predict/hooks/usePredictOrderbook.ts @@ -0,0 +1,74 @@ +import { useEffect, useRef, useState } from 'react'; +import Engine from '../../../../core/Engine'; +import type { OrderbookData } from '../../Charts/LivelineChart/LivelineChart.types'; +import type { OrderbookSnapshot } from '../types'; + +export interface UsePredictOrderbookOptions { + enabled?: boolean; +} + +export interface UsePredictOrderbookResult { + orderbook: OrderbookData | null; + loading: boolean; +} + +const toLivelineOrderbook = (snapshot: OrderbookSnapshot): OrderbookData => ({ + bids: snapshot.bids.map(({ price, size }) => [price, size]), + asks: snapshot.asks.map(({ price, size }) => [price, size]), +}); + +/** + * Hook for subscribing to real-time orderbook (depth) updates for a single + * outcome token via the Predict controller. The snapshot received from the + * controller is already sorted (bids desc, asks asc) and is mapped to the + * tuple shape consumed by `LivelineChart`'s `orderbook` prop. + * + * @param tokenId - The outcome token ID; pass undefined or empty string to skip subscribing + * @param options - Configuration options (enabled: boolean) + * @returns Latest orderbook tuple data and loading flag + */ +export function usePredictOrderbook( + tokenId?: string, + options: UsePredictOrderbookOptions = {}, +): UsePredictOrderbookResult { + const { enabled = true } = options; + + const [orderbook, setOrderbook] = useState(null); + const [loading, setLoading] = useState(true); + + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + + // Reset state when token changes to avoid stale data from previous + // subscriptions. + setOrderbook(null); + setLoading(true); + + if (!tokenId || !enabled) { + setLoading(false); + return; + } + + const { PredictController } = Engine.context; + const unsubscribe = PredictController.subscribeToOrderbook( + tokenId, + (snapshot) => { + if (!isMountedRef.current) return; + setOrderbook(toLivelineOrderbook(snapshot)); + setLoading(false); + }, + ); + + return () => { + isMountedRef.current = false; + unsubscribe(); + }; + }, [tokenId, enabled]); + + return { + orderbook, + loading, + }; +} diff --git a/app/components/UI/Predict/hooks/usePredictSearchMarketData.test.tsx b/app/components/UI/Predict/hooks/usePredictSearchMarketData.test.tsx index cb6d5c6c806..bd94e92ca4c 100644 --- a/app/components/UI/Predict/hooks/usePredictSearchMarketData.test.tsx +++ b/app/components/UI/Predict/hooks/usePredictSearchMarketData.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { renderHook, waitFor } from '@testing-library/react-native'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants'; -import { PredictMarket, Recurrence } from '../types'; +import { PredictMarket, PredictOutcome, Recurrence } from '../types'; import { usePredictSearchMarketData } from './usePredictSearchMarketData'; jest.mock('../../../../util/Logger', () => ({ @@ -50,12 +50,49 @@ const mockMarketData: PredictMarket[] = [ recurrence: Recurrence.NONE, category: 'crypto', tags: ['trending'], - outcomes: [], + outcomes: [ + { + id: 'outcome-1', + providerId: POLYMARKET_PROVIDER_ID, + marketId: 'market-1', + title: 'Yes', + description: 'Bitcoin will reach $100k', + image: '', + status: 'open', + tokens: [{ id: 'token-1', title: 'Yes', price: 0.65 }], + volume: 1000000, + groupItemTitle: 'Yes', + }, + ], liquidity: 1000000, volume: 1000000, }, ]; +const createOutcome = (id: string, price: number): PredictOutcome => ({ + ...mockMarketData[0].outcomes[0], + id, + title: id, + tokens: [ + { + ...mockMarketData[0].outcomes[0].tokens[0], + id: `${id}-token`, + price, + }, + ], +}); + +const createMarket = ( + id: string, + outcomes = [createOutcome(`${id}-outcome`, 0.5)], +): PredictMarket => ({ + ...mockMarketData[0], + id, + slug: id, + title: id, + outcomes, +}); + describe('usePredictSearchMarketData', () => { beforeEach(() => { jest.clearAllMocks(); @@ -110,6 +147,46 @@ describe('usePredictSearchMarketData', () => { expect(result.current.marketData).toEqual(mockMarketData); }); + it('filters stale trending fallback results for an empty query', async () => { + const staleMarket = createMarket('stale-market', [ + createOutcome('stale-high', 0.99), + createOutcome('stale-low', 0.01), + ]); + const liveMarket = createMarket('live-market'); + mockGetMarkets.mockResolvedValue({ + markets: [staleMarket, liveMarket], + nextCursor: null, + }); + + const { Wrapper } = createWrapper(); + const { result } = renderHook(() => usePredictSearchMarketData({ q: '' }), { + wrapper: Wrapper, + }); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.marketData).toEqual([liveMarket]); + }); + + it('does not filter stale manual search results before exposing market data', async () => { + const staleMarket = createMarket('stale-market', [ + createOutcome('stale-high', 0.99), + createOutcome('stale-low', 0.01), + ]); + const liveMarket = createMarket('live-market'); + mockSearchMarkets.mockResolvedValue([staleMarket, liveMarket]); + + const { Wrapper } = createWrapper(); + const { result } = renderHook( + () => usePredictSearchMarketData({ q: ' bitcoin ' }), + { wrapper: Wrapper }, + ); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.marketData).toEqual([staleMarket, liveMarket]); + }); + it('sets error and clears data when search throws', async () => { mockSearchMarkets.mockRejectedValue(new Error('Search failed')); diff --git a/app/components/UI/Predict/hooks/usePredictSearchMarketData.tsx b/app/components/UI/Predict/hooks/usePredictSearchMarketData.tsx index ba518d0fe80..691a5ae4c1e 100644 --- a/app/components/UI/Predict/hooks/usePredictSearchMarketData.tsx +++ b/app/components/UI/Predict/hooks/usePredictSearchMarketData.tsx @@ -4,6 +4,7 @@ import Engine from '../../../../core/Engine'; import Logger from '../../../../util/Logger'; import { PREDICT_CONSTANTS } from '../constants/errors'; import { PredictMarket } from '../types'; +import { getVisiblePredictMarkets } from '../utils/marketStaleness'; import { ensureError } from '../utils/predictErrorHandler'; export interface UsePredictSearchMarketDataOptions { @@ -86,8 +87,11 @@ export const usePredictSearchMarketData = ({ } const markets = query.data ?? []; - return refine ? refine(markets) : markets; - }, [enabled, query.data, refine]); + const visibleMarkets = trimmedQuery + ? markets + : getVisiblePredictMarkets(markets); + return refine ? refine(visibleMarkets) : visibleMarkets; + }, [enabled, query.data, refine, trimmedQuery]); const queryRefetch = query.refetch; const refetch = useCallback(async () => { diff --git a/app/components/UI/Predict/hooks/usePredictWorldCup.test.ts b/app/components/UI/Predict/hooks/usePredictWorldCup.test.ts index c1b5ac82dd0..c3124870498 100644 --- a/app/components/UI/Predict/hooks/usePredictWorldCup.test.ts +++ b/app/components/UI/Predict/hooks/usePredictWorldCup.test.ts @@ -3,7 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react-native'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import Engine from '../../../../core/Engine'; import { DEFAULT_PREDICT_WORLD_CUP_FLAG } from '../constants/flags'; -import { Recurrence, type PredictMarket } from '../types'; +import { Recurrence, type PredictMarket, type PredictOutcome } from '../types'; import { usePredictWorldCupMarkets } from './usePredictWorldCup'; jest.mock('../../../../core/Engine', () => ({ @@ -16,6 +16,28 @@ jest.mock('../../../../core/Engine', () => ({ const mockGetMarkets = jest.mocked(Engine.context.PredictController.getMarkets); +const createOutcome = ( + overrides: Partial = {}, +): PredictOutcome => ({ + id: 'outcome-1', + providerId: 'polymarket', + marketId: 'market-1', + title: 'Outcome 1', + description: 'Outcome description', + image: 'outcome.png', + status: 'open', + tokens: [ + { + id: 'token-1', + title: 'Yes', + price: 0.5, + }, + ], + volume: 0, + groupItemTitle: 'Outcome 1', + ...overrides, +}); + const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { cacheTime: Infinity, retry: false } }, @@ -38,7 +60,7 @@ const createMarket = ( recurrence: Recurrence.NONE, category: 'hot', tags: [], - outcomes: [], + outcomes: [createOutcome()], liquidity: 0, volume: 0, ...overrides, @@ -79,6 +101,46 @@ describe('usePredictWorldCupMarkets', () => { expect(result.current.hasMore).toBe(false); }); + it('filters stale World Cup markets while preserving pagination state', async () => { + const { Wrapper } = createWrapper(); + const liveMarket = createMarket({ id: 'live-market' }); + const staleMarket = createMarket({ + id: 'stale-market', + outcomes: [ + createOutcome({ + id: 'dead-outcome', + title: 'Dead outcome', + tokens: [ + { + id: 'dead-token', + title: 'Yes', + price: 0.99, + }, + ], + }), + ], + }); + mockGetMarkets.mockResolvedValue({ + markets: [staleMarket, liveMarket], + nextCursor: 'cursor-2', + }); + + const { result } = renderHook( + () => + usePredictWorldCupMarkets({ + tabKey: 'all', + config: DEFAULT_PREDICT_WORLD_CUP_FLAG, + pageSize: 30, + }), + { wrapper: Wrapper }, + ); + + await waitFor(() => + expect(result.current.marketData).toEqual([liveMarket]), + ); + expect(result.current.hasMore).toBe(true); + }); + it('requests Props markets with a cached paginated query', async () => { const { Wrapper } = createWrapper(); const propsMarket = createMarket({ id: 'props-market' }); diff --git a/app/components/UI/Predict/hooks/usePredictWorldCup.ts b/app/components/UI/Predict/hooks/usePredictWorldCup.ts index d0f345fb40a..ce9eb0278f0 100644 --- a/app/components/UI/Predict/hooks/usePredictWorldCup.ts +++ b/app/components/UI/Predict/hooks/usePredictWorldCup.ts @@ -28,6 +28,7 @@ import { import { strings } from '../../../../../locales/i18n'; import type { PredictMarket } from '../types'; import type { PredictWorldCupConfig } from '../types/flags'; +import { getVisiblePredictMarkets } from '../utils/marketStaleness'; import type { UsePredictMarketDataResult } from './usePredictMarketData'; export interface UsePredictWorldCupMarketsOptions { @@ -232,10 +233,22 @@ export const usePredictWorldCupMarkets = ({ await singleQuery.refetch(); }, [infiniteQuery, marketDataConfig.paginationEnabled, singleQuery]); + const infiniteMarketData = useMemo( + () => infiniteQuery.data?.pages.flatMap((page) => page.markets) ?? [], + [infiniteQuery.data], + ); + const visibleInfiniteMarketData = useMemo( + () => getVisiblePredictMarkets(infiniteMarketData), + [infiniteMarketData], + ); + const visibleSingleMarketData = useMemo( + () => getVisiblePredictMarkets(singleQuery.data ?? []), + [singleQuery.data], + ); + if (marketDataConfig.paginationEnabled) { return { - marketData: - infiniteQuery.data?.pages.flatMap((page) => page.markets) ?? [], + marketData: visibleInfiniteMarketData, isFetching: infiniteQuery.isLoading, isFetchingMore: infiniteQuery.isFetchingNextPage, error: infiniteQuery.error?.message ?? null, @@ -246,7 +259,7 @@ export const usePredictWorldCupMarkets = ({ } return { - marketData: singleQuery.data ?? [], + marketData: visibleSingleMarketData, isFetching: singleQuery.isLoading, isFetchingMore: false, error: singleQuery.error?.message ?? null, diff --git a/app/components/UI/Predict/index.ts b/app/components/UI/Predict/index.ts index 6f7b56c29d3..64cf7842f4b 100644 --- a/app/components/UI/Predict/index.ts +++ b/app/components/UI/Predict/index.ts @@ -5,5 +5,6 @@ export { default as PredictScreenStack } from './routes'; export { selectPredictEnabledFlag } from './selectors/featureFlags'; export { default as PredictSellPreview } from './views/PredictSellPreview/PredictSellPreview'; export { PredictModalStack } from './routes'; +export { PredictPreviewSheetProvider } from './contexts'; export * from './types'; diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index d40eb5557e1..2ea34b2f4cd 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -46,6 +46,7 @@ import { fetchEventsFromPolymarketApi, getBalance, getL2Headers, + getOrderBook, getRawBalance, parsePolymarketEvents, parsePolymarketPositions, @@ -106,6 +107,7 @@ jest.mock('./utils', () => { searchEventsFromPolymarketApi: jest.fn(), getBalance: jest.fn(), getL2Headers: jest.fn(), + getOrderBook: jest.fn(), getRawBalance: jest.fn(), getMarketDetailsFromGammaApi: jest.fn(), getPolymarketEndpoints: jest.fn(() => ({ @@ -125,6 +127,29 @@ jest.mock('./utils', () => { }; }); +const mockWebSocketManagerInstance = { + subscribeToGame: jest.fn(), + subscribeToMarketPrices: jest.fn(), + subscribeToOrderbook: jest.fn(), + subscribeToCryptoPrices: jest.fn(), + seedOrderbookSnapshot: jest.fn(), + getConnectionStatus: jest.fn(() => ({ + sportsConnected: false, + marketConnected: false, + rtdsConnected: false, + gameSubscriptionCount: 0, + priceSubscriptionCount: 0, + cryptoPriceSubscriptionCount: 0, + orderbookSubscriptionCount: 0, + })), +}; + +jest.mock('./WebSocketManager', () => ({ + WebSocketManager: { + getInstance: jest.fn(() => mockWebSocketManagerInstance), + }, +})); + jest.mock('./protocol/transport', () => ({ submitProtocolClobOrder: jest.fn(), })); @@ -189,6 +214,7 @@ const mockGetDeployProxyWalletTransaction = jest.mocked( getDeployProxyWalletTransaction, ); const mockGetL2Headers = jest.mocked(getL2Headers); +const mockGetOrderBook = jest.mocked(getOrderBook); const mockGetRawBalance = jest.mocked(getRawBalance); const mockGetSafeTransferAmount = jest.mocked(getSafeTransferAmount); const mockGetSafeTransferAmountRaw = jest.mocked(getSafeTransferAmountRaw); @@ -1491,3 +1517,112 @@ describe('PolymarketProvider', () => { ).rejects.toThrow('Failed to get crypto price history'); }); }); + +describe('PolymarketProvider.subscribeToOrderbook', () => { + const mockBook = { + market: 'market-1', + asset_id: 'token1', + hash: 'hash', + timestamp: '2025-01-12T12:00:00Z', + bids: [{ price: '0.45', size: '50' }], + asks: [{ price: '0.55', size: '50' }], + min_order_size: '1', + tick_size: '0.01', + neg_risk: false, + }; + + beforeEach(() => { + mockWebSocketManagerInstance.subscribeToOrderbook.mockReset(); + mockWebSocketManagerInstance.seedOrderbookSnapshot.mockReset(); + mockGetOrderBook.mockReset(); + }); + + it('returns the WebSocketManager unsubscribe function', () => { + const wsUnsubscribe = jest.fn(); + mockWebSocketManagerInstance.subscribeToOrderbook.mockReturnValue( + wsUnsubscribe, + ); + mockGetOrderBook.mockResolvedValue(mockBook); + + const provider = createProvider(); + const callback = jest.fn(); + const unsubscribe = provider.subscribeToOrderbook('token1', callback); + + expect( + mockWebSocketManagerInstance.subscribeToOrderbook, + ).toHaveBeenCalledWith('token1', callback); + expect(unsubscribe).toBe(wsUnsubscribe); + }); + + it('bootstraps with getOrderBook and seeds the WebSocketManager on success', async () => { + mockWebSocketManagerInstance.subscribeToOrderbook.mockReturnValue( + jest.fn(), + ); + mockGetOrderBook.mockResolvedValue(mockBook); + + createProvider().subscribeToOrderbook('token1', jest.fn()); + + expect(mockGetOrderBook).toHaveBeenCalledWith({ tokenId: 'token1' }); + + // Flush the pending REST promise. + await Promise.resolve(); + await Promise.resolve(); + + expect( + mockWebSocketManagerInstance.seedOrderbookSnapshot, + ).toHaveBeenCalledWith('token1', mockBook); + }); + + it('does not seed when getOrderBook rejects, but still returns the WS unsubscribe', async () => { + const wsUnsubscribe = jest.fn(); + mockWebSocketManagerInstance.subscribeToOrderbook.mockReturnValue( + wsUnsubscribe, + ); + mockGetOrderBook.mockRejectedValue(new Error('boom')); + + const unsubscribe = createProvider().subscribeToOrderbook( + 'token1', + jest.fn(), + ); + + expect(unsubscribe).toBe(wsUnsubscribe); + + await Promise.resolve(); + await Promise.resolve(); + + expect( + mockWebSocketManagerInstance.seedOrderbookSnapshot, + ).not.toHaveBeenCalled(); + }); + + it('subscribes via WS before awaiting REST so a WS book event can populate the cache first', async () => { + // Asserts the provider's race-safe ordering: the synchronous WS + // subscription happens before the awaited REST bootstrap. Combined + // with the WebSocketManager guard (`seedOrderbookSnapshot` no-ops when + // the cache is already populated), this prevents a late REST snapshot + // from stomping a newer WS-delivered book. + const callOrder: string[] = []; + mockWebSocketManagerInstance.subscribeToOrderbook.mockImplementation(() => { + callOrder.push('ws.subscribe'); + return jest.fn(); + }); + mockGetOrderBook.mockImplementation(() => { + callOrder.push('rest.start'); + return Promise.resolve(mockBook); + }); + mockWebSocketManagerInstance.seedOrderbookSnapshot.mockImplementation( + () => { + callOrder.push('ws.seed'); + }, + ); + + createProvider().subscribeToOrderbook('token1', jest.fn()); + + expect(callOrder).toEqual(['ws.subscribe', 'rest.start']); + + await Promise.resolve(); + await Promise.resolve(); + + expect(callOrder).toEqual(['ws.subscribe', 'rest.start', 'ws.seed']); + }); +}); diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 3b6de7435ec..f07ba25d657 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -54,6 +54,7 @@ import { GetMarketsParams, GetMarketsResult, GetPositionsParams, + OrderbookCallback, OrderPreview, OrderResult, PlaceOrderParams, @@ -104,6 +105,7 @@ import { getL2Headers, fetchChildEventsFromGammaApi, getMarketDetailsFromGammaApi, + getOrderBook, getPolymarketEndpoints, getRawBalance, mergeChildEventsIntoParent, @@ -3086,6 +3088,28 @@ export class PolymarketProvider implements PredictProvider { ); } + public subscribeToOrderbook( + tokenId: string, + callback: OrderbookCallback, + ): () => void { + const ws = WebSocketManager.getInstance(); + const wsUnsubscribe = ws.subscribeToOrderbook(tokenId, callback); + + // Bootstrap with a REST snapshot so the chart has data before the first + // WS `book` event arrives. `getOrderBook` defaults to v1; `previewOrder` + // does not currently thread v2 either, so no protocol plumbing is needed. + getOrderBook({ tokenId }) + .then((book) => ws.seedOrderbookSnapshot(tokenId, book)) + .catch((err) => { + DevLogger.log('PolymarketProvider: orderbook bootstrap failed', { + err, + tokenId, + }); + }); + + return wsUnsubscribe; + } + public subscribeToCryptoPrices( symbols: string[], callback: CryptoPriceUpdateCallback, diff --git a/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts b/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts index 9afa595e822..42dbe583b7f 100644 --- a/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts +++ b/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts @@ -564,7 +564,7 @@ describe('WebSocketManager', () => { ]); }); - it('ignores non-price_change events', () => { + it('does not deliver book events to price subscribers', () => { const manager = WebSocketManager.getInstance(); const callback = jest.fn(); @@ -573,6 +573,23 @@ describe('WebSocketManager', () => { mockWebSocketInstances[0].simulateMessage({ event_type: 'book', market: 'market-1', + asset_id: 'token1', + bids: [{ price: '0.50', size: '100' }], + asks: [{ price: '0.52', size: '100' }], + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('ignores unknown event types', () => { + const manager = WebSocketManager.getInstance(); + const callback = jest.fn(); + + manager.subscribeToMarketPrices(['token1'], callback); + mockWebSocketInstances[0].simulateOpen(); + mockWebSocketInstances[0].simulateMessage({ + event_type: 'something_else', + market: 'market-1', }); expect(callback).not.toHaveBeenCalled(); @@ -683,6 +700,464 @@ describe('WebSocketManager', () => { }); }); + describe('orderbook subscriptions', () => { + const getMarketInstance = () => { + const instance = mockWebSocketInstances.find( + (ws) => + ws.url === 'wss://ws-subscriptions-clob.polymarket.com/ws/market', + ); + if (!instance) { + throw new Error('Market WebSocket instance was not created'); + } + return instance; + }; + + it('connects to market WS and subscribes when first orderbook subscriber registers', () => { + const manager = WebSocketManager.getInstance(); + + manager.subscribeToOrderbook('token1', jest.fn()); + const market = getMarketInstance(); + market.simulateOpen(); + + expect(market.send).toHaveBeenCalledWith( + JSON.stringify({ + type: 'market', + assets_ids: ['token1'], + }), + ); + }); + + it('emits a sorted snapshot on book events (bids desc, asks asc)', () => { + const manager = WebSocketManager.getInstance(); + const callback = jest.fn(); + + manager.subscribeToOrderbook('token1', callback); + const market = getMarketInstance(); + market.simulateOpen(); + market.simulateMessage({ + event_type: 'book', + market: 'market-1', + asset_id: 'token1', + bids: [ + { price: '0.40', size: '100' }, + { price: '0.45', size: '200' }, + { price: '0.50', size: '50' }, + ], + asks: [ + { price: '0.60', size: '30' }, + { price: '0.55', size: '80' }, + ], + timestamp: '2025-01-12T12:00:00Z', + }); + + expect(callback).toHaveBeenCalledTimes(1); + const snapshot = callback.mock.calls[0][0]; + expect(snapshot.tokenId).toBe('token1'); + expect(snapshot.bids).toEqual([ + { price: 0.5, size: 50 }, + { price: 0.45, size: 200 }, + { price: 0.4, size: 100 }, + ]); + expect(snapshot.asks).toEqual([ + { price: 0.55, size: 80 }, + { price: 0.6, size: 30 }, + ]); + expect(typeof snapshot.timestamp).toBe('number'); + }); + + it('ignores book events for tokens with no active subscription', () => { + const manager = WebSocketManager.getInstance(); + const callback = jest.fn(); + + manager.subscribeToOrderbook('token1', callback); + const market = getMarketInstance(); + market.simulateOpen(); + market.simulateMessage({ + event_type: 'book', + market: 'market-1', + asset_id: 'other-token', + bids: [{ price: '0.10', size: '1' }], + asks: [{ price: '0.90', size: '1' }], + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('seedOrderbookSnapshot emits a sorted snapshot from REST data', () => { + const manager = WebSocketManager.getInstance(); + const callback = jest.fn(); + + manager.subscribeToOrderbook('token1', callback); + + manager.seedOrderbookSnapshot('token1', { + market: 'market-1', + asset_id: 'token1', + hash: 'hash', + timestamp: '2025-01-12T12:00:00Z', + // REST returns asks descending, bids ascending — emit must re-sort. + asks: [ + { price: '0.60', size: '30' }, + { price: '0.55', size: '80' }, + ], + bids: [ + { price: '0.40', size: '100' }, + { price: '0.45', size: '200' }, + { price: '0.50', size: '50' }, + ], + min_order_size: '1', + tick_size: '0.01', + neg_risk: false, + }); + + expect(callback).toHaveBeenCalledTimes(1); + const snapshot = callback.mock.calls[0][0]; + expect(snapshot.bids).toEqual([ + { price: 0.5, size: 50 }, + { price: 0.45, size: 200 }, + { price: 0.4, size: 100 }, + ]); + expect(snapshot.asks).toEqual([ + { price: 0.55, size: 80 }, + { price: 0.6, size: 30 }, + ]); + }); + + it('seedOrderbookSnapshot is a no-op when no subscriber is registered', () => { + const manager = WebSocketManager.getInstance(); + const callback = jest.fn(); + + // No subscription for this token. + manager.seedOrderbookSnapshot('orphan-token', { + market: 'market-1', + asset_id: 'orphan-token', + hash: 'hash', + timestamp: '2025-01-12T12:00:00Z', + asks: [{ price: '0.6', size: '1' }], + bids: [{ price: '0.4', size: '1' }], + min_order_size: '1', + tick_size: '0.01', + neg_risk: false, + }); + + // Subscribe afterwards and confirm the cache wasn't populated. + manager.subscribeToOrderbook('orphan-token', callback); + expect(callback).not.toHaveBeenCalled(); + }); + + it('seedOrderbookSnapshot does not overwrite an already-cached WS snapshot', () => { + // Guards the race where a WS `book` event populates the cache before + // the parallel REST bootstrap promise resolves: REST is by definition + // older than the WS push, so it must not stomp the fresher state. + const manager = WebSocketManager.getInstance(); + const callback = jest.fn(); + + manager.subscribeToOrderbook('token1', callback); + const market = getMarketInstance(); + market.simulateOpen(); + + // Fresh WS book event arrives first. + market.simulateMessage({ + event_type: 'book', + market: 'market-1', + asset_id: 'token1', + bids: [{ price: '0.50', size: '100' }], + asks: [{ price: '0.52', size: '100' }], + }); + expect(callback).toHaveBeenCalledTimes(1); + const wsSnapshot = callback.mock.calls[0][0]; + callback.mockClear(); + + // Late REST bootstrap with stale data. + manager.seedOrderbookSnapshot('token1', { + market: 'market-1', + asset_id: 'token1', + hash: 'hash', + timestamp: '2025-01-12T11:59:59Z', + bids: [{ price: '0.40', size: '999' }], + asks: [{ price: '0.60', size: '999' }], + min_order_size: '1', + tick_size: '0.01', + neg_risk: false, + }); + + // REST seed is a no-op; cache and subscribers untouched. + expect(callback).not.toHaveBeenCalled(); + + // The next WS event still emits the post-WS state (not the REST state). + market.simulateMessage({ + event_type: 'book', + market: 'market-1', + asset_id: 'token1', + bids: [{ price: '0.51', size: '101' }], + asks: [{ price: '0.53', size: '101' }], + }); + // Throttle window from the first emit is open; trailing emit fires + // after 250ms. + jest.advanceTimersByTime(250); + const finalSnapshot = callback.mock.calls.at(-1)?.[0]; + expect(finalSnapshot.bids).toEqual([{ price: 0.51, size: 101 }]); + expect(finalSnapshot.asks).toEqual([{ price: 0.53, size: 101 }]); + // Sanity: the stale REST values never appeared. + expect(wsSnapshot.bids[0].price).toBe(0.5); + }); + + it('replays the cached snapshot to a late subscriber synchronously', () => { + const manager = WebSocketManager.getInstance(); + const firstCallback = jest.fn(); + + manager.subscribeToOrderbook('token1', firstCallback); + const market = getMarketInstance(); + market.simulateOpen(); + market.simulateMessage({ + event_type: 'book', + market: 'market-1', + asset_id: 'token1', + bids: [{ price: '0.45', size: '50' }], + asks: [{ price: '0.55', size: '50' }], + }); + firstCallback.mockClear(); + + const lateCallback = jest.fn(); + manager.subscribeToOrderbook('token1', lateCallback); + + expect(lateCallback).toHaveBeenCalledTimes(1); + const snapshot = lateCallback.mock.calls[0][0]; + expect(snapshot.bids).toEqual([{ price: 0.45, size: 50 }]); + expect(snapshot.asks).toEqual([{ price: 0.55, size: 50 }]); + }); + + it('throttles rapid book events to one trailing emit per token window', () => { + const manager = WebSocketManager.getInstance(); + const callback = jest.fn(); + + manager.subscribeToOrderbook('token1', callback); + const market = getMarketInstance(); + market.simulateOpen(); + + // First book — emitted immediately. + market.simulateMessage({ + event_type: 'book', + market: 'market-1', + asset_id: 'token1', + bids: [{ price: '0.45', size: '50' }], + asks: [{ price: '0.55', size: '50' }], + }); + expect(callback).toHaveBeenCalledTimes(1); + + // Subsequent rapid books within the throttle window — coalesced. + market.simulateMessage({ + event_type: 'book', + market: 'market-1', + asset_id: 'token1', + bids: [{ price: '0.46', size: '60' }], + asks: [{ price: '0.54', size: '70' }], + }); + market.simulateMessage({ + event_type: 'book', + market: 'market-1', + asset_id: 'token1', + bids: [{ price: '0.47', size: '70' }], + asks: [{ price: '0.53', size: '90' }], + }); + expect(callback).toHaveBeenCalledTimes(1); + + // Advance past the throttle window — trailing emit fires with latest state. + jest.advanceTimersByTime(250); + expect(callback).toHaveBeenCalledTimes(2); + const trailing = callback.mock.calls[1][0]; + expect(trailing.bids).toEqual([{ price: 0.47, size: 70 }]); + expect(trailing.asks).toEqual([{ price: 0.53, size: 90 }]); + }); + + it('does not emit a stale orderbook on price_change events even with a cached book', () => { + // `price_change` only carries `best_bid` / `best_ask`, no per-level + // sizes. Emitting on these events can only show a stale, wider-than- + // real spread until the next `book` event arrives, so the manager + // intentionally suppresses orderbook emits here. + const manager = WebSocketManager.getInstance(); + const callback = jest.fn(); + + manager.subscribeToOrderbook('token1', callback); + const market = getMarketInstance(); + market.simulateOpen(); + + market.simulateMessage({ + event_type: 'book', + market: 'market-1', + asset_id: 'token1', + bids: [ + { price: '0.45', size: '50' }, + { price: '0.40', size: '100' }, + ], + asks: [ + { price: '0.55', size: '50' }, + { price: '0.60', size: '100' }, + ], + }); + expect(callback).toHaveBeenCalledTimes(1); + callback.mockClear(); + jest.advanceTimersByTime(250); + + market.simulateMessage({ + event_type: 'price_change', + market: 'market-1', + price_changes: [ + { + asset_id: 'token1', + price: '0.58', + best_bid: '0.56', + best_ask: '0.59', + }, + ], + timestamp: '2025-01-12T12:00:00Z', + }); + // No emit until the next `book` event repopulates the cache with real + // level sizes. + jest.advanceTimersByTime(500); + expect(callback).not.toHaveBeenCalled(); + + // Once a fresh book arrives, the new state is delivered. + market.simulateMessage({ + event_type: 'book', + market: 'market-1', + asset_id: 'token1', + bids: [{ price: '0.56', size: '10' }], + asks: [{ price: '0.59', size: '10' }], + }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback.mock.calls[0][0].bids).toEqual([ + { price: 0.56, size: 10 }, + ]); + expect(callback.mock.calls[0][0].asks).toEqual([ + { price: 0.59, size: 10 }, + ]); + }); + + it('does not emit orderbook updates when there is no cached book', () => { + const manager = WebSocketManager.getInstance(); + const orderbookCallback = jest.fn(); + const priceCallback = jest.fn(); + + manager.subscribeToOrderbook('token1', orderbookCallback); + manager.subscribeToMarketPrices(['token1'], priceCallback); + const market = getMarketInstance(); + market.simulateOpen(); + + market.simulateMessage({ + event_type: 'price_change', + market: 'market-1', + price_changes: [ + { + asset_id: 'token1', + price: '0.50', + best_bid: '0.48', + best_ask: '0.52', + }, + ], + timestamp: '2025-01-12T12:00:00Z', + }); + + expect(priceCallback).toHaveBeenCalledTimes(1); + expect(orderbookCallback).not.toHaveBeenCalled(); + }); + + it('does not unsubscribe a token shared with an active price subscription', () => { + const manager = WebSocketManager.getInstance(); + + manager.subscribeToMarketPrices(['token1'], jest.fn()); + const unsubscribeOrderbook = manager.subscribeToOrderbook( + 'token1', + jest.fn(), + ); + const market = getMarketInstance(); + market.simulateOpen(); + market.send.mockClear(); + + unsubscribeOrderbook(); + + expect(market.send).not.toHaveBeenCalledWith( + JSON.stringify({ + operation: 'unsubscribe', + assets_ids: ['token1'], + }), + ); + }); + + it('sends WS unsubscribe when last orderbook subscriber is removed and no price sub references the token', () => { + const manager = WebSocketManager.getInstance(); + + const unsubscribe = manager.subscribeToOrderbook('token1', jest.fn()); + const market = getMarketInstance(); + market.simulateOpen(); + market.send.mockClear(); + + unsubscribe(); + + expect(market.send).toHaveBeenCalledWith( + JSON.stringify({ + operation: 'unsubscribe', + assets_ids: ['token1'], + }), + ); + }); + + it('closes the market socket only when both price and orderbook maps are empty', () => { + const manager = WebSocketManager.getInstance(); + + const unsubscribePrice = manager.subscribeToMarketPrices( + ['token1'], + jest.fn(), + ); + const unsubscribeOrderbook = manager.subscribeToOrderbook( + 'token1', + jest.fn(), + ); + const market = getMarketInstance(); + market.simulateOpen(); + market.close.mockClear(); + + unsubscribePrice(); + expect(market.close).not.toHaveBeenCalled(); + + unsubscribeOrderbook(); + expect(market.close).toHaveBeenCalledTimes(1); + }); + + it('resubscribes the union of price and orderbook tokens on reconnect', () => { + const manager = WebSocketManager.getInstance(); + + manager.subscribeToMarketPrices(['tokenA'], jest.fn()); + manager.subscribeToOrderbook('tokenB', jest.fn()); + const market = getMarketInstance(); + market.simulateOpen(); + market.send.mockClear(); + + market.simulateClose(); + jest.advanceTimersByTime(3000); + const reconnected = + mockWebSocketInstances[mockWebSocketInstances.length - 1]; + reconnected.simulateOpen(); + + const subscribeCall = reconnected.send.mock.calls.find( + ([msg]: [string]) => { + try { + return JSON.parse(msg).type === 'market'; + } catch { + return false; + } + }, + ); + if (!subscribeCall) { + throw new Error('Expected a market subscribe call after reconnect'); + } + const payload = JSON.parse(subscribeCall[0]); + expect(payload.assets_ids).toEqual( + expect.arrayContaining(['tokenA', 'tokenB']), + ); + expect(payload.assets_ids).toHaveLength(2); + }); + }); + describe('crypto price subscriptions', () => { it('connects to RTDS WS when first subscription is made', () => { const manager = WebSocketManager.getInstance(); @@ -1605,6 +2080,7 @@ describe('WebSocketManager', () => { gameSubscriptionCount: 0, priceSubscriptionCount: 0, cryptoPriceSubscriptionCount: 0, + orderbookSubscriptionCount: 0, }); manager.subscribeToGame('123', jest.fn()); @@ -1616,6 +2092,8 @@ describe('WebSocketManager', () => { manager.subscribeToCryptoPrices(['btc/usd'], jest.fn()); mockWebSocketInstances[2].simulateOpen(); + manager.subscribeToOrderbook('token2', jest.fn()); + expect(manager.getConnectionStatus()).toEqual({ sportsConnected: true, marketConnected: true, @@ -1623,6 +2101,7 @@ describe('WebSocketManager', () => { gameSubscriptionCount: 1, priceSubscriptionCount: 1, cryptoPriceSubscriptionCount: 1, + orderbookSubscriptionCount: 1, }); }); }); diff --git a/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts b/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts index c1f1f195e63..36602dbaff8 100644 --- a/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts +++ b/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts @@ -3,6 +3,9 @@ import { CryptoPriceUpdate, CryptoPriceUpdateCallback, GameUpdate, + OrderbookCallback, + OrderbookLevel, + OrderbookSnapshot, PredictGamePeriod, PredictGameStatus, PriceUpdate, @@ -10,6 +13,7 @@ import { import { GameCache } from './GameCache'; import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; import { trace, endTrace, TraceName } from '../../../../../util/trace'; +import { OrderBook } from './types'; const SPORTS_WS_URL = 'wss://sports-api.polymarket.com/ws'; const MARKET_WS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market'; @@ -22,6 +26,7 @@ const RTDS_WS_URL = 'wss://ws-live-data.polymarket.com'; const RTDS_CRYPTO_PRICES_CHAINLINK_TOPIC = 'crypto_prices_chainlink'; const RTDS_PING_INTERVAL_MS = 5000; const DEFAULT_THROTTLE_INTERVAL_MS = 16; +const ORDERBOOK_EMIT_THROTTLE_MS = 250; type GameUpdateCallback = (update: GameUpdate) => void; type PriceUpdateCallback = (updates: PriceUpdate[]) => void; @@ -40,6 +45,9 @@ interface SportsWebSocketEvent { interface MarketWebSocketEvent { event_type: string; market: string; + asset_id?: string; + bids?: { price: string; size: string }[]; + asks?: { price: string; size: string }[]; price_changes?: { asset_id: string; price: string; @@ -73,6 +81,19 @@ export class WebSocketManager { private gameSubscriptions: Map> = new Map(); private priceSubscriptions: Map> = new Map(); + private orderbookSubscriptions: Map> = + new Map(); + private orderbookState: Map< + string, + { + bids: Map; + asks: Map; + timestamp: number; + } + > = new Map(); + private orderbookEmitTimers: Map> = + new Map(); + private orderbookPendingEmit: Set = new Set(); private sportsReconnectAttempts = 0; private marketReconnectAttempts = 0; @@ -314,9 +335,11 @@ export class WebSocketManager { subscriptionCallbacks.delete(callback); if (subscriptionCallbacks.size === 0) { this.priceSubscriptions.delete(subscriptionKey); - const remainingTokenIds = this.getSubscribedMarketTokenIds(); + const remainingPriceTokenIds = this.getSubscribedMarketTokenIds(); const tokenIdsToUnsubscribe = tokenIds.filter( - (tokenId) => !remainingTokenIds.has(tokenId), + (tokenId) => + !remainingPriceTokenIds.has(tokenId) && + !this.orderbookSubscriptions.has(tokenId), ); if (tokenIdsToUnsubscribe.length > 0) { this.sendMarketUnsubscribe(tokenIdsToUnsubscribe); @@ -324,12 +347,187 @@ export class WebSocketManager { } } - if (this.priceSubscriptions.size === 0) { + if ( + this.priceSubscriptions.size === 0 && + this.orderbookSubscriptions.size === 0 + ) { + this.disconnectMarket(); + } + }; + } + + subscribeToOrderbook( + tokenId: string, + callback: OrderbookCallback, + ): () => void { + let callbacks = this.orderbookSubscriptions.get(tokenId); + if (!callbacks) { + callbacks = new Set(); + this.orderbookSubscriptions.set(tokenId, callbacks); + } + callbacks.add(callback); + + this.ensureMarketConnection([tokenId]); + + // Replay cached snapshot to late subscribers so they render without waiting + // for the next WS book event. + const cached = this.orderbookState.get(tokenId); + if (cached) { + try { + callback(this.buildOrderbookSnapshot(tokenId, cached)); + } catch (error) { + DevLogger.log('WebSocketManager: Orderbook subscriber failed', { + error, + tokenId, + }); + } + } + + return () => { + const subscriptionCallbacks = this.orderbookSubscriptions.get(tokenId); + if (subscriptionCallbacks) { + subscriptionCallbacks.delete(callback); + if (subscriptionCallbacks.size === 0) { + this.orderbookSubscriptions.delete(tokenId); + this.orderbookState.delete(tokenId); + this.orderbookPendingEmit.delete(tokenId); + const pendingTimer = this.orderbookEmitTimers.get(tokenId); + if (pendingTimer) { + clearTimeout(pendingTimer); + this.orderbookEmitTimers.delete(tokenId); + } + const remainingPriceTokenIds = this.getSubscribedMarketTokenIds(); + if (!remainingPriceTokenIds.has(tokenId)) { + this.sendMarketUnsubscribe([tokenId]); + } + } + } + + if ( + this.priceSubscriptions.size === 0 && + this.orderbookSubscriptions.size === 0 + ) { this.disconnectMarket(); } }; } + /** + * Seed the orderbook cache with a REST snapshot before WS book events arrive. + * REST returns `bids` ascending and `asks` descending; the cached price/size + * maps are unordered, and {@link buildOrderbookSnapshot} re-sorts on emit + * (bids desc, asks asc). + * + * No-ops in two cases: when no subscriber is registered for the token (the + * consumer unsubscribed before REST resolved), and when a WS `book` event + * has already populated the cache (the WS push is by definition newer than + * the REST snapshot that started in parallel, so seeding here would + * visually regress the depth chart until the next WS event arrives). + */ + public seedOrderbookSnapshot(tokenId: string, book: OrderBook): void { + if (!this.orderbookSubscriptions.has(tokenId)) { + return; + } + if (this.orderbookState.has(tokenId)) { + return; + } + + const bids = new Map(); + book.bids?.forEach((level) => { + const size = parseFloat(level.size); + if (Number.isFinite(size) && size > 0) { + bids.set(level.price, size); + } + }); + const asks = new Map(); + book.asks?.forEach((level) => { + const size = parseFloat(level.size); + if (Number.isFinite(size) && size > 0) { + asks.set(level.price, size); + } + }); + + this.orderbookState.set(tokenId, { + bids, + asks, + timestamp: Date.now(), + }); + + this.emitOrderbookSnapshot(tokenId); + } + + private buildOrderbookSnapshot( + tokenId: string, + cached: { + bids: Map; + asks: Map; + timestamp: number; + }, + ): OrderbookSnapshot { + const bids: OrderbookLevel[] = []; + cached.bids.forEach((size, price) => { + const numericPrice = parseFloat(price); + if (Number.isFinite(numericPrice)) { + bids.push({ price: numericPrice, size }); + } + }); + bids.sort((a, b) => b.price - a.price); + + const asks: OrderbookLevel[] = []; + cached.asks.forEach((size, price) => { + const numericPrice = parseFloat(price); + if (Number.isFinite(numericPrice)) { + asks.push({ price: numericPrice, size }); + } + }); + asks.sort((a, b) => a.price - b.price); + + return { + tokenId, + bids, + asks, + timestamp: cached.timestamp, + }; + } + + private emitOrderbookSnapshot(tokenId: string): void { + const cached = this.orderbookState.get(tokenId); + const callbacks = this.orderbookSubscriptions.get(tokenId); + if (!cached || !callbacks || callbacks.size === 0) { + return; + } + + const snapshot = this.buildOrderbookSnapshot(tokenId, cached); + callbacks.forEach((callback) => { + try { + callback(snapshot); + } catch (error) { + DevLogger.log('WebSocketManager: Orderbook subscriber failed', { + error, + tokenId, + }); + } + }); + } + + private scheduleOrderbookEmit(tokenId: string): void { + // Emit immediately if no timer is active (first emit per window is instant). + if (!this.orderbookEmitTimers.has(tokenId)) { + this.emitOrderbookSnapshot(tokenId); + const timer = setTimeout(() => { + this.orderbookEmitTimers.delete(tokenId); + if (this.orderbookPendingEmit.delete(tokenId)) { + this.emitOrderbookSnapshot(tokenId); + } + }, ORDERBOOK_EMIT_THROTTLE_MS); + this.orderbookEmitTimers.set(tokenId, timer); + return; + } + + // A timer is already active; mark a trailing emit. + this.orderbookPendingEmit.add(tokenId); + } + subscribeToCryptoPrices( symbols: string[], callback: CryptoPriceUpdateCallback, @@ -415,6 +613,11 @@ export class WebSocketManager { try { const data: MarketWebSocketEvent = JSON.parse(event.data); + if (data.event_type === 'book' && data.asset_id) { + this.handleBookEvent(data); + return; + } + if (data.event_type !== 'price_change' || !data.price_changes) { return; } @@ -436,6 +639,14 @@ export class WebSocketManager { callbacks.forEach((callback) => callback(relevantUpdates)); } }); + + // Intentionally NOT forwarding `price_change` to orderbook subscribers. + // The payload only carries `best_bid` / `best_ask` (no per-level + // size), so we have nothing to insert into the cached book — we could + // only PRUNE crossed levels, which leaves the chart showing a + // wider-than-real spread until the next full `book` event. Waiting + // for the next `book` event (typically <1s) is preferable to emitting + // a knowingly stale snapshot. } catch (error) { DevLogger.log('WebSocketManager: Failed to parse market message', { error, @@ -443,6 +654,38 @@ export class WebSocketManager { } }; + private handleBookEvent(data: MarketWebSocketEvent): void { + if (!data.asset_id) { + return; + } + if (!this.orderbookSubscriptions.has(data.asset_id)) { + return; + } + + const bids = new Map(); + data.bids?.forEach((level) => { + const size = parseFloat(level.size); + if (Number.isFinite(size) && size > 0) { + bids.set(level.price, size); + } + }); + const asks = new Map(); + data.asks?.forEach((level) => { + const size = parseFloat(level.size); + if (Number.isFinite(size) && size > 0) { + asks.set(level.price, size); + } + }); + + this.orderbookState.set(data.asset_id, { + bids, + asks, + timestamp: Date.now(), + }); + + this.scheduleOrderbookEmit(data.asset_id); + } + private sendMarketSubscribe(tokenIds: string[]): void { if (this.marketWs?.readyState !== WebSocket.OPEN) { return; @@ -485,6 +728,9 @@ export class WebSocketManager { private resubscribeAllMarkets(): void { const allTokenIds = this.getSubscribedMarketTokenIds(); + this.orderbookSubscriptions.forEach((_, tokenId) => { + allTokenIds.add(tokenId); + }); if (allTokenIds.size > 0) { this.sendMarketSubscribe(Array.from(allTokenIds)); @@ -492,7 +738,10 @@ export class WebSocketManager { } private scheduleMarketReconnect(): void { - if (this.priceSubscriptions.size === 0) { + if ( + this.priceSubscriptions.size === 0 && + this.orderbookSubscriptions.size === 0 + ) { return; } @@ -550,6 +799,14 @@ export class WebSocketManager { private disconnectMarket(): void { this.cleanupMarketConnection(); this.marketReconnectAttempts = 0; + // Drop cached orderbook state so a future reconnect doesn't replay a + // stale snapshot to subscribers. The provider's REST bootstrap and the + // next live `book` event will repopulate. Also flush throttle timers so + // they don't fire after the socket is closed. + this.orderbookState.clear(); + this.orderbookEmitTimers.forEach((timer) => clearTimeout(timer)); + this.orderbookEmitTimers.clear(); + this.orderbookPendingEmit.clear(); } private ensureRtdsConnection(symbols?: string[]): void { @@ -844,7 +1101,10 @@ export class WebSocketManager { if (this.gameSubscriptions.size > 0) { this.connectSports(); } - if (this.priceSubscriptions.size > 0) { + if ( + this.priceSubscriptions.size > 0 || + this.orderbookSubscriptions.size > 0 + ) { this.connectMarket(); } if (this.cryptoPriceSubscriptions.size > 0) { @@ -863,6 +1123,11 @@ export class WebSocketManager { this.gameSubscriptions.clear(); this.priceSubscriptions.clear(); this.cryptoPriceSubscriptions.clear(); + this.orderbookSubscriptions.clear(); + this.orderbookState.clear(); + this.orderbookPendingEmit.clear(); + this.orderbookEmitTimers.forEach((timer) => clearTimeout(timer)); + this.orderbookEmitTimers.clear(); if (this.appStateSubscription) { this.appStateSubscription.remove(); @@ -877,6 +1142,7 @@ export class WebSocketManager { gameSubscriptionCount: number; priceSubscriptionCount: number; cryptoPriceSubscriptionCount: number; + orderbookSubscriptionCount: number; } { return { sportsConnected: this.sportsWs?.readyState === WebSocket.OPEN, @@ -885,6 +1151,7 @@ export class WebSocketManager { gameSubscriptionCount: this.gameSubscriptions.size, priceSubscriptionCount: this.priceSubscriptions.size, cryptoPriceSubscriptionCount: this.cryptoPriceSubscriptions.size, + orderbookSubscriptionCount: this.orderbookSubscriptions.size, }; } } diff --git a/app/components/UI/Predict/providers/polymarket/types.ts b/app/components/UI/Predict/providers/polymarket/types.ts index 8bcc3142d97..2c9d1c8f5ca 100644 --- a/app/components/UI/Predict/providers/polymarket/types.ts +++ b/app/components/UI/Predict/providers/polymarket/types.ts @@ -75,7 +75,7 @@ export interface PolymarketApiMarket { closed: boolean; active: boolean; resolvedBy: string; - orderPriceMinTickSize: number; + orderPriceMinTickSize: number | null; events?: PolymarketApiEvent[]; umaResolutionStatus: string; line?: number; diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index 270aada3f51..a3c5432a94d 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -1067,7 +1067,7 @@ export const parsePolymarketMarket = ( sportsMarketType: market.sportsMarketType, line: market.line, negRisk: market.negRisk, - tickSize: market.orderPriceMinTickSize.toString(), + tickSize: market.orderPriceMinTickSize?.toString() ?? '0.01', resolvedBy: market.resolvedBy, resolutionStatus: market.umaResolutionStatus, }); @@ -1097,8 +1097,8 @@ export const parsePolymarketEvents = ( const { category, teamLookup, extendedSportsMarketsLeagues } = options; const sortBy = options.sortMarketsBy ?? sortMarketsBy; - const parsedMarkets: PredictMarket[] = events.map( - (event: PolymarketApiEvent) => { + return events.flatMap((event: PolymarketApiEvent) => { + try { const tags = Array.isArray(event.tags) ? event.tags : []; const eventLeague = getEventLeague(event, extendedSportsMarketsLeagues); @@ -1152,33 +1152,41 @@ export const parsePolymarketEvents = ( ? buildOutcomeGroups(outcomes) : undefined; - return { - id: event.id, - slug: event.slug, - providerId: POLYMARKET_PROVIDER_ID, - title: event.title, - description, - image: event.icon, - status: event.closed - ? PredictMarketStatus.CLOSED - : PredictMarketStatus.OPEN, - recurrence: getRecurrence(event.series), - endDate: event.endDate, - category, - tags: tags.map((t) => t.slug), - outcomes, - ...(outcomeGroups && { outcomeGroups }), - liquidity: event.liquidity, - volume: event.volume, - game, - ...(seriesData && { series: seriesData }), - ...(event.parentEventId !== undefined && { - parentMarketId: event.parentEventId, - }), - }; - }, - ); - return parsedMarkets; + return [ + { + id: event.id, + slug: event.slug, + providerId: POLYMARKET_PROVIDER_ID, + title: event.title, + description, + image: event.icon, + status: event.closed + ? PredictMarketStatus.CLOSED + : PredictMarketStatus.OPEN, + recurrence: getRecurrence(event.series), + endDate: event.endDate, + category, + tags: tags.map((t) => t.slug), + outcomes, + ...(outcomeGroups && { outcomeGroups }), + liquidity: event.liquidity, + volume: event.volume, + game, + ...(seriesData && { series: seriesData }), + ...(event.parentEventId !== undefined && { + parentMarketId: event.parentEventId, + }), + } as PredictMarket, + ]; + } catch (err) { + Logger.error(err instanceof Error ? err : new Error(String(err)), { + feature: 'predict', + method: 'parsePolymarketEvents', + eventId: event.id, + }); + return []; + } + }); }; /** diff --git a/app/components/UI/Predict/providers/types.ts b/app/components/UI/Predict/providers/types.ts index 2d673c81bc7..e4522567825 100644 --- a/app/components/UI/Predict/providers/types.ts +++ b/app/components/UI/Predict/providers/types.ts @@ -16,6 +16,7 @@ import { GetPriceParams, GetPriceResponse, GetSeriesParams, + OrderbookCallback, OrderPreview, OrderResult, PlaceOrderParams, @@ -47,6 +48,7 @@ export type { GetMarketsParams, GetMarketsResult, GetPositionsParams, + OrderbookCallback, OrderPreview, OrderResult, PlaceOrderParams, @@ -214,6 +216,11 @@ export interface PredictProvider { callback: PriceUpdateCallback, ): () => void; + subscribeToOrderbook?( + tokenId: string, + callback: OrderbookCallback, + ): () => void; + subscribeToCryptoPrices?( symbols: string[], callback: CryptoPriceUpdateCallback, diff --git a/app/components/UI/Predict/schemas/flags.test.ts b/app/components/UI/Predict/schemas/flags.test.ts index 12786dac994..79172659cd6 100644 --- a/app/components/UI/Predict/schemas/flags.test.ts +++ b/app/components/UI/Predict/schemas/flags.test.ts @@ -217,7 +217,11 @@ describe('PredictWorldCupSchema', () => { seriesId: '11433', tagSlug: 'fifa-world-cup', gamesTagId: '100639', - bannerImageUrl: 'https://example.com/banner.png', + bannerImage: { + url: 'https://example.com/banner.png', + width: 400, + height: 200, + }, stages: [ { key: 'group_stage', diff --git a/app/components/UI/Predict/schemas/flags.ts b/app/components/UI/Predict/schemas/flags.ts index 8ea6d30159f..2a2c8c3117e 100644 --- a/app/components/UI/Predict/schemas/flags.ts +++ b/app/components/UI/Predict/schemas/flags.ts @@ -76,7 +76,13 @@ export const PredictWorldCupSchema = defaulted( string(), () => DEFAULT_PREDICT_WORLD_CUP_FLAG.gamesTagId, ), - bannerImageUrl: optional(string()), + bannerImage: optional( + object({ + url: string(), + width: number(), + height: number(), + }), + ), stages: defaulted(array(PredictWorldCupStageSchema), () => []), }), () => DEFAULT_PREDICT_WORLD_CUP_FLAG, diff --git a/app/components/UI/Predict/types/flags.ts b/app/components/UI/Predict/types/flags.ts index 2f28fbe7f66..07eae3ff165 100644 --- a/app/components/UI/Predict/types/flags.ts +++ b/app/components/UI/Predict/types/flags.ts @@ -37,7 +37,11 @@ export interface PredictWorldCupConfig extends VersionGatedFeatureFlag { seriesId: string; tagSlug: string; gamesTagId: string; - bannerImageUrl?: string; + bannerImage?: { + url: string; + width: number; + height: number; + }; stages: PredictWorldCupStageConfig[]; } diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts index 08b85ecc2dc..05f36cd2b88 100644 --- a/app/components/UI/Predict/types/index.ts +++ b/app/components/UI/Predict/types/index.ts @@ -120,6 +120,7 @@ export type PredictMarket = { series?: PredictSeries; parentMarketId?: string | number | null; childMarketIds?: string[]; + isHighlighted?: boolean; }; export type PredictSeries = { @@ -267,6 +268,18 @@ export interface CryptoPriceUpdate { timestamp: number; } +export interface OrderbookLevel { + price: number; + size: number; +} + +export interface OrderbookSnapshot { + tokenId: string; + bids: OrderbookLevel[]; + asks: OrderbookLevel[]; + timestamp: number; +} + export type PredictOutcomeGroup = { key: string; outcomes: PredictOutcome[]; @@ -669,6 +682,7 @@ export interface ConnectionStatus { export type GameUpdateCallback = (update: GameUpdate) => void; export type PriceUpdateCallback = (updates: PriceUpdate[]) => void; export type CryptoPriceUpdateCallback = (update: CryptoPriceUpdate) => void; +export type OrderbookCallback = (snapshot: OrderbookSnapshot) => void; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PrepareDepositParams {} diff --git a/app/components/UI/Predict/utils/marketStaleness.test.ts b/app/components/UI/Predict/utils/marketStaleness.test.ts new file mode 100644 index 00000000000..f996db9f241 --- /dev/null +++ b/app/components/UI/Predict/utils/marketStaleness.test.ts @@ -0,0 +1,349 @@ +import { + PredictMarketStatus, + Recurrence, + type PredictMarket, + type PredictOutcome, + type PredictOutcomeGroup, +} from '../types'; +import { + filterVisibleMarketOutcomes, + getPredictMarketProbabilityPenalty, + getPredictMarketTimePenalty, + getVisiblePredictMarket, + getVisiblePredictMarkets, + isPredictMarketExpiredByTime, + isPredictOutcomeDead, +} from './marketStaleness'; + +const NOW = new Date('2026-03-18T12:00:00.000Z'); + +const createOutcome = ({ + id, + price, + status = PredictMarketStatus.OPEN, +}: { + id: string; + price?: number; + status?: PredictMarketStatus; +}): PredictOutcome => ({ + id, + providerId: 'polymarket', + marketId: 'market-1', + title: id, + description: id, + image: '', + status, + tokens: + price === undefined + ? [] + : [ + { id: `${id}-yes`, title: 'Yes', price }, + { id: `${id}-no`, title: 'No', price: 1 - price }, + ], + volume: 100, + groupItemTitle: id, +}); + +const createMarket = ({ + id, + outcomes = [createOutcome({ id: 'outcome-1', price: 0.5 })], + status = PredictMarketStatus.OPEN, + recurrence = Recurrence.NONE, + endDate, + isHighlighted, + outcomeGroups, + game, +}: { + id: string; + outcomes?: PredictOutcome[]; + status?: PredictMarketStatus; + recurrence?: Recurrence; + endDate?: string; + isHighlighted?: boolean; + outcomeGroups?: PredictOutcomeGroup[]; + game?: PredictMarket['game']; +}): PredictMarket => ({ + id, + providerId: 'polymarket', + slug: id, + title: id, + description: id, + image: '', + status, + recurrence, + category: 'trending', + tags: [], + outcomes, + ...(outcomeGroups && { outcomeGroups }), + liquidity: 100, + volume: 100, + ...(endDate && { endDate }), + ...(isHighlighted && { isHighlighted }), + ...(game && { game }), +}); + +const createGame = ( + status: NonNullable['status'], +): NonNullable => ({ + id: 'game-1', + startTime: '2026-03-18T10:00:00.000Z', + status, + league: 'nba', + elapsed: null, + period: null, + score: null, + homeTeam: { + id: 'home', + name: 'Home', + logo: '', + abbreviation: 'HOME', + color: 'black', + }, + awayTeam: { + id: 'away', + name: 'Away', + logo: '', + abbreviation: 'AWAY', + color: 'white', + }, +}); + +describe('marketStaleness', () => { + describe('isPredictOutcomeDead', () => { + it('treats probabilities at or beyond the dead thresholds as dead', () => { + expect( + isPredictOutcomeDead(createOutcome({ id: 'high', price: 0.95 })), + ).toBe(true); + expect( + isPredictOutcomeDead(createOutcome({ id: 'low', price: 0.05 })), + ).toBe(true); + }); + + it('keeps probabilities inside the dead thresholds live', () => { + expect( + isPredictOutcomeDead(createOutcome({ id: 'high-live', price: 0.949 })), + ).toBe(false); + expect( + isPredictOutcomeDead(createOutcome({ id: 'low-live', price: 0.051 })), + ).toBe(false); + }); + + it('treats missing probability as dead', () => { + expect(isPredictOutcomeDead(createOutcome({ id: 'missing' }))).toBe(true); + }); + }); + + describe('filterVisibleMarketOutcomes', () => { + it('hides a market when all outcomes are dead', () => { + const market = createMarket({ + id: 'all-dead', + outcomes: [ + createOutcome({ id: 'dead-high', price: 0.97 }), + createOutcome({ id: 'dead-low', price: 0.03 }), + ], + }); + + expect(filterVisibleMarketOutcomes(market)).toBeNull(); + }); + + it('keeps live outcomes in their original order', () => { + const liveOne = createOutcome({ id: 'live-one', price: 0.4 }); + const liveTwo = createOutcome({ id: 'live-two', price: 0.6 }); + const market = createMarket({ + id: 'partial', + outcomes: [ + createOutcome({ id: 'dead-high', price: 0.97 }), + liveOne, + createOutcome({ id: 'dead-low', price: 0.03 }), + liveTwo, + ], + }); + + expect(filterVisibleMarketOutcomes(market)?.outcomes).toEqual([ + liveOne, + liveTwo, + ]); + }); + + it('keeps outcome groups synchronized with visible outcomes', () => { + const live = createOutcome({ id: 'live', price: 0.5 }); + const dead = createOutcome({ id: 'dead', price: 0.99 }); + const market = createMarket({ + id: 'grouped', + outcomes: [live, dead], + outcomeGroups: [ + { + key: 'main', + outcomes: [live, dead], + subgroups: [ + { key: 'live-subgroup', outcomes: [live] }, + { key: 'dead-subgroup', outcomes: [dead] }, + ], + }, + ], + }); + + expect(filterVisibleMarketOutcomes(market)?.outcomeGroups).toEqual([ + { + key: 'main', + outcomes: [live], + subgroups: [{ key: 'live-subgroup', outcomes: [live] }], + }, + ]); + }); + + it('omits outcomeGroups when all groups are filtered out', () => { + const live = createOutcome({ id: 'live', price: 0.5 }); + const dead = createOutcome({ id: 'dead', price: 0.99 }); + const market = createMarket({ + id: 'groups-pruned', + outcomes: [live, dead], + outcomeGroups: [ + { + key: 'dead-only', + outcomes: [dead], + }, + ], + }); + + expect(filterVisibleMarketOutcomes(market)).toEqual({ + ...market, + outcomes: [live], + }); + }); + }); + + describe('getVisiblePredictMarket', () => { + it('hides markets that are not open', () => { + const market = createMarket({ + id: 'resolved', + status: PredictMarketStatus.RESOLVED, + }); + + expect(getVisiblePredictMarket(market, { now: NOW })).toBeNull(); + }); + + it('hides ended games', () => { + const market = createMarket({ + id: 'ended-game', + game: createGame('ended'), + }); + + expect(getVisiblePredictMarket(market, { now: NOW })).toBeNull(); + }); + + it('hides expired daily markets', () => { + const market = createMarket({ + id: 'expired-daily', + recurrence: Recurrence.DAILY, + endDate: '2026-03-18T11:59:00.000Z', + }); + + expect(getVisiblePredictMarket(market, { now: NOW })).toBeNull(); + }); + + it('does not hide expired non-daily markets by time alone', () => { + const market = createMarket({ + id: 'expired-none', + recurrence: Recurrence.NONE, + endDate: '2026-03-18T11:59:00.000Z', + }); + + expect(getVisiblePredictMarket(market, { now: NOW })).toEqual(market); + }); + + it('keeps highlighted open markets without staleness filtering', () => { + const market = createMarket({ + id: 'highlight', + isHighlighted: true, + recurrence: Recurrence.DAILY, + endDate: '2026-03-18T11:59:00.000Z', + outcomes: [createOutcome({ id: 'dead-high', price: 0.99 })], + }); + + expect(getVisiblePredictMarket(market, { now: NOW })).toEqual(market); + }); + }); + + describe('penalties and ranking', () => { + it('applies probability staleness penalty from the original market outcomes', () => { + const market = createMarket({ + id: 'stale-top-outcome', + outcomes: [ + createOutcome({ id: 'dead-high', price: 0.97 }), + createOutcome({ id: 'live', price: 0.5 }), + ], + }); + + expect(getPredictMarketProbabilityPenalty(market)).toBeCloseTo(0.8); + }); + + it('applies last-hour time penalty to daily markets', () => { + const market = createMarket({ + id: 'last-hour', + recurrence: Recurrence.DAILY, + endDate: '2026-03-18T12:30:00.000Z', + }); + + expect(getPredictMarketTimePenalty(market, { now: NOW })).toBe(0.5); + }); + + it('does not apply last-hour time penalty to non-daily non-game markets', () => { + const market = createMarket({ + id: 'last-hour-none', + recurrence: Recurrence.NONE, + endDate: '2026-03-18T12:30:00.000Z', + }); + + expect(getPredictMarketTimePenalty(market, { now: NOW })).toBe(1); + }); + + it('detects time expiry separately from ranking penalties', () => { + const market = createMarket({ + id: 'expired-daily', + recurrence: Recurrence.DAILY, + endDate: '2026-03-18T12:00:00.000Z', + }); + + expect(isPredictMarketExpiredByTime(market, { now: NOW })).toBe(true); + }); + + it('ranks highlighted markets first and stale markets behind live markets', () => { + const highlighted = createMarket({ + id: 'highlighted', + isHighlighted: true, + outcomes: [createOutcome({ id: 'highlighted-dead', price: 0.99 })], + }); + const stale = createMarket({ + id: 'stale', + recurrence: Recurrence.DAILY, + endDate: '2026-03-18T12:30:00.000Z', + outcomes: [ + createOutcome({ id: 'stale-dead', price: 0.99 }), + createOutcome({ id: 'stale-live', price: 0.5 }), + ], + }); + const live = createMarket({ + id: 'live', + outcomes: [createOutcome({ id: 'live-outcome', price: 0.5 })], + }); + + expect( + getVisiblePredictMarkets([stale, highlighted, live], { now: NOW }).map( + (market) => market.id, + ), + ).toEqual(['highlighted', 'live', 'stale']); + }); + + it('preserves original order when ranking scores tie', () => { + const first = createMarket({ id: 'first' }); + const second = createMarket({ id: 'second' }); + + expect( + getVisiblePredictMarkets([first, second], { now: NOW }).map( + (market) => market.id, + ), + ).toEqual(['first', 'second']); + }); + }); +}); diff --git a/app/components/UI/Predict/utils/marketStaleness.ts b/app/components/UI/Predict/utils/marketStaleness.ts new file mode 100644 index 00000000000..ec47991990c --- /dev/null +++ b/app/components/UI/Predict/utils/marketStaleness.ts @@ -0,0 +1,256 @@ +import { + PredictMarketStatus, + Recurrence, + type PredictMarket, + type PredictOutcome, + type PredictOutcomeGroup, +} from '../types'; + +export const PREDICT_DEAD_OUTCOME_HIGH_THRESHOLD = 0.95; +export const PREDICT_DEAD_OUTCOME_LOW_THRESHOLD = 0.05; +export const PREDICT_MIN_STALENESS_PENALTY = 0.1; +export const PREDICT_LAST_HOUR_TIME_PENALTY = 0.5; + +const HOUR_IN_MS = 60 * 60 * 1000; + +export interface PredictMarketStalenessOptions { + now?: Date | number; +} + +const getNowMs = (options?: PredictMarketStalenessOptions): number => { + if (options?.now instanceof Date) { + return options.now.getTime(); + } + + if (typeof options?.now === 'number') { + return options.now; + } + + return Date.now(); +}; + +const getOutcomeProbability = (outcome: PredictOutcome): number | null => { + const probability = outcome.tokens?.[0]?.price; + + if (typeof probability !== 'number' || !Number.isFinite(probability)) { + return null; + } + + return probability; +}; + +export const isPredictOutcomeDead = (outcome: PredictOutcome): boolean => { + const probability = getOutcomeProbability(outcome); + + if (probability === null) { + return true; + } + + return ( + probability >= PREDICT_DEAD_OUTCOME_HIGH_THRESHOLD || + probability <= PREDICT_DEAD_OUTCOME_LOW_THRESHOLD + ); +}; + +const isPredictOutcomeDisplayable = (outcome: PredictOutcome): boolean => + outcome.status === PredictMarketStatus.OPEN && !isPredictOutcomeDead(outcome); + +const filterOutcomeGroup = ( + group: PredictOutcomeGroup, + visibleOutcomeIds: Set, +): PredictOutcomeGroup | null => { + const outcomes = group.outcomes.filter((outcome) => + visibleOutcomeIds.has(outcome.id), + ); + const subgroups = group.subgroups + ?.map((subgroup) => filterOutcomeGroup(subgroup, visibleOutcomeIds)) + .filter((subgroup): subgroup is PredictOutcomeGroup => Boolean(subgroup)); + + if (outcomes.length === 0 && (!subgroups || subgroups.length === 0)) { + return null; + } + + return { + ...group, + outcomes, + ...(subgroups && { subgroups }), + }; +}; + +export const filterVisibleMarketOutcomes = ( + market: PredictMarket, +): PredictMarket | null => { + const outcomes = market.outcomes.filter(isPredictOutcomeDisplayable); + + if (outcomes.length === 0) { + return null; + } + + const visibleOutcomeIds = new Set(outcomes.map((outcome) => outcome.id)); + const outcomeGroups = market.outcomeGroups + ?.map((group) => filterOutcomeGroup(group, visibleOutcomeIds)) + .filter((group): group is PredictOutcomeGroup => Boolean(group)); + + return { + ...market, + outcomes, + ...(outcomeGroups && outcomeGroups.length > 0 ? { outcomeGroups } : {}), + }; +}; + +const isDailyMarket = (market: PredictMarket): boolean => + market.recurrence === Recurrence.DAILY; + +const isGameMarket = (market: PredictMarket): boolean => Boolean(market.game); + +const getHoursUntilEndDate = ( + market: PredictMarket, + options?: PredictMarketStalenessOptions, +): number | null => { + if (!market.endDate) { + return null; + } + + const endDateMs = Date.parse(market.endDate); + if (!Number.isFinite(endDateMs)) { + return null; + } + + return (endDateMs - getNowMs(options)) / HOUR_IN_MS; +}; + +export const isPredictMarketExpiredByTime = ( + market: PredictMarket, + options?: PredictMarketStalenessOptions, +): boolean => { + if (market.game?.status === 'ended') { + return true; + } + + if (!isDailyMarket(market)) { + return false; + } + + const hoursUntilEndDate = getHoursUntilEndDate(market, options); + return hoursUntilEndDate !== null && hoursUntilEndDate <= 0; +}; + +export const getPredictMarketTimePenalty = ( + market: PredictMarket, + options?: PredictMarketStalenessOptions, +): number => { + if (!isDailyMarket(market) && !isGameMarket(market)) { + return 1; + } + + const hoursUntilEndDate = getHoursUntilEndDate(market, options); + if (hoursUntilEndDate === null) { + return 1; + } + + return hoursUntilEndDate > 0 && hoursUntilEndDate <= 1 + ? PREDICT_LAST_HOUR_TIME_PENALTY + : 1; +}; + +const getMaxOutcomeProbability = (market: PredictMarket): number | null => { + const probabilities = market.outcomes + .map(getOutcomeProbability) + .filter((probability): probability is number => probability !== null); + + if (probabilities.length === 0) { + return null; + } + + return Math.max(...probabilities); +}; + +export const getPredictMarketProbabilityPenalty = ( + market: PredictMarket, +): number => { + const maxProbability = getMaxOutcomeProbability(market); + + if ( + maxProbability === null || + maxProbability <= PREDICT_DEAD_OUTCOME_HIGH_THRESHOLD + ) { + return 1; + } + + return Math.max( + PREDICT_MIN_STALENESS_PENALTY, + 1 - (maxProbability - PREDICT_DEAD_OUTCOME_HIGH_THRESHOLD) * 10, + ); +}; + +const getPredictMarketStalenessPenalty = ( + market: PredictMarket, + options?: PredictMarketStalenessOptions, +): number => { + if (market.isHighlighted) { + return 1; + } + + return ( + getPredictMarketProbabilityPenalty(market) * + getPredictMarketTimePenalty(market, options) + ); +}; + +export const getVisiblePredictMarket = ( + market: PredictMarket, + options?: PredictMarketStalenessOptions, +): PredictMarket | null => { + if (market.status !== PredictMarketStatus.OPEN) { + return null; + } + + if (market.isHighlighted) { + return market; + } + + if (isPredictMarketExpiredByTime(market, options)) { + return null; + } + + return filterVisibleMarketOutcomes(market); +}; + +export const getVisiblePredictMarkets = ( + markets: PredictMarket[], + options?: PredictMarketStalenessOptions, +): PredictMarket[] => { + const visibleMarketEntries = markets + .map((market, index) => ({ + originalMarket: market, + visibleMarket: getVisiblePredictMarket(market, options), + index, + })) + .filter( + ( + entry, + ): entry is { + originalMarket: PredictMarket; + visibleMarket: PredictMarket; + index: number; + } => Boolean(entry.visibleMarket), + ); + + const highlightedMarkets = visibleMarketEntries + .filter(({ visibleMarket }) => visibleMarket.isHighlighted) + .map(({ visibleMarket }) => visibleMarket); + + const rankedMarkets = visibleMarketEntries + .filter(({ visibleMarket }) => !visibleMarket.isHighlighted) + .map(({ originalMarket, visibleMarket, index }) => ({ + market: visibleMarket, + index, + score: + (markets.length - index) * + getPredictMarketStalenessPenalty(originalMarket, options), + })) + .sort((a, b) => b.score - a.score || a.index - b.index) + .map(({ market }) => market); + + return [...highlightedMarkets, ...rankedMarkets]; +}; diff --git a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts index c74b3a1a0fd..503d2f01fbf 100644 --- a/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts +++ b/app/components/UI/Predict/utils/resolvePredictFeatureFlags.test.ts @@ -236,7 +236,11 @@ describe('resolvePredictFeatureFlags', () => { showMainFeedBanner: true, showMainFeedTab: true, showWorldCupScreen: true, - bannerImageUrl: 'https://example.com/banner.png', + bannerImage: { + url: 'https://example.com/banner.png', + width: 400, + height: 200, + }, stages: [ { key: 'group_stage', @@ -255,7 +259,11 @@ describe('resolvePredictFeatureFlags', () => { showMainFeedBanner: true, showMainFeedTab: true, showWorldCupScreen: true, - bannerImageUrl: 'https://example.com/banner.png', + bannerImage: { + url: 'https://example.com/banner.png', + width: 400, + height: 200, + }, stages: [ { key: 'group_stage', diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx index 0107cc2ac6a..142b9d8a83a 100644 --- a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx +++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx @@ -1,8 +1,11 @@ import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; import renderWithProvider, { DeepPartial, } from '../../../../../util/test/renderWithProvider'; -import StakeConfirmationView from './StakeConfirmationView'; +import StakeConfirmationView, { + STAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID, +} from './StakeConfirmationView'; import { Image, ImageSize } from 'react-native'; import { createMockAccountsControllerState } from '../../../../../util/test/accountsControllerTestUtils'; import { backgroundState } from '../../../../../util/test/initial-root-state'; @@ -12,6 +15,8 @@ import { StakeConfirmationViewRouteParams } from './StakeConfirmationView.types' import { MOCK_POOL_STAKING_SDK } from '../../__mocks__/stakeMockData'; import { RootState } from '../../../../../reducers'; import { strings } from '../../../../../../locales/i18n'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { EVENT_LOCATIONS, EVENT_PROVIDERS } from '../../constants/events'; jest.mock('../../../../hooks/useIpfsGateway', () => jest.fn()); @@ -74,13 +79,18 @@ jest.mock('react-redux', () => ({ .mockImplementation((callback) => callback(mockInitialState)), })); +const mockNavigate = jest.fn(); +const mockGoBack = jest.fn(); +const mockSetOptions = jest.fn(); + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { ...actualNav, useNavigation: () => ({ - navigate: jest.fn(), - setOptions: jest.fn(), + navigate: mockNavigate, + setOptions: mockSetOptions, + goBack: mockGoBack, }), useRoute: () => ({ key: '1', @@ -97,6 +107,24 @@ jest.mock('@react-navigation/native', () => { }; }); +const mockAddProperties = jest.fn().mockReturnThis(); +const mockBuild = jest.fn().mockReturnValue({ + name: 'STAKE_CONFIRMATION_BACK_CLICKED', +}); +const mockEventBuilder = { + addProperties: mockAddProperties, + build: mockBuild, +}; +const mockCreateEventBuilder = jest.fn().mockReturnValue(mockEventBuilder); +const mockTrackEvent = jest.fn(); + +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + jest.mock('../../hooks/usePoolStakedDeposit', () => ({ __esModule: true, default: () => ({ @@ -117,13 +145,49 @@ jest.mock('../../hooks/usePooledStakes', () => ({ })); describe('StakeConfirmationView', () => { - it('renders stake confirmation view', () => { - const { getByText } = renderWithProvider( + beforeEach(() => { + jest.clearAllMocks(); + }); + + const renderView = () => + renderWithProvider( , ); + it('renders stake confirmation view', () => { + const { getByText } = renderView(); + expect(getByText(strings('stake.staking_from'))).toBeOnTheScreen(); }); + + it('renders header with the stake title', () => { + const { getByText } = renderView(); + + expect(getByText(strings('stake.stake'))).toBeOnTheScreen(); + }); + + it('calls navigation.goBack on back press', () => { + const { getByTestId } = renderView(); + + fireEvent.press(getByTestId(STAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID)); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('tracks STAKE_CONFIRMATION_BACK_CLICKED on back press', () => { + const { getByTestId } = renderView(); + + fireEvent.press(getByTestId(STAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID)); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.STAKE_CONFIRMATION_BACK_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + selected_provider: EVENT_PROVIDERS.CONSENSYS, + location: EVENT_LOCATIONS.STAKE_CONFIRMATION_VIEW, + }); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx index a90d79d8c7e..23a11d62b27 100644 --- a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx +++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx @@ -1,8 +1,9 @@ -import React, { useEffect } from 'react'; +import React, { useCallback } from 'react'; import { View } from 'react-native'; import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; +import { Box, HeaderStandard } from '@metamask/design-system-react-native'; +import { ScrollView } from 'react-native-gesture-handler'; import { useStyles } from '../../../../hooks/useStyles'; -import { getStakingNavbar } from '../../../Navbar'; import styleSheet from './StakeConfirmationView.styles'; import TokenValueStack from '../../components/StakingConfirmation/TokenValueStack/TokenValueStack'; import AccountCard from '../../components/StakingConfirmation/AccountCard/AccountCard'; @@ -12,13 +13,16 @@ import { StakeConfirmationViewRouteParams } from './StakeConfirmationView.types' import { strings } from '../../../../../../locales/i18n'; import { FooterButtonGroupActions } from '../../components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.types'; import UnstakingTimeCard from '../../components/StakingConfirmation/UnstakeTimeCard/UnstakeTimeCard'; -import { ScrollView } from 'react-native-gesture-handler'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { EVENT_LOCATIONS, EVENT_PROVIDERS } from '../../constants/events'; import { getDecimalChainId } from '../../../../../util/networks'; const MOCK_STAKING_CONTRACT_NAME = 'MM Pooled Staking'; +export const STAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID = + 'stake-confirmation-header-back-button'; + const StakeConfirmationView = () => { const navigation = useNavigation(); const route = @@ -26,59 +30,59 @@ const StakeConfirmationView = () => { RouteProp<{ params: StakeConfirmationViewRouteParams }, 'params'> >(); - const { styles, theme } = useStyles(styleSheet, {}); + const { styles } = useStyles(styleSheet, {}); + const { trackEvent, createEventBuilder } = useAnalytics(); - useEffect(() => { - navigation.setOptions( - getStakingNavbar( - strings('stake.stake'), - navigation, - theme.colors, - { - backgroundColor: theme.colors.background.default, - hasCancelButton: false, - }, - { - backButtonEvent: { - event: MetaMetricsEvents.STAKE_CONFIRMATION_BACK_CLICKED, - properties: { - selected_provider: EVENT_PROVIDERS.CONSENSYS, - location: EVENT_LOCATIONS.STAKE_CONFIRMATION_VIEW, - }, - }, - }, - ), + const handleBackPress = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.STAKE_CONFIRMATION_BACK_CLICKED) + .addProperties({ + selected_provider: EVENT_PROVIDERS.CONSENSYS, + location: EVENT_LOCATIONS.STAKE_CONFIRMATION_VIEW, + }) + .build(), ); - }, [navigation, theme.colors]); + navigation.goBack(); + }, [navigation, trackEvent, createEventBuilder]); return ( - - - - - - + + + + - + + + + + - - - + + + ); }; diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx index b5bc3b5d87f..1b07cb3cff7 100644 --- a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx +++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx @@ -1,19 +1,23 @@ import { Hex } from '@metamask/utils'; import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { fireLayoutEvent } from '../../../../../util/testUtils/react-native-svg-charts'; +import { strings } from '../../../../../../locales/i18n'; import useEarningsHistory from '../../../Earn/hooks/useEarningsHistory'; -import { getStakingNavbar } from '../../../Navbar'; import { MOCK_STAKED_ETH_MAINNET_ASSET } from '../../__mocks__/stakeMockData'; -import StakeEarningsHistoryView from './StakeEarningsHistoryView'; +import StakeEarningsHistoryView, { + STAKE_EARNINGS_HISTORY_VIEW_BACK_BUTTON_TEST_ID, +} from './StakeEarningsHistoryView'; -jest.mock('../../../Navbar'); jest.mock('../../../Earn/hooks/useEarningsHistory'); +const mockGoBack = jest.fn(); const mockNavigation = { navigate: jest.fn(), setOptions: jest.fn(), + goBack: mockGoBack, }; jest.mock('@react-navigation/native', () => { @@ -29,7 +33,7 @@ jest.mock('@react-navigation/native', () => { }; }); jest.mock('react-native-svg-charts', () => { - const reactNativeSvgCharts = jest.requireActual('react-native-svg-charts'); // Get the actual Grid component + const reactNativeSvgCharts = jest.requireActual('react-native-svg-charts'); return { ...reactNativeSvgCharts, Grid: () => <>, @@ -88,20 +92,35 @@ const mockInitialState = { const earningsHistoryView = ; describe('StakeEarningsHistoryView', () => { - it('calls navigation setOptions on render', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the staking earnings history header title with the asset ticker', () => { + const expectedTitle = strings('stake.earnings_history_title', { + ticker: + MOCK_STAKED_ETH_MAINNET_ASSET.ticker || + MOCK_STAKED_ETH_MAINNET_ASSET.symbol, + }); + const renderedView = renderWithProvider(earningsHistoryView, { state: mockInitialState, }); fireLayoutEvent(renderedView.root); - expect(mockNavigation.setOptions).toHaveBeenCalled(); + + expect(renderedView.getByText(expectedTitle)).toBeOnTheScreen(); }); - it('calls navigation setOptions to get staking navigation bar', () => { + it('calls navigation.goBack when the header back button is pressed', () => { const renderedView = renderWithProvider(earningsHistoryView, { state: mockInitialState, }); fireLayoutEvent(renderedView.root); - expect(mockNavigation.setOptions).toHaveBeenCalled(); - expect(getStakingNavbar).toHaveBeenCalled(); + + fireEvent.press( + renderedView.getByTestId(STAKE_EARNINGS_HISTORY_VIEW_BACK_BUTTON_TEST_ID), + ); + + expect(mockGoBack).toHaveBeenCalledTimes(1); }); }); diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx index e7de4bfeab1..b421d81ad0e 100644 --- a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx +++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx @@ -1,43 +1,41 @@ import { useNavigation, useRoute } from '@react-navigation/native'; -import React, { useEffect } from 'react'; +import React from 'react'; import { View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; +import { Box, HeaderStandard } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import { useStyles } from '../../../../hooks/useStyles'; -import { getStakingNavbar } from '../../../Navbar'; import styleSheet from './StakeEarningsHistoryView.styles'; import { StakeEarningsHistoryViewRouteParams } from './StakeEarningsHistoryView.types'; import EarningsHistory from '../../../Earn/components/Earnings/EarningsHistory/EarningsHistory'; +export const STAKE_EARNINGS_HISTORY_VIEW_BACK_BUTTON_TEST_ID = + 'stake-earnings-history-header-back-button'; + const StakeEarningsHistoryView = () => { const navigation = useNavigation(); const route = useRoute(); - const { styles, theme } = useStyles(styleSheet, {}); + const { styles } = useStyles(styleSheet, {}); const { asset } = route.params; - useEffect(() => { - navigation.setOptions( - getStakingNavbar( - strings('stake.earnings_history_title', { - ticker: asset.ticker || asset.symbol, - }), - navigation, - theme.colors, - { - backgroundColor: theme.colors.background.default, - hasCancelButton: false, - hasBackButton: true, - }, - ), - ); - }, [navigation, theme.colors, asset.ticker, asset.symbol]); - return ( - - - - - + + + + + + + + ); }; diff --git a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.styles.ts b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.styles.ts index 6cb23362438..09661d88ce1 100644 --- a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.styles.ts +++ b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.styles.ts @@ -10,7 +10,7 @@ const styleSheet = (params: { theme: Theme }) => { paddingTop: 8, paddingHorizontal: 16, backgroundColor: colors.background.default, - height: '100%', + flex: 1, justifyContent: 'space-between', }, cardsContainer: { diff --git a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.test.tsx b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.test.tsx index f4515eb166c..7f0131dc3ad 100644 --- a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.test.tsx +++ b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.test.tsx @@ -1,5 +1,8 @@ import React from 'react'; -import UnstakeConfirmationView from './UnstakeConfirmationView'; +import { fireEvent } from '@testing-library/react-native'; +import UnstakeConfirmationView, { + UNSTAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID, +} from './UnstakeConfirmationView'; import renderWithProvider, { DeepPartial, } from '../../../../../util/test/renderWithProvider'; @@ -10,6 +13,8 @@ import { UnstakeConfirmationViewRouteParams } from './UnstakeConfirmationView.ty import { MOCK_POOL_STAKING_SDK } from '../../__mocks__/stakeMockData'; import { RootState } from '../../../../../reducers'; import { strings } from '../../../../../../locales/i18n'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { EVENT_LOCATIONS, EVENT_PROVIDERS } from '../../constants/events'; const MOCK_ADDRESS_1 = '0x0'; const MOCK_ADDRESS_2 = '0x1'; @@ -64,6 +69,7 @@ Image.getSize = jest.fn( ); const mockNavigate = jest.fn(); const mockSetOptions = jest.fn(); +const mockGoBack = jest.fn(); jest.mock('@react-navigation/native', () => { const actualReactNavigation = jest.requireActual('@react-navigation/native'); @@ -72,6 +78,7 @@ jest.mock('@react-navigation/native', () => { useNavigation: () => ({ navigate: mockNavigate, setOptions: mockSetOptions, + goBack: mockGoBack, }), useRoute: () => ({ key: '1', @@ -84,6 +91,24 @@ jest.mock('@react-navigation/native', () => { }; }); +const mockAddProperties = jest.fn().mockReturnThis(); +const mockBuild = jest.fn().mockReturnValue({ + name: 'UNSTAKE_CONFIRMATION_BACK_CLICKED', +}); +const mockEventBuilder = { + addProperties: mockAddProperties, + build: mockBuild, +}; +const mockCreateEventBuilder = jest.fn().mockReturnValue(mockEventBuilder); +const mockTrackEvent = jest.fn(); + +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + jest.mock('../../hooks/usePoolStakedDeposit', () => ({ __esModule: true, default: () => ({ @@ -104,14 +129,50 @@ jest.mock('../../hooks/useStakeContext', () => ({ })); describe('UnstakeConfirmationView', () => { - it('renders unstake confirmation view', () => { - const { getByText } = renderWithProvider(, { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const renderView = () => + renderWithProvider(, { state: mockInitialState, }); + it('renders unstake confirmation view', () => { + const { getByText } = renderView(); + expect(getByText(strings('stake.unstaking_to'))).toBeOnTheScreen(); expect(getByText(strings('stake.interacting_with'))).toBeOnTheScreen(); expect(getByText('Cancel')).toBeOnTheScreen(); expect(getByText('Continue')).toBeOnTheScreen(); }); + + it('renders header with the unstake title', () => { + const { getByText } = renderView(); + + expect(getByText(strings('stake.unstake'))).toBeOnTheScreen(); + }); + + it('calls navigation.goBack on back press', () => { + const { getByTestId } = renderView(); + + fireEvent.press(getByTestId(UNSTAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID)); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('tracks UNSTAKE_CONFIRMATION_BACK_CLICKED on back press', () => { + const { getByTestId } = renderView(); + + fireEvent.press(getByTestId(UNSTAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID)); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.UNSTAKE_CONFIRMATION_BACK_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + selected_provider: EVENT_PROVIDERS.CONSENSYS, + location: EVENT_LOCATIONS.UNSTAKE_CONFIRMATION_VIEW, + }); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.tsx b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.tsx index 73ce556b5c7..84c4fa57c58 100644 --- a/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.tsx +++ b/app/components/UI/Stake/Views/UnstakeConfirmationView/UnstakeConfirmationView.tsx @@ -1,9 +1,10 @@ +import React, { useCallback } from 'react'; import { View } from 'react-native'; -import React, { useEffect } from 'react'; import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; +import { Box, HeaderStandard } from '@metamask/design-system-react-native'; +import { useSelector } from 'react-redux'; import styleSheet from './UnstakeConfirmationView.styles'; import { useStyles } from '../../../../hooks/useStyles'; -import { getStakingNavbar } from '../../../Navbar'; import { strings } from '../../../../../../locales/i18n'; import UnstakingTimeCard from '../../components/StakingConfirmation/UnstakeTimeCard/UnstakeTimeCard'; import { UnstakeConfirmationViewRouteParams } from './UnstakeConfirmationView.types'; @@ -12,68 +13,71 @@ import AccountCard from '../../components/StakingConfirmation/AccountCard/Accoun import ConfirmationFooter from '../../components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter'; import { FooterButtonGroupActions } from '../../components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.types'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { EVENT_LOCATIONS, EVENT_PROVIDERS } from '../../constants/events'; import { getDecimalChainId } from '../../../../../util/networks'; -import { useSelector } from 'react-redux'; import { selectEvmChainId } from '../../../../../selectors/networkController'; const MOCK_STAKING_CONTRACT_NAME = 'MM Pooled Staking'; +export const UNSTAKE_CONFIRMATION_VIEW_BACK_BUTTON_TEST_ID = + 'unstake-confirmation-header-back-button'; + const UnstakeConfirmationView = () => { const route = useRoute< RouteProp<{ params: UnstakeConfirmationViewRouteParams }, 'params'> >(); - const { styles, theme } = useStyles(styleSheet, {}); + const { styles } = useStyles(styleSheet, {}); const chainId = useSelector(selectEvmChainId); const navigation = useNavigation(); + const { trackEvent, createEventBuilder } = useAnalytics(); - useEffect(() => { - navigation.setOptions( - getStakingNavbar( - strings('stake.unstake'), - navigation, - theme.colors, - { - backgroundColor: theme.colors.background.default, - hasCancelButton: false, - }, - { - backButtonEvent: { - event: MetaMetricsEvents.UNSTAKE_CONFIRMATION_BACK_CLICKED, - properties: { - selected_provider: EVENT_PROVIDERS.CONSENSYS, - location: EVENT_LOCATIONS.UNSTAKE_CONFIRMATION_VIEW, - }, - }, - }, - ), + const handleBackPress = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.UNSTAKE_CONFIRMATION_BACK_CLICKED) + .addProperties({ + selected_provider: EVENT_PROVIDERS.CONSENSYS, + location: EVENT_LOCATIONS.UNSTAKE_CONFIRMATION_VIEW, + }) + .build(), ); - }, [navigation, theme.colors]); + navigation.goBack(); + }, [navigation, trackEvent, createEventBuilder]); return ( - - - - - - + + + + + + + + + - - + ); }; diff --git a/app/components/UI/Stake/routes/index.tsx b/app/components/UI/Stake/routes/index.tsx index 51c460bda35..e08f6e25e32 100644 --- a/app/components/UI/Stake/routes/index.tsx +++ b/app/components/UI/Stake/routes/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { createStackNavigator } from '@react-navigation/stack'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; import Routes from '../../../../constants/navigation/Routes'; import { Confirm } from '../../../Views/confirmations/components/confirm'; import StakeConfirmationView from '../Views/StakeConfirmationView/StakeConfirmationView'; @@ -16,10 +16,13 @@ import EarnTokenList from '../../Earn/components/EarnTokenList'; import EarnInputView from '../../Earn/Views/EarnInputView/EarnInputView'; import EarnWithdrawInputView from '../../Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView'; import { useEmptyNavHeaderForConfirmations } from '../../../Views/confirmations/hooks/ui/useEmptyNavHeaderForConfirmations'; -import { clearStackNavigatorOptions } from '../../../../constants/navigation/clearStackNavigatorOptions'; +import { + clearNativeStackNavigatorOptions, + transparentModalScreenOptions, +} from '../../../../constants/navigation/clearStackNavigatorOptions'; -const Stack = createStackNavigator(); -const ModalStack = createStackNavigator(); +const Stack = createNativeStackNavigator(); +const ModalStack = createNativeStackNavigator(); // eslint-disable-next-line @typescript-eslint/no-explicit-any type ScreenComponent = React.ComponentType; @@ -30,8 +33,16 @@ const StakeScreenStack = () => { return ( - - + + { ( ({ setString: jest.fn(), })); +jest.mock('@metamask/design-system-react-native', () => { + const actualDesignSystem = jest.requireActual( + '@metamask/design-system-react-native', + ); + + return { + ...actualDesignSystem, + Toaster: jest.fn(() => null), + toast: Object.assign(jest.fn(), { + dismiss: jest.fn(), + }), + }; +}); + const mockEthEoaAccount = { ...createMockInternalAccount( '0x4fec2622fb662e892dd0e5060b91fa49ddcfdcb5', @@ -193,6 +210,27 @@ describe('AddressList', () => { }); }); + it('calls navigation.goBack from the header back button', () => { + renderWithAddressList(); + + const navOptionsWithHeader = mockSetOptions.mock.calls + .map(([opts]) => opts) + .find( + (opts) => + opts && + opts.headerShown === true && + typeof opts.header === 'function', + ); + + expect(navOptionsWithHeader).toBeDefined(); + + const { getByTestId, unmount } = render(navOptionsWithHeader.header()); + fireEvent.press(getByTestId(AddressListIds.GO_BACK)); + + expect(mockGoBack).toHaveBeenCalled(); + unmount(); + }); + it('does not set navigation options when title is not provided', () => { const { useParams } = jest.requireMock( '../../../../util/navigation/navUtils', @@ -307,5 +345,22 @@ describe('AddressList', () => { expect(addPropertiesCall).toHaveProperty('location', 'address-list'); }); + + it('shows the design system copy toast', async () => { + const { getAllByTestId } = renderWithAddressList(); + + const copyButton = getAllByTestId( + 'multichain-address-row-copy-button', + )[0]; + expect(copyButton).toBeDefined(); + fireEvent.press(copyButton); + + await waitFor(() => { + expect(toast).toHaveBeenCalledWith({ + description: strings('notifications.address_copied_to_clipboard'), + hasNoTimeout: false, + }); + }); + }); }); }); diff --git a/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx b/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx index 18b112e5b45..d23796de419 100644 --- a/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx +++ b/app/components/Views/MultichainAccounts/AddressList/AddressList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useLayoutEffect } from 'react'; +import React, { useCallback, useLayoutEffect } from 'react'; import { View } from 'react-native'; import { useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; @@ -7,7 +7,7 @@ import { FlashList } from '@shopify/flash-list'; import { useStyles } from '../../../hooks/useStyles'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import { selectInternalAccountListSpreadByScopesByGroupId } from '../../../../selectors/multichainAccounts/accounts'; -import { IconName } from '@metamask/design-system-react-native'; +import { IconName, Toaster, toast } from '@metamask/design-system-react-native'; import MultichainAddressRow, { MULTICHAIN_ADDRESS_ROW_QR_BUTTON_TEST_ID, } from '../../../../component-library/components-temp/MultichainAccounts/MultichainAddressRow'; @@ -22,7 +22,6 @@ import styleSheet from './styles'; import type { AddressListProps, AddressItem } from './types'; import ClipboardManager from '../../../../core/ClipboardManager'; import getHeaderCompactStandardNavbarOptions from '../../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions'; -import { ToastContext } from '../../../../component-library/components/Toast'; import { strings } from '../../../../../locales/i18n'; import { EVENT_NAME } from '../../../../core/Analytics/MetaMetrics.events'; @@ -39,7 +38,6 @@ export const createAddressListNavigationDetails = export const AddressList = () => { const navigation = useNavigation(); const { styles } = useStyles(styleSheet, {}); - const { toastRef } = useContext(ToastContext); const { trackEvent, createEventBuilder } = useAnalytics(); const { groupId, title, onLoad } = useParams(); @@ -70,9 +68,15 @@ export const AddressList = () => { networkName={item.networkName} address={item.account.address} copyParams={{ - toastMessage: strings('notifications.address_copied_to_clipboard'), - callback: copyAddressToClipboard, - toastRef, + callback: async () => { + await copyAddressToClipboard(); + toast({ + description: strings( + 'notifications.address_copied_to_clipboard', + ), + hasNoTimeout: false, + }); + }, }} icons={[ { @@ -98,7 +102,7 @@ export const AddressList = () => { /> ); }, - [navigation, groupId, toastRef, trackEvent, createEventBuilder], + [navigation, groupId, trackEvent, createEventBuilder], ); useLayoutEffect(() => { @@ -123,6 +127,7 @@ export const AddressList = () => { renderItem={renderAddressItem} onLoad={onLoad} /> + ); }; diff --git a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx index c33a8dab3c3..7e01c4bcfdc 100644 --- a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx +++ b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.test.tsx @@ -15,31 +15,28 @@ const mockDispatch = jest.fn(); // Mock the BottomSheet component const mockOnCloseBottomSheet = jest.fn(); // eslint-disable-next-line import-x/no-commonjs -jest.mock( - '../../../../component-library/components/BottomSheets/BottomSheet', - () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports, import-x/no-commonjs, @typescript-eslint/no-var-requires - const ReactMock = require('react'); - return { - __esModule: true, - default: ReactMock.forwardRef( - ( - { children }: { children: React.ReactNode }, - ref: React.Ref<{ onCloseBottomSheet: () => void }>, - ) => { - ReactMock.useImperativeHandle(ref, () => ({ - onCloseBottomSheet: mockOnCloseBottomSheet, - })); - return ReactMock.createElement( - 'View', - { testID: 'bottom-sheet' }, - children, - ); - }, - ), - }; - }, -); +jest.mock('@metamask/design-system-react-native', () => { + const actualDesignSystem = jest.requireActual( + '@metamask/design-system-react-native', + ); + // eslint-disable-next-line @typescript-eslint/no-require-imports, import-x/no-commonjs, @typescript-eslint/no-var-requires + const ReactMock = require('react'); + + return { + ...actualDesignSystem, + BottomSheet: ReactMock.forwardRef( + ( + { children, testID }: { children: React.ReactNode; testID?: string }, + ref: React.Ref<{ onCloseBottomSheet: () => void }>, + ) => { + ReactMock.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: mockOnCloseBottomSheet, + })); + return ReactMock.createElement('View', { testID }, children); + }, + ), + }; +}); // Mock React Navigation jest.mock('@react-navigation/native', () => { diff --git a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx index ae95fb07b9f..daad79080bb 100644 --- a/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx +++ b/app/components/Views/MultichainAccounts/IntroModal/LearnMoreBottomSheet.tsx @@ -10,10 +10,9 @@ import { Button, ButtonVariant, ButtonBaseSize, + BottomSheet, + type BottomSheetRef, } from '@metamask/design-system-react-native'; -import BottomSheet, { - BottomSheetRef, -} from '../../../../component-library/components/BottomSheets/BottomSheet'; import { useNavigation, useTheme } from '@react-navigation/native'; import { strings } from '../../../../../locales/i18n'; import { useStyles } from '../../../../component-library/hooks'; @@ -63,7 +62,11 @@ const LearnMoreBottomSheet: React.FC = ({ }, [isCheckboxChecked, navigation, isBasicFunctionalityEnabled, dispatch]); return ( - + ( - - - - - - - + + + + + + ), ], diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx index e713298b008..79df1f7cd17 100644 --- a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx +++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx @@ -2,7 +2,6 @@ import { useNavigation } from '@react-navigation/native'; import React, { useCallback, - useContext, useEffect, useMemo, useRef, @@ -15,10 +14,12 @@ import { NON_EVM_TESTNET_IDS } from '@metamask/multichain-network-controller'; // External dependencies. import { strings } from '../../../../../locales/i18n.js'; import { - ToastContext, - ToastVariants, -} from '../../../../component-library/components/Toast/index.ts'; -import { ToastOptions } from '../../../../component-library/components/Toast/Toast.types.ts'; + AvatarFavicon, + AvatarFaviconSize, + Box, + Toaster, + toast, +} from '@metamask/design-system-react-native'; import { USER_INTENT } from '../../../../constants/permissions.ts'; import { MetaMetricsEvents } from '../../../../core/Analytics/index.ts'; import Engine from '../../../../core/Engine/index.ts'; @@ -49,12 +50,13 @@ import useFavicon from '../../../hooks/useFavicon/useFavicon.ts'; import { AccountConnectProps, AccountConnectScreens, + NetworkAvatarProps, } from '../../AccountConnect/AccountConnect.types.ts'; -import { getNetworkImageSource } from '../../../../util/networks/index.js'; import { AvatarSize, AvatarVariant, -} from '../../../../component-library/components/Avatars/Avatar/index.ts'; +} from '../../../../component-library/components/Avatars/Avatar'; +import { getNetworkImageSource } from '../../../../util/networks/index.js'; import { EvmAndMultichainNetworkConfigurationsWithCaipChainId, getSelectedMultichainNetwork, @@ -97,7 +99,6 @@ import { getPermissions } from '../../../../selectors/snaps/index.ts'; import { useSDKV2Connection } from '../../../hooks/useSDKV2Connection'; import { useAccountGroupsForPermissions } from '../../../hooks/useAccountGroupsForPermissions/useAccountGroupsForPermissions.ts'; import NetworkConnectMultiSelector from '../../NetworkConnect/NetworkConnectMultiSelector/index.ts'; -import { Box } from '@metamask/design-system-react-native'; import { TESTNET_CAIP_IDS } from '../../../../constants/network.js'; import { getCaip25AccountIdsFromAccountGroupAndScope } from '../../../../util/multichain/getCaip25AccountIdsFromAccountGroupAndScope.ts'; import { isSnapId } from '@metamask/snaps-utils'; @@ -124,6 +125,9 @@ const ScreenContainer: React.FC = ({ ); +const NETWORK_AVATAR_SIZE = AvatarSize.Xs; +const NETWORK_AVATAR_VARIANT = AvatarVariant.Network; + const MultichainAccountConnect = (props: AccountConnectProps) => { const { colors } = useTheme(); const { styles } = useStyles(styleSheet, {}); @@ -397,13 +401,15 @@ const MultichainAccountConnect = (props: AccountConnectProps) => { .filter( (selectedChainId) => !NON_EVM_TESTNET_IDS.includes(selectedChainId), ) - .map((selectedChainId) => ({ - size: AvatarSize.Xs, - name: networkConfigurations[selectedChainId]?.name || '', - imageSource: getNetworkImageSource({ chainId: selectedChainId }), - variant: AvatarVariant.Network, - caipChainId: selectedChainId, - })), + .map( + (selectedChainId): NetworkAvatarProps => ({ + size: NETWORK_AVATAR_SIZE, + name: networkConfigurations[selectedChainId]?.name || '', + imageSource: getNetworkImageSource({ chainId: selectedChainId }), + variant: NETWORK_AVATAR_VARIANT, + caipChainId: selectedChainId, + }), + ), [networkConfigurations, selectedChainIds], ); @@ -471,8 +477,6 @@ const MultichainAccountConnect = (props: AccountConnectProps) => { const [userIntent, setUserIntent] = useState(USER_INTENT.None); const isMountedRef = useRef(true); - const { toastRef } = useContext(ToastContext); - const accountsLength = useSelector(selectAccountsLength); const dappUrl = @@ -714,15 +718,14 @@ const MultichainAccountConnect = (props: AccountConnectProps) => { .build(), ); - const labelOptions: ToastOptions['labelOptions'] = - connectedAccountLength >= 1 - ? [{ label: strings('toast.permissions_updated') }] - : []; - - toastRef?.current?.showToast({ - variant: ToastVariants.App, - labelOptions, - appIconSource: faviconSource, + toast({ + description: + connectedAccountLength >= 1 + ? strings('toast.permissions_updated') + : undefined, + startAccessory: ( + + ), hasNoTimeout: false, }); } catch (e) { @@ -745,7 +748,6 @@ const MultichainAccountConnect = (props: AccountConnectProps) => { createEventBuilder, accountsLength, originSource, - toastRef, faviconSource, referrer, ]); @@ -996,6 +998,7 @@ const MultichainAccountConnect = (props: AccountConnectProps) => { )} {renderPhishingModal()} + ); }; diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx index 7101315a946..ec2878dfca5 100644 --- a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx +++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnectMultiSelector/MultichainAccountConnectMultiSelector.tsx @@ -9,11 +9,10 @@ import { Button, ButtonVariant, ButtonBaseSize, + Text, + TextColor, } from '@metamask/design-system-react-native'; import SheetHeader from '../../../../../component-library/components/Sheet/SheetHeader'; -import Text, { - TextColor, -} from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../component-library/hooks'; import HelpText, { HelpTextSeverity, @@ -179,7 +178,7 @@ const MultichainAccountConnectMultiSelector = ({ {connection?.originatorInfo?.apiVersion && ( - + {strings('permissions.sdk_connection', { originator_platform: connection?.originatorInfo?.platform, api_version: connection?.originatorInfo?.apiVersion, diff --git a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.styles.ts b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.styles.ts index 141a8387609..db3bb30b317 100644 --- a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.styles.ts +++ b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.styles.ts @@ -92,7 +92,7 @@ const createStyles = (params: { theme: Theme }) => { }, permissionRequestNetworkName: { marginRight: 4, - maxWidth: '75%', + maxWidth: '60%', }, avatarGroup: { marginLeft: 2, diff --git a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.test.tsx b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.test.tsx index 39237f7db7a..ce192ca2fd1 100644 --- a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.test.tsx +++ b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.test.tsx @@ -8,16 +8,144 @@ import renderWithProvider, { DeepPartial, } from '../../../../util/test/renderWithProvider'; import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../util/test/accountsControllerTestUtils'; -import { - AvatarSize, - AvatarVariant, -} from '../../../../component-library/components/Avatars/Avatar/Avatar.types'; +import { NetworkAvatarProps } from '../../AccountConnect/AccountConnect.types'; import { RootState } from '../../../../reducers'; import { CommonSelectorsIDs } from '../../../../util/Common.testIds'; import { ConnectedAccountsSelectorsIDs } from '../../AccountConnect/ConnectedAccountModal.testIds'; import { PermissionSummaryBottomSheetSelectorsIDs } from '../../AccountConnect/PermissionSummaryBottomSheet.testIds'; import { NetworkNonPemittedBottomSheetSelectorsIDs } from '../../NetworkConnect/NetworkNonPemittedBottomSheet.testIds'; +jest.mock('@metamask/design-system-twrnc-preset', () => { + const tw = (..._args: unknown[]) => ({}); + tw.style = jest.fn(() => ({})); + return { useTailwind: () => tw }; +}); + +jest.mock('../../../../component-library/components/Avatars/Avatar', () => { + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => , + AvatarSize: { Xs: '16', Sm: '24', Md: '32', Lg: '40', Xl: '48' }, + AvatarVariant: { + Account: 'Account', + Favicon: 'Favicon', + Icon: 'Icon', + Network: 'Network', + Token: 'Token', + }, + }; +}); + +jest.mock( + '../../../../component-library/components/Avatars/AvatarGroup', + () => { + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => , + }; + }, +); + +jest.mock('../../../../component-library/components/Texts/Text', () => { + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: Text, + TextColor: { + Default: 'Default', + Error: 'Error', + Alternative: 'Alternative', + }, + TextVariant: { + BodyMD: 'sBodyMD', + BodyMDMedium: 'sBodyMDMedium', + BodySM: 'sBodySM', + BodySMMedium: 'sBodySMMedium', + HeadingMD: 'sHeadingMD', + }, + }; +}); + +jest.mock('../../../../component-library/components/Icons/Icon', () => { + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ name }: { name: string }) => ( + + ), + IconColor: { Alternative: 'Alternative', Default: 'Default' }, + IconName: { + ArrowLeft: 'ArrowLeft', + ArrowRight: 'ArrowRight', + Data: 'Data', + Info: 'Info', + Wallet: 'Wallet', + }, + IconSize: { Sm: 'Sm', Md: 'Md' }, + }; +}); + +jest.mock('../../../../component-library/components/Buttons/ButtonIcon', () => { + const { Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + onPress, + testID, + }: { + onPress?: () => void; + testID?: string; + }) => , + ButtonIconSizes: { Sm: 'Sm' }, + }; +}); + +jest.mock( + '../../../../component-library/components/Badges/BadgeWrapper', + () => { + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }; + }, +); + +jest.mock('../../../../component-library/components/Badges/Badge', () => { + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => , + BadgeVariant: { Network: 'Network' }, + }; +}); + +jest.mock( + '../../../../component-library/components/Avatars/Avatar/variants/AvatarFavicon', + () => { + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => , + }; + }, +); + +jest.mock( + '../../../../component-library/components/Avatars/Avatar/variants/AvatarToken', + () => { + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => , + }; + }, +); + const mockOnEdit = jest.fn(); const mockOnEditNetworks = jest.fn(); const mockOnBack = jest.fn(); @@ -41,19 +169,24 @@ const MOCK_CURRENT_PAGE_INFORMATION = { url: 'https://mock-dapp.example.com/', }; -const MOCK_NETWORK_AVATARS = [ +/** Legacy pixel sizes used by NetworkAvatarProps until AccountConnect.types migrates. */ +const LEGACY_NETWORK_AVATAR_SIZE = '16' as NetworkAvatarProps['size']; +const LEGACY_NETWORK_AVATAR_VARIANT = + 'Network' as NetworkAvatarProps['variant']; + +const MOCK_NETWORK_AVATARS: NetworkAvatarProps[] = [ { name: 'Ethereum Mainnet', imageSource: { uri: 'test-network-avatar.png' }, - size: AvatarSize.Xs, - variant: AvatarVariant.Network, + size: LEGACY_NETWORK_AVATAR_SIZE, + variant: LEGACY_NETWORK_AVATAR_VARIANT, caipChainId: 'eip155:1' as CaipChainId, }, { name: 'Polygon', imageSource: { uri: 'test-polygon-avatar.png' }, - size: AvatarSize.Xs, - variant: AvatarVariant.Network, + size: LEGACY_NETWORK_AVATAR_SIZE, + variant: LEGACY_NETWORK_AVATAR_VARIANT, caipChainId: 'eip155:137' as CaipChainId, }, ]; diff --git a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx index e8115bee65c..fb11a34204d 100644 --- a/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx +++ b/app/components/Views/MultichainAccounts/MultichainPermissionsSummary/MultichainPermissionsSummary.tsx @@ -16,12 +16,11 @@ import Icon, { IconName, IconSize, } from '../../../../component-library/components/Icons/Icon'; -import TextComponent, { - TextColor, - TextVariant, -} from '../../../../component-library/components/Texts/Text'; import AvatarGroup from '../../../../component-library/components/Avatars/AvatarGroup'; import { + Text, + TextColor, + TextVariant, Button, ButtonVariant, ButtonBaseSize, @@ -68,6 +67,8 @@ import { AccountGroupObject } from '@metamask/account-tree-controller'; import { selectIconSeedAddressesByAccountGroupIds } from '../../../../selectors/multichainAccounts/accounts'; import { RootState } from '../../../../reducers'; +const TAB_BAR_HORIZONTAL_PADDING = 0; + export interface MultichainPermissionsSummaryProps { currentPageInformation: { currentEnsName: string; @@ -276,12 +277,9 @@ const MultichainPermissionsSummary = ({ ) : ( - + {strings('permissions.edit')} - + )} @@ -373,22 +371,22 @@ const MultichainPermissionsSummary = ({ iconColor={colors.icon.alternative} /> - + {strings('permissions.see_your_accounts')} - + - - + {strings('permissions.requesting_for')} - - + + - + {strings('permissions.use_enabled_networks')} - + {(isNetworkSwitch || isNonDappNetworkSwitch) && ( <> - - + + {strings('permissions.requesting_for')} - - + + {isNonDappNetworkSwitch ? networkName || providerConfig.nickname : chainName} - - + + - - + + {getNetworkLabel()} - - + + ) => ( - + ), [colors], ); @@ -577,12 +579,12 @@ const MultichainPermissionsSummary = ({ PermissionSummaryBottomSheetSelectorsIDs.NETWORK_PERMISSIONS_CONTAINER } > - @@ -593,19 +595,16 @@ const MultichainPermissionsSummary = ({ : strings('permissions.title_dapp_url_has_approval_to', { dappUrl: hostname, })} - + {isMaliciousDapp && !isAlreadyConnected && } - + {strings('account_dapp_connections.account_summary_header')} - + {isNonDappNetworkSwitch && ( - + {strings('permissions.non_permitted_network_description')} - + )} {!nonTabView ? ( {renderTabsContent()} diff --git a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.test.tsx b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.test.tsx index 33d693e2de4..dd2ebb2083c 100644 --- a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.test.tsx +++ b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, waitFor } from '@testing-library/react-native'; import { Platform } from 'react-native'; import { AccountGroupId, AccountWalletId } from '@metamask/account-api'; import { SolAccountType, EthScope, SolScope } from '@metamask/keyring-api'; +import { toast } from '@metamask/design-system-react-native'; import { createMockInternalAccount } from '../../../../util/test/accountsControllerTestUtils'; import { renderScreen } from '../../../../util/test/renderWithProvider'; @@ -63,6 +64,24 @@ jest.mock('../../../../core/Engine', () => ({ }, })); +jest.mock('../../../../core/ClipboardManager', () => ({ + setStringExpire: jest.fn(), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actualDesignSystem = jest.requireActual( + '@metamask/design-system-react-native', + ); + + return { + ...actualDesignSystem, + Toaster: jest.fn(() => null), + toast: Object.assign(jest.fn(), { + dismiss: jest.fn(), + }), + }; +}); + const mockEthEoaAccount = { ...createMockInternalAccount( '0x4fec2622fb662e892dd0e5060b91fa49ddcfdcb5', @@ -239,6 +258,41 @@ describe('PrivateKeyList', () => { unmount(); }); + it('copies a private key and shows the design system toast', async () => { + const { getByTestId, findByTestId, getAllByTestId } = + renderWithPrivateKeyList(); + const mockClipboardManager = jest.requireMock( + '../../../../core/ClipboardManager', + ) as { setStringExpire: jest.Mock }; + + fireEvent.changeText( + getByTestId(PrivateKeyListIds.PASSWORD_INPUT), + 'correct-password', + ); + fireEvent.press(getByTestId(PrivateKeyListIds.CONTINUE_BUTTON)); + + await findByTestId(PrivateKeyListIds.LIST); + + const copyButton = getAllByTestId( + PrivateKeyListIds.COPY_TO_CLIPBOARD_BUTTON, + )[0]; + expect(copyButton).toBeDefined(); + fireEvent.press(copyButton); + + await waitFor(() => { + expect(mockClipboardManager.setStringExpire).toHaveBeenCalledWith( + `mock-private-key-for-${mockEthEoaAccount.address}`, + ); + }); + + await waitFor(() => { + expect(toast).toHaveBeenCalledWith({ + description: strings('multichain_accounts.private_key_list.copied'), + hasNoTimeout: false, + }); + }); + }); + it('clears wrong-password error and shows list when correct password is entered after wrong', async () => { const { getByTestId, findByTestId, queryByTestId } = renderWithPrivateKeyList(); diff --git a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx index 0895a47575c..2ca54c0ae56 100644 --- a/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx +++ b/app/components/Views/MultichainAccounts/PrivateKeyList/PrivateKeyList.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useCallback, useMemo, - useContext, useLayoutEffect, } from 'react'; import { TextInput, Linking } from 'react-native'; @@ -26,12 +25,13 @@ import { Button, ButtonVariant, ButtonSize, + Toaster, + toast, } from '@metamask/design-system-react-native'; import Engine from '../../../../core/Engine'; import ClipboardManager from '../../../../core/ClipboardManager'; import MultichainAddressRow from '../../../../component-library/components-temp/MultichainAccounts/MultichainAddressRow'; import getHeaderCompactStandardNavbarOptions from '../../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions'; -import { ToastContext } from '../../../../component-library/components/Toast'; import { strings } from '../../../../../locales/i18n'; import { useParams, @@ -75,7 +75,6 @@ export const PrivateKeyList = () => { const theme = useTheme(); const { bottom: bottomInset } = useSafeAreaInsets(); - const { toastRef } = useContext(ToastContext); const [password, setPassword] = useState(''); const [wrongPassword, setWrongPassword] = useState(false); const [reveal, setReveal] = useState(false); @@ -184,17 +183,21 @@ export const PrivateKeyList = () => { networkName={item.networkName} address={item.account.address} copyParams={{ - toastMessage: strings('multichain_accounts.private_key_list.copied'), - toastRef, callback: async () => { await ClipboardManager.setStringExpire( privateKeys[item.account.id], ); + toast({ + description: strings( + 'multichain_accounts.private_key_list.copied', + ), + hasNoTimeout: false, + }); }, }} /> ), - [privateKeys, toastRef], + [privateKeys], ); const privateKeyBannerDescription = useMemo( @@ -323,6 +326,7 @@ export const PrivateKeyList = () => { /> {reveal ? renderPrivateKeyList() : renderPassword()} + ); }; diff --git a/app/components/Views/Settings/NotificationsSettings/FeatureAnnouncementToggle.test.tsx b/app/components/Views/Settings/NotificationsSettings/FeatureAnnouncementToggle.test.tsx deleted file mode 100644 index 91e7581cc41..00000000000 --- a/app/components/Views/Settings/NotificationsSettings/FeatureAnnouncementToggle.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; -import { - render, - fireEvent, - waitFor, - screen, -} from '@testing-library/react-native'; -import { FeatureAnnouncementToggle } from './FeatureAnnouncementToggle'; -// eslint-disable-next-line import-x/no-namespace -import * as UseSwitchNotificationsModule from '../../../../util/notifications/hooks/useSwitchNotifications'; -import { NotificationSettingsViewSelectorsIDs } from './NotificationSettingsView.testIds'; - -const arrangeMockMetrics = () => { - const mockTrackEvent = jest.fn(); - const mockAddProperties = jest.fn(); - const mockCreateEventBuilder = jest.fn().mockReturnValue({ - addProperties: mockAddProperties.mockReturnThis(), - build: jest.fn().mockReturnThis(), - }); - - return { - mockTrackEvent, - mockAddProperties, - mockCreateEventBuilder, - }; -}; - -describe('FeatureAnnouncementToggle', () => { - const arrangeMocks = () => { - const mockSwitchFeatureAnnouncements = jest.fn(); - const mockUseFeatureAnnouncementToggle = jest - .spyOn(UseSwitchNotificationsModule, 'useFeatureAnnouncementToggle') - .mockReturnValue({ - data: true, - switchFeatureAnnouncements: mockSwitchFeatureAnnouncements, - }); - - return { - mockSwitchFeatureAnnouncements, - mockUseFeatureAnnouncementToggle, - ...arrangeMockMetrics(), - }; - }; - - it('renders correctly', () => { - arrangeMocks(); - render(); - expect( - screen.getByTestId( - NotificationSettingsViewSelectorsIDs.FEATURE_ANNOUNCEMENTS_TOGGLE, - ), - ).toBeTruthy(); - }); - - it('toggles feature announcements', async () => { - const mocks = arrangeMocks(); - render(); - const toggleSwitch = screen.getByTestId( - NotificationSettingsViewSelectorsIDs.FEATURE_ANNOUNCEMENTS_TOGGLE, - ); - - fireEvent(toggleSwitch, 'onChange', { nativeEvent: { value: false } }); - - await waitFor(() => { - // Assert new switch call - expect(mocks.mockSwitchFeatureAnnouncements).toHaveBeenCalledWith(false); - }); - }); -}); diff --git a/app/components/Views/Settings/NotificationsSettings/FeatureAnnouncementToggle.tsx b/app/components/Views/Settings/NotificationsSettings/FeatureAnnouncementToggle.tsx deleted file mode 100644 index 78a2848e4e4..00000000000 --- a/app/components/Views/Settings/NotificationsSettings/FeatureAnnouncementToggle.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React, { useCallback } from 'react'; -import { useFeatureAnnouncementToggle } from '../../../../util/notifications/hooks/useSwitchNotifications'; -import CustomNotificationsRow from './CustomNotificationsRow'; -import { strings } from '../../../../../locales/i18n'; -import { NotificationSettingsViewSelectorsIDs } from './NotificationSettingsView.testIds'; - -export function FeatureAnnouncementToggle() { - const { data: isEnabled, switchFeatureAnnouncements } = - useFeatureAnnouncementToggle(); - - const toggleCustomNotificationsEnabled = useCallback(async () => { - await switchFeatureAnnouncements(!isEnabled); - }, [isEnabled, switchFeatureAnnouncements]); - - return ( - - ); -} diff --git a/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx b/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx index ca3bee4be55..6caacfa5d57 100644 --- a/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx +++ b/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { render } from '@testing-library/react-native'; -import { useSelector } from 'react-redux'; import useCurrencyRatePolling from './useCurrencyRatePolling'; import useTokenRatesPolling from './useTokenRatesPolling'; import useTokenDetectionPolling from './useTokenDetectionPolling'; @@ -8,7 +7,6 @@ import useTokenBalancesPolling from './useTokenBalancesPolling'; import { AssetPollingProvider } from './AssetPollingProvider'; import useMultichainAssetsRatePolling from './useMultichainAssetsRatePolling'; -import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState'; import { selectSelectedInternalAccount } from '../../../selectors/accountsController'; jest.mock('react-redux', () => ({ @@ -17,10 +15,6 @@ jest.mock('react-redux', () => ({ ), })); -jest.mock('../../../selectors/featureFlagController/assetsUnifyState', () => ({ - selectIsAssetsUnifyStateEnabled: jest.fn(), -})); - jest.mock('./useCurrencyRatePolling', () => jest.fn()); jest.mock('./useTokenRatesPolling', () => jest.fn()); jest.mock('./useTokenDetectionPolling', () => jest.fn()); @@ -44,9 +38,6 @@ describe('AssetPollingProvider', () => { beforeEach(() => { jest.clearAllMocks(); - (selectIsAssetsUnifyStateEnabled as unknown as jest.Mock).mockReturnValue( - true, - ); (selectSelectedInternalAccount as unknown as jest.Mock).mockReturnValue({ id: 'mock-account-id', address: '0x123', @@ -54,21 +45,7 @@ describe('AssetPollingProvider', () => { }); }); - it('does not mount polling hooks when unified assets state is disabled', () => { - (selectIsAssetsUnifyStateEnabled as unknown as jest.Mock).mockReturnValue( - false, - ); - - render(); - - expect(mockUseCurrencyRatePolling).not.toHaveBeenCalled(); - expect(mockUseTokenRatesPolling).not.toHaveBeenCalled(); - expect(mockUseTokenDetectionPolling).not.toHaveBeenCalled(); - expect(mockUseTokenBalancesPolling).not.toHaveBeenCalled(); - expect(mockUseMultichainAssetsRatePolling).not.toHaveBeenCalled(); - }); - - it('calls all polling hooks when unified assets state is enabled', () => { + it('calls all polling hooks on render', () => { render(); expect(mockUseCurrencyRatePolling).toHaveBeenCalledWith(undefined); diff --git a/app/components/hooks/AssetPolling/AssetPollingProvider.tsx b/app/components/hooks/AssetPolling/AssetPollingProvider.tsx index 2253914462a..25ef9e6e01e 100644 --- a/app/components/hooks/AssetPolling/AssetPollingProvider.tsx +++ b/app/components/hooks/AssetPolling/AssetPollingProvider.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, memo } from 'react'; +import { useMemo, memo } from 'react'; import { Hex } from '@metamask/utils'; import { useSelector } from 'react-redux'; import useCurrencyRatePolling from './useCurrencyRatePolling'; @@ -7,18 +7,17 @@ import useTokenDetectionPolling from './useTokenDetectionPolling'; import useTokenBalancesPolling from './useTokenBalancesPolling'; import useMultichainAssetsRatePolling from './useMultichainAssetsRatePolling'; import { selectSelectedInternalAccount } from '../../../selectors/accountsController'; -import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState'; export interface AssetPollingProviderProps { chainIds?: Hex[]; address?: Hex; } -/** - * Controller polling hooks for the asset stack. Only mounted when unified - * assets state is enabled so hooks are not called when that path is inactive. - */ -const AssetPollingEnabledContent = memo((props: AssetPollingProviderProps) => { +// This provider is a step towards making controller polling fully UI based. +// Eventually, individual UI components will call the use*Polling hooks to +// poll and return particular data. This polls globally in the meantime. +// Each hook no-ops (empty polling input) when unified assets state is enabled. +export const AssetPollingProvider = memo((props: AssetPollingProviderProps) => { const { chainIds, address } = props; const chainParams = useMemo( @@ -45,21 +44,4 @@ const AssetPollingEnabledContent = memo((props: AssetPollingProviderProps) => { return null; }); -AssetPollingEnabledContent.displayName = 'AssetPollingEnabledContent'; - -// This provider is a step towards making controller polling fully UI based. -// Eventually, individual UI components will call the use*Polling hooks to -// poll and return particular data. This polls globally in the meantime. -export const AssetPollingProvider = memo((props: AssetPollingProviderProps) => { - const isAssetsUnifyStateEnabled = useSelector( - selectIsAssetsUnifyStateEnabled, - ); - - if (!isAssetsUnifyStateEnabled) { - return null; - } - - return ; -}); - AssetPollingProvider.displayName = 'AssetPollingProvider'; diff --git a/app/components/hooks/AssetPolling/useCurrencyRatePolling.test.ts b/app/components/hooks/AssetPolling/useCurrencyRatePolling.test.ts index d2a2bc6df4c..55c470c79d7 100644 --- a/app/components/hooks/AssetPolling/useCurrencyRatePolling.test.ts +++ b/app/components/hooks/AssetPolling/useCurrencyRatePolling.test.ts @@ -13,9 +13,16 @@ jest.mock('../../../core/Engine', () => ({ }, })); +jest.mock('../../../selectors/featureFlagController/assetsUnifyState', () => ({ + selectIsAssetsUnifyStateEnabled: jest.fn(), +})); + +import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState'; + describe('useCurrencyRatePolling', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(selectIsAssetsUnifyStateEnabled).mockReturnValue(false); }); it('Should poll by the native currencies in network state', async () => { @@ -78,6 +85,55 @@ describe('useCurrencyRatePolling', () => { ).toHaveBeenCalledWith({ nativeCurrencies: ['ETH', 'POL'] }); }); + it('does not start polling when unified assets state is enabled', () => { + jest.mocked(selectIsAssetsUnifyStateEnabled).mockReturnValue(true); + + const state = { + engine: { + backgroundState: { + MultichainNetworkController: { + isEvmSelected: true, + selectedMultichainNetworkChainId: SolScope.Mainnet, + multichainNetworkConfigurationsByChainId: {}, + }, + NetworkController: { + selectedNetworkClientId: 'selectedNetworkClientId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], + defaultRpcEndpointIndex: 0, + }, + }, + }, + PreferencesController: { + tokenNetworkFilter: { + '0x1': true, + }, + }, + NetworkEnablementController: { + enabledNetworkMap: { + eip155: { + '0x1': true, + }, + }, + }, + }, + }, + } as unknown as RootState; + + renderHookWithProvider(() => useCurrencyRatePolling(), { state }); + + expect( + jest.mocked(Engine.context.CurrencyRateController.startPolling), + ).not.toHaveBeenCalled(); + }); + it('should poll only for current network if selected one is not popular', async () => { const state = { engine: { diff --git a/app/components/hooks/AssetPolling/useCurrencyRatePolling.ts b/app/components/hooks/AssetPolling/useCurrencyRatePolling.ts index bca6f99a5c5..1669677e9a9 100644 --- a/app/components/hooks/AssetPolling/useCurrencyRatePolling.ts +++ b/app/components/hooks/AssetPolling/useCurrencyRatePolling.ts @@ -2,11 +2,16 @@ import { useSelector } from 'react-redux'; import { Hex } from '@metamask/utils'; import usePolling from '../usePolling'; import { selectEvmNetworkConfigurationsByChainId } from '../../../selectors/networkController'; +import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState'; import Engine from '../../../core/Engine'; import { usePollingNetworks } from './use-polling-networks'; // Polls native currency prices across networks. const useCurrencyRatePolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { + const isAssetsUnifyStateEnabled = useSelector( + selectIsAssetsUnifyStateEnabled, + ); + // Selectors to determine polling input const networkConfigurations = useSelector( selectEvmNetworkConfigurationsByChainId, @@ -43,7 +48,8 @@ const useCurrencyRatePolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { const { CurrencyRateController } = Engine.context; - const input = overridePollingInput ?? pollingInput; + const resolvedInput = overridePollingInput ?? pollingInput; + const input = isAssetsUnifyStateEnabled ? [] : resolvedInput; usePolling({ startPolling: CurrencyRateController.startPolling.bind( diff --git a/app/components/hooks/AssetPolling/useMultichainAssetsRatePolling.ts b/app/components/hooks/AssetPolling/useMultichainAssetsRatePolling.ts index 2db1e9c9529..0b1f14e5fe1 100644 --- a/app/components/hooks/AssetPolling/useMultichainAssetsRatePolling.ts +++ b/app/components/hooks/AssetPolling/useMultichainAssetsRatePolling.ts @@ -1,13 +1,21 @@ +import { useSelector } from 'react-redux'; import usePolling from '../usePolling'; import Engine from '../../../core/Engine'; +import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState'; const useMultichainAssetsRatePolling = ({ accountId, }: { accountId: string; }) => { + const isAssetsUnifyStateEnabled = useSelector( + selectIsAssetsUnifyStateEnabled, + ); + const { MultichainAssetsRatesController } = Engine.context; + const input = isAssetsUnifyStateEnabled ? [] : [{ accountId }]; + usePolling({ startPolling: MultichainAssetsRatesController.startPolling.bind( MultichainAssetsRatesController, @@ -16,7 +24,7 @@ const useMultichainAssetsRatePolling = ({ MultichainAssetsRatesController.stopPollingByPollingToken.bind( MultichainAssetsRatesController, ), - input: [{ accountId }], + input, }); }; diff --git a/app/components/hooks/AssetPolling/useMultichanAssetsRatePolling.test.ts b/app/components/hooks/AssetPolling/useMultichanAssetsRatePolling.test.ts index 6111a01d2f6..c9320f2ad89 100644 --- a/app/components/hooks/AssetPolling/useMultichanAssetsRatePolling.test.ts +++ b/app/components/hooks/AssetPolling/useMultichanAssetsRatePolling.test.ts @@ -1,7 +1,12 @@ import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; import useMultichainAssetsRatePolling from './useMultichainAssetsRatePolling'; import Engine from '../../../core/Engine'; +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + // Mock Engine with MultichainAssetsRatesController jest.mock('../../../core/Engine', () => ({ context: { @@ -20,6 +25,8 @@ describe('useMultichainAssetsRatePolling', () => { // Reset all mocks before each test jest.resetAllMocks(); + jest.mocked(useSelector).mockReturnValue(false); + // Setup mock implementations mockStartPolling.mockImplementation(() => 'mock-polling-token'); mockStopPollingByPollingToken.mockImplementation(() => undefined); @@ -44,6 +51,15 @@ describe('useMultichainAssetsRatePolling', () => { expect(mockStopPollingByPollingToken).not.toHaveBeenCalled(); }); + it('does not start polling when unified assets state is enabled', () => { + jest.mocked(useSelector).mockReturnValue(true); + const accountId = 'test-account-id-123'; + + renderHook(() => useMultichainAssetsRatePolling({ accountId })); + + expect(mockStartPolling).not.toHaveBeenCalled(); + }); + it('stops polling on unmount', () => { // Arrange const accountId = 'test-account-id-123'; diff --git a/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts b/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts index 989ef744279..9621faca3f5 100644 --- a/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts +++ b/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts @@ -6,6 +6,7 @@ import { usePollingNetworks } from './use-polling-networks'; import { NetworkConfiguration } from '@metamask/network-controller'; import initialRootState from '../../../util/test/initial-root-state'; import { selectSelectedAccountGroupId } from '../../../selectors/multichainAccounts/accountTreeController'; +import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -51,6 +52,9 @@ const arrangeMocks = () => { if (selector === selectSelectedAccountGroupId) { return selector({}); } + if (selector === selectIsAssetsUnifyStateEnabled) { + return false; + } return selector(initialRootState); }); @@ -128,6 +132,29 @@ describe('useTokenBalancesPolling', () => { jest.resetAllMocks(); }); + describe('unified assets state gating', () => { + it('does not start polling when unified assets state is enabled', () => { + withNoPollingAssertions({ + overrideMocks: () => { + jest.mocked(useSelector).mockImplementation((selector) => { + if (selector === selectIsAssetsUnifyStateEnabled) { + return true; + } + if (selector === selectSelectedAccountGroupId) { + return selector({}); + } + return selector(initialRootState); + }); + }, + testFn: ({ mocks }) => { + expect( + mocks.mockTokenBalancesController.startPolling, + ).not.toHaveBeenCalled(); + }, + }); + }); + }); + describe('Basic polling behavior', () => { it.each([ { diff --git a/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts b/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts index 2aadc527ef3..5d033024e6c 100644 --- a/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts +++ b/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts @@ -4,8 +4,13 @@ import { Hex } from '@metamask/utils'; import { usePollingNetworks } from './use-polling-networks'; import { useSelector } from 'react-redux'; import { selectSelectedAccountGroupId } from '../../../selectors/multichainAccounts/accountTreeController'; +import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState'; const useTokenBalancesPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { + const isAssetsUnifyStateEnabled = useSelector( + selectIsAssetsUnifyStateEnabled, + ); + const pollingNetworks = usePollingNetworks(); // Input to force polling to restart when selected account group changes @@ -36,7 +41,8 @@ const useTokenBalancesPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { const { TokenBalancesController } = Engine.context; - const input = overridePollingInput ?? pollingInput; + const resolvedInput = overridePollingInput ?? pollingInput; + const input = isAssetsUnifyStateEnabled ? [] : resolvedInput; usePolling({ startPolling: TokenBalancesController.startPolling.bind( diff --git a/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts b/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts index 871185fd0cf..9bed49929b1 100644 --- a/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts +++ b/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts @@ -13,9 +13,16 @@ jest.mock('../../../core/Engine', () => ({ }, })); +jest.mock('../../../selectors/featureFlagController/assetsUnifyState', () => ({ + selectIsAssetsUnifyStateEnabled: jest.fn(), +})); + +import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState'; + describe('useTokenDetectionPolling', () => { beforeEach(() => { jest.resetAllMocks(); + jest.mocked(selectIsAssetsUnifyStateEnabled).mockReturnValue(false); }); const selectedAddress = '0x1234567890abcdef'; @@ -117,6 +124,16 @@ describe('useTokenDetectionPolling', () => { ).toHaveBeenCalledTimes(1); }); + it('does not start polling when unified assets state is enabled', () => { + jest.mocked(selectIsAssetsUnifyStateEnabled).mockReturnValue(true); + + renderHookWithProvider(() => useTokenDetectionPolling(), { state }); + + expect( + jest.mocked(Engine.context.TokenDetectionController.startPolling), + ).not.toHaveBeenCalled(); + }); + it('Should not poll when token detection is disabled', async () => { renderHookWithProvider( () => useTokenDetectionPolling({ chainIds: ['0x1'] }), diff --git a/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts b/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts index 9cd34a89c1f..20d87dbc58d 100644 --- a/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts +++ b/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts @@ -4,12 +4,16 @@ import Engine from '../../../core/Engine'; import { Hex } from '@metamask/utils'; import { selectSelectedInternalAccount } from '../../../selectors/accountsController'; import { selectUseTokenDetection } from '../../../selectors/preferencesController'; +import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState'; import { usePollingNetworks } from './use-polling-networks'; const useTokenDetectionPolling = ({ chainIds, address, }: { chainIds?: Hex[]; address?: Hex } = {}) => { + const isAssetsUnifyStateEnabled = useSelector( + selectIsAssetsUnifyStateEnabled, + ); const selectedAccount = useSelector(selectSelectedInternalAccount); const useTokenDetection = useSelector(selectUseTokenDetection); @@ -37,12 +41,14 @@ const useTokenDetectionPolling = ({ const { TokenDetectionController } = Engine.context; - const input = useTokenDetection + const resolvedInput = useTokenDetection ? (overridePollingInput ?? pollingInput).filter( (i) => i.chainIds && i.address, ) : []; + const input = isAssetsUnifyStateEnabled ? [] : resolvedInput; + usePolling({ startPolling: TokenDetectionController.startPolling.bind( TokenDetectionController, diff --git a/app/components/hooks/AssetPolling/useTokenRatesPolling.test.ts b/app/components/hooks/AssetPolling/useTokenRatesPolling.test.ts index c47a322b878..9039638c531 100644 --- a/app/components/hooks/AssetPolling/useTokenRatesPolling.test.ts +++ b/app/components/hooks/AssetPolling/useTokenRatesPolling.test.ts @@ -13,9 +13,16 @@ jest.mock('../../../core/Engine', () => ({ }, })); +jest.mock('../../../selectors/featureFlagController/assetsUnifyState', () => ({ + selectIsAssetsUnifyStateEnabled: jest.fn(), +})); + +import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState'; + describe('useTokenRatesPolling', () => { beforeEach(() => { jest.resetAllMocks(); + jest.mocked(selectIsAssetsUnifyStateEnabled).mockReturnValue(false); }); const state = { @@ -105,6 +112,18 @@ describe('useTokenRatesPolling', () => { ).toHaveBeenCalledTimes(1); }); + it('does not start polling when unified assets state is enabled', () => { + jest.mocked(selectIsAssetsUnifyStateEnabled).mockReturnValue(true); + + renderHookWithProvider(() => useTokenRatesPolling({ chainIds: ['0x1'] }), { + state, + }); + + expect( + jest.mocked(Engine.context.TokenRatesController.startPolling), + ).not.toHaveBeenCalled(); + }); + it('should poll only for current network if selected one is not popular', () => { const stateToTest = { engine: { diff --git a/app/components/hooks/AssetPolling/useTokenRatesPolling.ts b/app/components/hooks/AssetPolling/useTokenRatesPolling.ts index 5e78014bdb9..2081d0bb3de 100644 --- a/app/components/hooks/AssetPolling/useTokenRatesPolling.ts +++ b/app/components/hooks/AssetPolling/useTokenRatesPolling.ts @@ -1,9 +1,15 @@ +import { useSelector } from 'react-redux'; import usePolling from '../usePolling'; import Engine from '../../../core/Engine'; import { Hex } from '@metamask/utils'; import { usePollingNetworks } from './use-polling-networks'; +import { selectIsAssetsUnifyStateEnabled } from '../../../selectors/featureFlagController/assetsUnifyState'; const useTokenRatesPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { + const isAssetsUnifyStateEnabled = useSelector( + selectIsAssetsUnifyStateEnabled, + ); + const pollingNetworks = usePollingNetworks(); const pollingInput = pollingNetworks.length > 0 @@ -17,7 +23,8 @@ const useTokenRatesPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { const { TokenRatesController } = Engine.context; - const input = overridePollingInput ?? pollingInput; + const resolvedInput = overridePollingInput ?? pollingInput; + const input = isAssetsUnifyStateEnabled ? [] : resolvedInput; usePolling({ startPolling: TokenRatesController.startPolling.bind(TokenRatesController), diff --git a/app/controllers/perps/PerpsController.test.ts b/app/controllers/perps/PerpsController.test.ts index 4b818e0f367..d1313036d24 100644 --- a/app/controllers/perps/PerpsController.test.ts +++ b/app/controllers/perps/PerpsController.test.ts @@ -2715,6 +2715,7 @@ describe('PerpsController', () => { origin: 'metamask', type: 'perpsDeposit', skipInitialGasEstimate: true, + isInternal: true, }, ); }); @@ -3106,6 +3107,7 @@ describe('PerpsController', () => { origin: 'metamask', type: 'perpsDepositAndOrder', skipInitialGasEstimate: true, + isInternal: true, }, ); // Should NOT also call with perpsDeposit type diff --git a/app/controllers/perps/PerpsController.ts b/app/controllers/perps/PerpsController.ts index 6962ac71900..92c2d4d2b60 100644 --- a/app/controllers/perps/PerpsController.ts +++ b/app/controllers/perps/PerpsController.ts @@ -1365,7 +1365,7 @@ export class PerpsController extends BaseController< // eslint-disable-next-line @typescript-eslint/no-explicit-any txParams as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any - options as any, + { ...(options as any), isInternal: true }, ); } diff --git a/app/util/notifications/hooks/useSwitchNotifications.test.tsx b/app/util/notifications/hooks/useSwitchNotifications.test.tsx index 914e4d0193a..b00c2bbe087 100644 --- a/app/util/notifications/hooks/useSwitchNotifications.test.tsx +++ b/app/util/notifications/hooks/useSwitchNotifications.test.tsx @@ -10,7 +10,6 @@ import { renderHookWithProvider } from '../../test/renderWithProvider'; import * as UseNotificationsModule from './useNotifications'; import { useAccountNotificationsToggle, - useFeatureAnnouncementToggle, useFetchAccountNotifications, useNotificationsToggle, useSwitchNotificationLoadingText, @@ -81,82 +80,6 @@ describe('useSwitchNotifications - useNotificationsToggle', () => { }); }); -describe('useSwitchNotifications - useFeatureAnnouncementToggle()', () => { - const arrangeMocks = () => { - const mockListNotifications = jest.fn(); - const mockUseListNotifications = jest - .spyOn(UseNotificationsModule, 'useListNotifications') - .mockReturnValue({ - error: null, - isLoading: false, - notificationsData: [], - listNotifications: mockListNotifications, - }); - - const mockSelectIsEnabled = jest - .spyOn(Selectors, 'selectIsMetamaskNotificationsEnabled') - .mockReturnValue(true); - const mockSelectIsFeatureAnnouncementsEnabled = jest - .spyOn(Selectors, 'selectIsFeatureAnnouncementsEnabled') - .mockReturnValue(true); - - const mockToggleFeatureAnnouncement = jest - .spyOn(Actions, 'toggleFeatureAnnouncements') - .mockImplementation(jest.fn()); - - return { - mockListNotifications, - mockUseListNotifications, - mockSelectIsEnabled, - mockSelectIsFeatureAnnouncementsEnabled, - mockToggleFeatureAnnouncement, - }; - }; - - type Mocks = ReturnType; - const arrangeAct = async (val: boolean, mutateMocks?: (m: Mocks) => void) => { - // Arrange - const mocks = arrangeMocks(); - mutateMocks?.(mocks); - const hook = renderHookWithProvider(() => useFeatureAnnouncementToggle()); - - // Act - await act(() => hook.result.current.switchFeatureAnnouncements(val)); - - return { mocks, hook }; - }; - - it('performs enable flow', async () => { - const { mocks } = await arrangeAct(true); - await waitFor(() => - expect(mocks.mockToggleFeatureAnnouncement).toHaveBeenCalledWith(true), - ); - await waitFor(() => expect(mocks.mockListNotifications).toHaveBeenCalled()); - }); - - it('performs disable flow', async () => { - const { mocks } = await arrangeAct(false); - await waitFor(() => - expect(mocks.mockToggleFeatureAnnouncement).toHaveBeenCalledWith(false), - ); - await waitFor(() => expect(mocks.mockListNotifications).toHaveBeenCalled()); - }); - - it('bails if notifications are not enabled', async () => { - const { mocks } = await arrangeAct(true, (m) => - m.mockSelectIsEnabled.mockReturnValue(false), - ); - await waitFor(() => - expect(mocks.mockToggleFeatureAnnouncement).not.toHaveBeenCalledWith( - true, - ), - ); - await waitFor(() => - expect(mocks.mockListNotifications).not.toHaveBeenCalled(), - ); - }); -}); - describe('useSwitchNotifications - useFetchAccountNotifications()', () => { const arrangeMocks = () => { const mockSelectIsUpdatingMetamaskNotificationsAccount = jest diff --git a/app/util/notifications/hooks/useSwitchNotifications.ts b/app/util/notifications/hooks/useSwitchNotifications.ts index e0aea45b74d..00aa20b480d 100644 --- a/app/util/notifications/hooks/useSwitchNotifications.ts +++ b/app/util/notifications/hooks/useSwitchNotifications.ts @@ -5,12 +5,10 @@ import { enableAccounts, disableAccounts, fetchAccountNotificationSettings, - toggleFeatureAnnouncements, } from '../../../actions/notification/helpers'; import { debounce } from 'lodash'; import { - selectIsFeatureAnnouncementsEnabled, selectIsMetamaskNotificationsEnabled, selectIsMetaMaskPushNotificationsLoading, selectIsUpdatingMetamaskNotifications, @@ -51,31 +49,6 @@ export function useNotificationsToggle() { }; } -export function useFeatureAnnouncementToggle() { - const { listNotifications } = useListNotifications(); - const isEnabled = useSelector(selectIsMetamaskNotificationsEnabled); - const data = useSelector(selectIsFeatureAnnouncementsEnabled); - const switchFeatureAnnouncements = useCallback( - async (val: boolean) => { - assertIsFeatureEnabled(); - if (!isEnabled) { - return; - } - - await toggleFeatureAnnouncements(val); - - // Refetch notifications - debounce(listNotifications)(); - }, - [isEnabled, listNotifications], - ); - - return { - data, - switchFeatureAnnouncements, - }; -} - export function useFetchAccountNotifications(accounts: string[]) { const accountsBeingUpdated = useSelector( selectIsUpdatingMetamaskNotificationsAccount, diff --git a/locales/languages/en.json b/locales/languages/en.json index 0c8974d5398..748ebceb013 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -2243,7 +2243,7 @@ "world_cup": { "title": "World Cup", "banner_title": "World Cup 2026", - "banner_description": "Trade on World Cup markets", + "banner_description": "Trade every match, every moment.", "tabs": { "all": "All", "props": "Props", @@ -6745,16 +6745,16 @@ "get_now": "Get now", "link_title": "Link MetaMask Card", "link_subtitle": "Spend your Money balance and earn on purchases. Plus, up to {{apy}}% APY on your balance.", + "link_subtitle_no_apy": "Spend your Money balance and earn on purchases.", "link_bullet_cashback": "Get {{percentage}}% mUSD back", "link_bullet_apy": "Earn up to {{apy}}% APY", "link_card": "Link card", - "link_pending_title": "Linking card", - "link_pending_description": "Approving spending limit…", - "link_success_title": "Card linked successfully", - "link_success_description": "You can now spend while you earn", - "link_error": "Couldn't link card", + "link_pending_title": "Linking your card", + "link_success_title": "Your card is ready to use", + "link_error": "Something went wrong linking your card", "link_card_sheet_title": "Spend and earn", - "link_card_sheet_description": "Link your card so you can spend your Money balance while it earns {{apy}}% APY.", + "link_card_sheet_description": "Link your card so you can spend your Money balance and earn mUSD back on purchases—all while earning up to {{apy}}% APY.", + "link_card_sheet_description_no_apy": "Link your card so you can spend your Money balance and earn mUSD back on purchases.", "link_card_sheet_cta": "Link card", "manage_card": "Manage", "avail_balance": "Avail. balance" diff --git a/tests/websocket/account-activity-mocks.test.ts b/tests/websocket/account-activity-mocks.test.ts index 348a236c581..10ea43300d1 100644 --- a/tests/websocket/account-activity-mocks.test.ts +++ b/tests/websocket/account-activity-mocks.test.ts @@ -27,10 +27,13 @@ describe('Account Activity WebSocket Mocks', () => { beforeEach(async () => { clients = []; - testPort = 51000 + Math.floor(Math.random() * 9000); server = new LocalWebSocketServer('test-account-activity'); - server.setServerPort(testPort); + // Use port 0 so the OS picks a free port. Picking a random fixed port + // can collide with other processes / Jest workers and produce flaky + // EADDRINUSE failures. + server.setServerPort(0); await server.start(); + testPort = server.getServerPort(); await setupAccountActivityMocks(server); }); @@ -376,10 +379,10 @@ describe('Account Activity WebSocket Mocks', () => { it('uses custom mocks passed to setup (override behavior)', async () => { await server.stop(); - const customPort = testPort + 1; const customServer = new LocalWebSocketServer('test-custom-mocks'); - customServer.setServerPort(customPort); + customServer.setServerPort(0); await customServer.start(); + const customPort = customServer.getServerPort(); await setupAccountActivityMocks(customServer, [ {