diff --git a/.github/guidelines/LABELING_GUIDELINES.md b/.github/guidelines/LABELING_GUIDELINES.md index 4819b24e8f7..ce37d3d97f3 100644 --- a/.github/guidelines/LABELING_GUIDELINES.md +++ b/.github/guidelines/LABELING_GUIDELINES.md @@ -39,6 +39,10 @@ Using any of these labels should be exceptional in case of CI friction and urgen - **skip-smart-e2e-selection**: Bypasses the AI-powered Smart E2E Selection so that the full E2E test suite runs instead of an AI-picked subset. This label does **not** force E2E builds/tests to run on a PR that would otherwise skip them (e.g. docs-only changes). Whether E2E runs at all is determined by path filters, branch, and other skip labels — not this label. +### Force Performance Tests + +- **run-performance-tests**: Forces the PR performance E2E workflow to run (all performance tests on Android low-profile devices), even when Smart E2E Selection would skip them (e.g. no performance-relevant changes detected, `skip-e2e`, or `pr-not-ready-for-e2e`). Adding or removing this label re-triggers CI. Not honored on fork PRs. + ### Block merge if any is present - **needs-qa**: The PR requires a full manual QA prior to being merged and added to a release. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a83b9938e6..e18799a1f71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1001,22 +1001,29 @@ jobs: run-performance-tests-pr: name: 'Run Performance Tests (PR)' - # Only run on pull_request events unless AI explicitly found no relevant changes. + # Run when: + # - run-performance-tests label is on the PR (forced run, all tests), OR + # - smart-e2e-selection ran and AI selected performance tags. # ai_performance_test_tags == '[]' → AI ran and found no perf-relevant changes: skip. - # ai_performance_test_tags == '' → conservative fallback (AI failed): run all tests. + # ai_performance_test_tags == '' → conservative fallback (AI failed) or run-performance-tests label: run all tests. # ai_performance_test_tags == '[…]' → specific tags selected: run those tests. if: >- ${{ github.event_name == 'pull_request' && !cancelled() && - needs.smart-e2e-selection.result != 'skipped' && - needs.smart-e2e-selection.outputs.ai_performance_test_tags != '[]' + ( + needs.get_requirements.outputs.run_performance == 'true' || + ( + needs.smart-e2e-selection.result == 'success' && + needs.smart-e2e-selection.outputs.ai_performance_test_tags != '[]' + ) + ) }} - needs: [smart-e2e-selection] + needs: [get_requirements, smart-e2e-selection] uses: ./.github/workflows/run-performance-e2e.yml with: - performance_tags: ${{ needs.smart-e2e-selection.outputs.ai_performance_test_tags }} - performance_tags_reasoning: ${{ needs.smart-e2e-selection.outputs.ai_performance_test_reasoning }} + performance_tags: ${{ needs.get_requirements.outputs.run_performance != 'true' && needs.smart-e2e-selection.outputs.ai_performance_test_tags || '' }} + performance_tags_reasoning: ${{ needs.get_requirements.outputs.run_performance == 'true' && 'Forced run via run-performance-tests label on PR' || needs.smart-e2e-selection.outputs.ai_performance_test_reasoning }} build_variant: 'e2e' branch_name: ${{ github.head_ref }} pr_number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/get-requirements.yml b/.github/workflows/get-requirements.yml index 84fafb0ab6b..59a6face860 100644 --- a/.github/workflows/get-requirements.yml +++ b/.github/workflows/get-requirements.yml @@ -26,6 +26,9 @@ on: run_smart_e2e_selection: description: 'Whether the smart-e2e-selection job should run. False for non-PR events, forks, or hard E2E skips.' value: ${{ jobs.detect-changes.outputs.run_smart_e2e_selection }} + run_performance: + description: 'Whether performance E2E tests should be forced to run via the run-performance-tests PR label' + value: ${{ jobs.detect-changes.outputs.run_performance }} jobs: detect-changes: @@ -40,6 +43,7 @@ jobs: changed_files: ${{ steps.set-outputs.outputs.changed_files }} block_merge_for_e2e_readiness: ${{ steps.set-outputs.outputs.block_merge }} run_smart_e2e_selection: ${{ steps.set-outputs.outputs.run_smart_e2e_selection }} + run_performance: ${{ steps.set-outputs.outputs.run_performance }} env: # For a `pull_request` event, the head commit hash is `github.event.pull_request.head.sha`. # For a `push` event, the head commit hash is `github.sha`. @@ -85,6 +89,7 @@ jobs: echo "SKIP_E2E=false" echo "LABEL_BLOCKS_MERGE=false" echo "SKIP_SMART_SELECTION=false" + echo "RUN_PERFORMANCE=false" } >> "$GITHUB_OUTPUT" LABELS=$(gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name') @@ -107,6 +112,11 @@ jobs: echo "-> SKIP_SMART_SELECTION=true due to 'skip-smart-e2e-selection' label on PR" fi + if echo "$LABELS" | grep -qx "run-performance-tests"; then + echo "RUN_PERFORMANCE=true" >> "$GITHUB_OUTPUT" + echo "-> RUN_PERFORMANCE=true due to 'run-performance-tests' label on PR" + fi + - name: Filter changed files id: filter if: steps.skip-merge-queue.outputs.up-to-date != 'true' @@ -124,6 +134,7 @@ jobs: IS_FORK: ${{ github.event.pull_request.head.repo.fork }} SHOULD_SKIP_E2E: ${{ steps.skip-e2e-tag.outputs.SKIP == 'true' || steps.check-labels.outputs.SKIP_E2E == 'true' }} LABEL_BLOCKS_MERGE: ${{ steps.check-labels.outputs.LABEL_BLOCKS_MERGE }} + RUN_PERFORMANCE_LABEL: ${{ steps.check-labels.outputs.RUN_PERFORMANCE }} ALL_CHANGES_COUNT: ${{ steps.filter.outputs.all_changes_count }} ALL_CHANGES_FILES: ${{ github.event_name == 'pull_request' && steps.filter.outputs.all_changes_files || '' }} IGNORABLE_COUNT: ${{ steps.filter.outputs.e2e_ignorable_count }} @@ -220,6 +231,13 @@ jobs: echo "-> BLOCK_MERGE bypassed — ignorable-only changes, E2E_WORKFLOWS_COUNT=0" fi + RUN_PERF=false + if [[ "$GITHUB_EVENT_NAME" == "pull_request" && \ + "$IS_FORK" != "true" && \ + "$RUN_PERFORMANCE_LABEL" == "true" ]]; then + RUN_PERF=true + fi + echo "$MSG" { echo "android_final=$ANDROID" @@ -227,6 +245,7 @@ jobs: echo "e2e_needed=$E2E_NEEDED" echo "run_smart_e2e_selection=$RUN_SMART" echo "block_merge=$BLOCK_MERGE" + echo "run_performance=$RUN_PERF" } >> "$GITHUB_OUTPUT" { diff --git a/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml b/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml index f64c6c70166..87db7ad6362 100644 --- a/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml +++ b/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml @@ -17,7 +17,8 @@ jobs: github.event.label.name == 'skip-e2e' || github.event.label.name == 'skip-e2e-flakiness-detection' || github.event.label.name == 'pr-not-ready-for-e2e' || - github.event.label.name == 'force-builds') + github.event.label.name == 'force-builds' || + github.event.label.name == 'run-performance-tests') runs-on: ubuntu-latest permissions: actions: write diff --git a/app/__mocks__/@metamask/compliance-controller.ts b/app/__mocks__/@metamask/compliance-controller.ts index edbfc962beb..fdbe3408924 100644 --- a/app/__mocks__/@metamask/compliance-controller.ts +++ b/app/__mocks__/@metamask/compliance-controller.ts @@ -5,10 +5,17 @@ * Compliance status is populated exclusively via per-address checks. */ +interface ComplianceServiceOptions { + messenger: unknown; + fetch: typeof fetch; + apiUrl?: string; + env?: 'production' | 'development'; +} + export class ComplianceService { readonly name = 'ComplianceService'; - constructor(args: Record) { + constructor(args: ComplianceServiceOptions) { Object.assign(this, args); } } @@ -53,8 +60,14 @@ function findWalletComplianceStatus( walletComplianceStatusMap: Record, address: string, ) { + const exactMatch = walletComplianceStatusMap[address]; + if (exactMatch !== undefined) { + return exactMatch; + } + + // Non-EVM addresses: no case-insensitive fallback (mirrors real package behaviour). if (!/^0x[0-9a-fA-F]{40}$/.test(address)) { - return walletComplianceStatusMap[address]; + return undefined; } return Object.entries(walletComplianceStatusMap).find( diff --git a/app/component-library/components/Icons/Icon/assets/error.svg b/app/component-library/components/Icons/Icon/assets/error.svg index 87f388cf7d4..8fcfa23f5e4 100644 --- a/app/component-library/components/Icons/Icon/assets/error.svg +++ b/app/component-library/components/Icons/Icon/assets/error.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/components/UI/Bridge/Views/BridgeView/index.tsx b/app/components/UI/Bridge/Views/BridgeView/index.tsx index 9981fbde55e..eda6122485a 100644 --- a/app/components/UI/Bridge/Views/BridgeView/index.tsx +++ b/app/components/UI/Bridge/Views/BridgeView/index.tsx @@ -120,6 +120,10 @@ import { ButtonVariants, } from '../../../../../component-library/components/Buttons/Button/Button.types.ts'; import { useIsNetworkFeeUnavailable } from '../../hooks/useIsNetworkFeeUnavailable/index.ts'; +import { + hidePostTradeNotificationSurface, + showPostTradeNotificationSurface, +} from '../../utils/postTradeNotifications'; const SCROLL_NEAR_BOTTOM_PX = 160; @@ -222,6 +226,16 @@ const BridgeViewContent = ({ latestSourceBalance }: BridgeViewContentProps) => { useBridgeViewOnFocus({ inputRef, keypadRef }); + useFocusEffect( + useCallback(() => { + showPostTradeNotificationSurface(); + + return () => { + hidePostTradeNotificationSurface(); + }; + }, []), + ); + // Scroll to top when navigating to the bridge view if requested useFocusEffect( useCallback(() => { diff --git a/app/components/UI/Bridge/components/PostTradeBottomSheet/index.tsx b/app/components/UI/Bridge/components/PostTradeBottomSheet/index.tsx index ee75098ee89..17ae3f0a81b 100644 --- a/app/components/UI/Bridge/components/PostTradeBottomSheet/index.tsx +++ b/app/components/UI/Bridge/components/PostTradeBottomSheet/index.tsx @@ -44,6 +44,10 @@ import { import styleSheet from './PostTradeBottomSheet.styles'; import { usePostTradeTxStatus } from './usePostTradeTxStatus'; import { useBridgeQuoteRequest } from '../../hooks/useBridgeQuoteRequest'; +import { + hidePostTradeNotificationSurface, + showPostTradeNotificationSurface, +} from '../../utils/postTradeNotifications'; export const getTradeSubtitle = ({ sourceAmount, @@ -128,6 +132,14 @@ export const PostTradeBottomSheet = () => { transactionHash: params.transactionHash, }); + useEffect(() => { + showPostTradeNotificationSurface(); + + return () => { + hidePostTradeNotificationSurface(); + }; + }, []); + useEffect(() => { const isTerminalStatus = status === PostTradeStatus.Success || status === PostTradeStatus.Failed; diff --git a/app/components/UI/Bridge/hooks/useBridgeConfirm/index.ts b/app/components/UI/Bridge/hooks/useBridgeConfirm/index.ts index 2bce3348d39..696a0531c45 100644 --- a/app/components/UI/Bridge/hooks/useBridgeConfirm/index.ts +++ b/app/components/UI/Bridge/hooks/useBridgeConfirm/index.ts @@ -24,6 +24,7 @@ import { POST_TRADE_MODAL_VARIANTS, } from '../../components/PostTradeBottomSheet/abTestConfig'; import Engine from '../../../../../core/Engine'; +import { withPostTradeNotificationSuppression } from '../../utils/postTradeNotifications'; interface Params { activeQuote: ReturnType['activeQuote'] | null; @@ -68,11 +69,15 @@ export const useBridgeConfirm = ({ try { dispatch(setIsSubmittingTx(true)); - const submittedTransaction = await submitBridgeTx({ - quoteResponse: activeQuote, - location, - transactionActiveAbTests, - }); + const submitTransaction = () => + submitBridgeTx({ + quoteResponse: activeQuote, + location, + transactionActiveAbTests, + }); + const submittedTransaction = isPostTradeModalEnabled + ? await withPostTradeNotificationSuppression(submitTransaction) + : await submitTransaction(); const transactionHash = submittedTransaction && 'hash' in submittedTransaction && diff --git a/app/components/UI/Bridge/utils/postTradeNotifications.test.ts b/app/components/UI/Bridge/utils/postTradeNotifications.test.ts new file mode 100644 index 00000000000..012fad920fb --- /dev/null +++ b/app/components/UI/Bridge/utils/postTradeNotifications.test.ts @@ -0,0 +1,147 @@ +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import type { NotificationSkipPredicate } from '../../../../core/notificationSkipPredicates'; + +const mockRegisterNotificationSkipPredicate = jest.fn(); +const mockGetIsBridgeTransaction = jest.fn(); + +jest.mock('../../../../core/notificationSkipPredicates', () => ({ + __esModule: true, + registerNotificationSkipPredicate: mockRegisterNotificationSkipPredicate, +})); + +jest.mock('./transaction', () => ({ + __esModule: true, + getIsBridgeTransaction: mockGetIsBridgeTransaction, +})); + +const loadPostTradeNotifications = () => { + jest.resetModules(); + mockRegisterNotificationSkipPredicate.mockReset(); + mockGetIsBridgeTransaction.mockReset(); + + return jest.requireActual( + './postTradeNotifications', + ) as typeof import('./postTradeNotifications'); +}; + +const getRegisteredPredicate = (): NotificationSkipPredicate => { + expect(mockRegisterNotificationSkipPredicate).toHaveBeenCalledTimes(1); + return mockRegisterNotificationSkipPredicate.mock + .calls[0][0] as NotificationSkipPredicate; +}; + +const transactionMeta = ( + txMeta: Partial = {}, +): TransactionMeta => + ({ + id: 'tx-1', + type: TransactionType.contractInteraction, + ...txMeta, + }) as TransactionMeta; + +describe('postTradeNotifications', () => { + it('registers once and suppresses tracked submissions only while a surface is visible', async () => { + const { + showPostTradeNotificationSurface, + hidePostTradeNotificationSurface, + withPostTradeNotificationSuppression, + } = loadPostTradeNotifications(); + + showPostTradeNotificationSurface(); + showPostTradeNotificationSurface(); + + expect(mockRegisterNotificationSkipPredicate).toHaveBeenCalledTimes(1); + + await expect( + withPostTradeNotificationSuppression(() => + Promise.resolve({ id: 'submitted-tx' }), + ), + ).resolves.toEqual({ id: 'submitted-tx' }); + + const predicate = getRegisteredPredicate(); + + expect(predicate(transactionMeta({ id: 'submitted-tx' }))).toBe(true); + expect(predicate(transactionMeta({ id: 'untracked-tx' }))).toBe(false); + + hidePostTradeNotificationSurface(); + hidePostTradeNotificationSurface(); + hidePostTradeNotificationSurface(); + + expect(predicate(transactionMeta({ id: 'submitted-tx' }))).toBe(false); + }); + + it('suppresses in-flight Bridge and batch transactions while a surface is visible', async () => { + const { + showPostTradeNotificationSurface, + withPostTradeNotificationSuppression, + } = loadPostTradeNotifications(); + const bridgeTxMeta = transactionMeta({ id: 'bridge-tx' }); + const batchTxMeta = transactionMeta({ + id: 'batch-tx', + type: TransactionType.batch, + }); + + showPostTradeNotificationSurface(); + + await withPostTradeNotificationSuppression(async () => { + const predicate = getRegisteredPredicate(); + + mockGetIsBridgeTransaction.mockReturnValueOnce(true); + expect(predicate(bridgeTxMeta)).toBe(true); + expect(mockGetIsBridgeTransaction).toHaveBeenCalledWith(bridgeTxMeta); + + mockGetIsBridgeTransaction.mockReturnValueOnce(false); + expect(predicate(batchTxMeta)).toBe(true); + + return { id: 'submitted-batch-tx' }; + }); + }); + + it('does not suppress missing or unrelated transaction metadata', async () => { + const { + showPostTradeNotificationSurface, + withPostTradeNotificationSuppression, + } = loadPostTradeNotifications(); + + showPostTradeNotificationSurface(); + mockGetIsBridgeTransaction.mockReturnValue(false); + + await withPostTradeNotificationSuppression(async () => { + const predicate = getRegisteredPredicate(); + + expect(predicate(undefined)).toBe(false); + expect(predicate(transactionMeta({ id: 'unrelated-tx' }))).toBe(false); + + return undefined; + }); + }); + + it('clears pending submission state when submit fails', async () => { + const { + showPostTradeNotificationSurface, + withPostTradeNotificationSuppression, + } = loadPostTradeNotifications(); + const bridgeTxMeta = transactionMeta({ id: 'bridge-tx' }); + const error = new Error('submit failed'); + + showPostTradeNotificationSurface(); + mockGetIsBridgeTransaction.mockReturnValue(true); + + await expect( + withPostTradeNotificationSuppression(async () => { + const predicate = getRegisteredPredicate(); + + expect(predicate(bridgeTxMeta)).toBe(true); + + throw error; + }), + ).rejects.toThrow(error); + + const predicate = getRegisteredPredicate(); + + expect(predicate(bridgeTxMeta)).toBe(false); + }); +}); diff --git a/app/components/UI/Bridge/utils/postTradeNotifications.ts b/app/components/UI/Bridge/utils/postTradeNotifications.ts new file mode 100644 index 00000000000..32acd82a5fa --- /dev/null +++ b/app/components/UI/Bridge/utils/postTradeNotifications.ts @@ -0,0 +1,84 @@ +import { + TransactionType, + type TransactionMeta, +} from '@metamask/transaction-controller'; +import { registerNotificationSkipPredicate } from '../../../../core/notificationSkipPredicates'; +import { getIsBridgeTransaction } from './transaction'; + +const MAX_TRACKED_TRANSACTION_IDS = 50; +const trackedTransactionIds = new Set(); + +let visibleSurfaceCount = 0; +let pendingSubmissions = 0; +let isNotificationSkipPredicateRegistered = false; + +function isPostTradeNotificationTransaction( + transactionMeta: TransactionMeta | undefined, +): boolean { + if (visibleSurfaceCount === 0) { + return false; + } + + if (transactionMeta?.id && trackedTransactionIds.has(transactionMeta.id)) { + return true; + } + + if (!transactionMeta || pendingSubmissions === 0) { + return false; + } + + return ( + getIsBridgeTransaction(transactionMeta) || + transactionMeta.type === TransactionType.batch + ); +} + +function ensureNotificationSkipPredicateRegistered(): void { + if (isNotificationSkipPredicateRegistered) { + return; + } + + registerNotificationSkipPredicate(isPostTradeNotificationTransaction); + isNotificationSkipPredicateRegistered = true; +} + +function trackPostTradeTransaction(txMetaId: string): void { + trackedTransactionIds.delete(txMetaId); + trackedTransactionIds.add(txMetaId); + + if (trackedTransactionIds.size > MAX_TRACKED_TRANSACTION_IDS) { + const oldest = trackedTransactionIds.values().next().value; + if (oldest !== undefined) { + trackedTransactionIds.delete(oldest); + } + } +} + +export function showPostTradeNotificationSurface(): void { + ensureNotificationSkipPredicateRegistered(); + visibleSurfaceCount += 1; +} + +export function hidePostTradeNotificationSurface(): void { + visibleSurfaceCount = Math.max(0, visibleSurfaceCount - 1); +} + +export async function withPostTradeNotificationSuppression< + SubmittedTransaction extends { id?: unknown } | undefined, +>( + submitTransaction: () => Promise, +): Promise { + ensureNotificationSkipPredicateRegistered(); + pendingSubmissions += 1; + + try { + const submittedTransaction = await submitTransaction(); + if (typeof submittedTransaction?.id === 'string') { + trackPostTradeTransaction(submittedTransaction.id); + } + + return submittedTransaction; + } finally { + pendingSubmissions = Math.max(0, pendingSubmissions - 1); + } +} diff --git a/app/components/UI/Compliance/hooks/useComplianceGate.test.ts b/app/components/UI/Compliance/hooks/useComplianceGate.test.ts index 86f66b05e9a..790e51a2711 100644 --- a/app/components/UI/Compliance/hooks/useComplianceGate.test.ts +++ b/app/components/UI/Compliance/hooks/useComplianceGate.test.ts @@ -85,6 +85,41 @@ describe('useComplianceGate', () => { expect(result.current.isBlocked).toBe(true); }); + describe('no address provided', () => { + it('returns isBlocked=false when no address is provided', () => { + mockUseSelector + .mockReturnValueOnce(true) // selectComplianceEnabled + .mockReturnValueOnce(false); // selectAreAnyWalletsBlocked + + const { result } = renderHook(() => useComplianceGate()); + + expect(result.current.isBlocked).toBe(false); + expect(mockCheckWalletsCompliance).not.toHaveBeenCalled(); + }); + + it('gate() proceeds with action when no address is provided', async () => { + mockUseSelector + .mockReturnValueOnce(true) // selectComplianceEnabled + .mockReturnValueOnce(false); // selectAreAnyWalletsBlocked + + const action = jest.fn().mockResolvedValue('result'); + const { result } = renderHook(() => useComplianceGate()); + + // Let the prefetch settle — checkCompliance returns undefined for empty addressKey + await act(async () => { + await Promise.resolve(); + }); + + const value = await result.current.gate(action); + + expect(action).toHaveBeenCalledTimes(1); + expect(value).toBe('result'); + expect(mockShowAccessRestrictedModal).not.toHaveBeenCalled(); + // checkWalletsCompliance is never called when there is no address to check + expect(mockCheckWalletsCompliance).not.toHaveBeenCalled(); + }); + }); + describe('prefetch effect', () => { it('calls checkCompliance on mount when compliance is enabled', async () => { mockUseSelector.mockReturnValue(true); @@ -293,5 +328,166 @@ describe('useComplianceGate', () => { expect(mockShowAccessRestrictedModal).not.toHaveBeenCalled(); expect(value).toBe('result'); }); + + describe('wallet switch race conditions', () => { + it('does not apply a late-resolving stale prefetch to the new address', async () => { + // Scenario: prefetch for BLOCKED_ADDRESS is still in-flight when the + // wallet switches to SAFE_ADDRESS. The old prefetch resolves blocked=true + // AFTER the new address is active. The requestId guard must prevent it + // from writing prefetchBlockedRef, so gate() for SAFE_ADDRESS allows + // the action. + mockUseSelector.mockReturnValue(true); // compliance enabled for all renders + + let resolveOldPrefetch!: ( + value: { address: string; blocked: boolean; checkedAt: string }[], + ) => void; + const oldPrefetch = new Promise< + { address: string; blocked: boolean; checkedAt: string }[] + >((resolve) => { + resolveOldPrefetch = resolve; + }); + + mockCheckWalletsCompliance + .mockReturnValueOnce(oldPrefetch) // first call: BLOCKED_ADDRESS (in-flight) + .mockResolvedValueOnce([ + { + address: SAFE_ADDRESS, + blocked: false, + checkedAt: '2025-01-01T00:00:00Z', + }, + ]); // second call: SAFE_ADDRESS + + const action = jest.fn().mockResolvedValue('result'); + const { result, rerender } = renderHook( + ({ address }: { address: string }) => useComplianceGate(address), + { initialProps: { address: BLOCKED_ADDRESS } }, + ); + + // Switch to SAFE_ADDRESS — new prefetch starts, requestId incremented. + rerender({ address: SAFE_ADDRESS }); + + // Let the new prefetch for SAFE_ADDRESS settle (not blocked). + await act(async () => { + await Promise.resolve(); + }); + + // Now resolve the old prefetch with blocked=true. The requestId guard + // must prevent this from writing prefetchBlockedRef. + resolveOldPrefetch([ + { + address: BLOCKED_ADDRESS, + blocked: true, + checkedAt: '2025-01-01T00:00:00Z', + }, + ]); + await act(async () => { + await Promise.resolve(); + }); + + const value = await act(async () => result.current.gate(action)); + + expect(action).toHaveBeenCalledTimes(1); + expect(mockShowAccessRestrictedModal).not.toHaveBeenCalled(); + expect(value).toBe('result'); + }); + + it('abandons silently when the wallet switches while gate is awaiting a check', async () => { + // Scenario: gate() is called with BLOCKED_ADDRESS while the prefetch is + // in-flight. Before it resolves, the wallet switches to SAFE_ADDRESS. + // gate() must not run the action and must not show the modal. + mockUseSelector.mockReturnValue(true); + + let resolveGatePrefetch!: ( + value: { address: string; blocked: boolean; checkedAt: string }[], + ) => void; + const gatePrefetch = new Promise< + { address: string; blocked: boolean; checkedAt: string }[] + >((resolve) => { + resolveGatePrefetch = resolve; + }); + + mockCheckWalletsCompliance + .mockReturnValueOnce(gatePrefetch) // prefetch for BLOCKED_ADDRESS + .mockResolvedValueOnce([]); // prefetch for SAFE_ADDRESS after switch + + const action = jest.fn(); + const { result, rerender } = renderHook( + ({ address }: { address: string }) => useComplianceGate(address), + { initialProps: { address: BLOCKED_ADDRESS } }, + ); + + // Start gate while the prefetch is still in-flight. + const gatePromise = result.current.gate(action); + + // Wallet switches mid-flight — currentAddressKeyRef updates immediately. + rerender({ address: SAFE_ADDRESS }); + + // Resolve the old prefetch (not blocked — to confirm it's the address + // check, not the blocked status, that causes the abandon). + resolveGatePrefetch([ + { + address: BLOCKED_ADDRESS, + blocked: false, + checkedAt: '2025-01-01T00:00:00Z', + }, + ]); + + await act(async () => { + await gatePromise; + }); + + // Action abandoned — it belonged to the previous wallet. + expect(action).not.toHaveBeenCalled(); + expect(mockShowAccessRestrictedModal).not.toHaveBeenCalled(); + }); + + it('blocks action for new address when its own prefetch returns blocked', async () => { + // Sanity check: after a wallet switch, gate() correctly blocks when the + // NEW address is blocked (not a false positive from the old address). + mockUseSelector.mockReturnValue(true); + + mockCheckWalletsCompliance + .mockResolvedValueOnce([ + { + address: SAFE_ADDRESS, + blocked: false, + checkedAt: '2025-01-01T00:00:00Z', + }, + ]) // prefetch for SAFE_ADDRESS + .mockResolvedValueOnce([ + { + address: BLOCKED_ADDRESS, + blocked: true, + checkedAt: '2025-01-01T00:00:00Z', + }, + ]); // prefetch for BLOCKED_ADDRESS + + const action = jest.fn(); + const { result, rerender } = renderHook( + ({ address }: { address: string }) => useComplianceGate(address), + { initialProps: { address: SAFE_ADDRESS } }, + ); + + // Let SAFE_ADDRESS prefetch settle. + await act(async () => { + await Promise.resolve(); + }); + + // Switch to BLOCKED_ADDRESS. + rerender({ address: BLOCKED_ADDRESS }); + + // Let BLOCKED_ADDRESS prefetch settle. + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + await result.current.gate(action); + }); + + expect(action).not.toHaveBeenCalled(); + expect(mockShowAccessRestrictedModal).toHaveBeenCalledTimes(1); + }); + }); }); }); diff --git a/app/components/UI/Compliance/hooks/useComplianceGate.ts b/app/components/UI/Compliance/hooks/useComplianceGate.ts index 352cd6daf68..a0b1f935e0f 100644 --- a/app/components/UI/Compliance/hooks/useComplianceGate.ts +++ b/app/components/UI/Compliance/hooks/useComplianceGate.ts @@ -16,7 +16,10 @@ type AddressInput = string | string[]; * 1. Skips the check entirely when compliance is disabled via feature flag. * 2. Awaits the prefetch if it is still in-flight (race-condition guard for * users who tap very quickly after the screen loads). - * 3. Shows `AccessRestrictedModal` and returns `undefined` if any address is + * 3. Abandons silently (returns `undefined`, shows nothing) if the selected + * wallet changed while the check was in flight — the action belonged to the + * previous wallet, so the user retries under the new one. + * 4. Shows `AccessRestrictedModal` and returns `undefined` if any address is * blocked; otherwise runs the action and returns its result. * * @param address - A single wallet address or array of addresses to check. @@ -43,7 +46,7 @@ export function useComplianceGate(address?: AddressInput) { // Derive addresses from the scalar key so the memo depends only on what it // uses — no eslint-disable needed. const addresses = useMemo( - () => (addressKey ? addressKey.split(',') : []), + () => (addressKey ? addressKey.split(',').filter(Boolean) : []), [addressKey], ); @@ -60,34 +63,65 @@ export function useComplianceGate(address?: AddressInput) { ); }, [addresses, addressKey]); - // Holds the in-flight prefetch promise so gate() can await it if the user - // taps a button before the prefetch has resolved. - const prefetchRef = useRef | null>(null); + // Holds the in-flight prefetch tagged with the address set it belongs to, so + // gate() can verify the cached prefetch belongs to the current wallet before + // trusting it. + const prefetchRef = useRef<{ + addressKey: string; + promise: Promise; + } | null>(null); - // Stores the resolved blocked status from the most recent API call. + // Stores the resolved blocked status from the most recent prefetch. // Default false = fail-open: assume not blocked until the API says otherwise. // Reset to false at the start of each prefetch (while in-flight) so gate() // never reads a stale result from a previous address. const prefetchBlockedRef = useRef(false); + // Guards against a slow in-flight prefetch for a previous address resolving + // late and overwriting prefetchBlockedRef for the current address. + const requestIdRef = useRef(0); + + // Latest-value ref assigned during render so it reflects the current wallet + // the instant a switch causes a re-render — before the prefetch effect fires. + // gate() reads this to detect a wallet switch that happens while a compliance + // check is in flight. + const currentAddressKeyRef = useRef(addressKey); + currentAddressKeyRef.current = addressKey; + + // Keep gate stable for consumers while allowing its fallback path to call the + // latest address-bound compliance check after a render-before-effect wallet switch. + const checkComplianceRef = useRef(checkCompliance); + checkComplianceRef.current = checkCompliance; + // Prefetch compliance status on mount and whenever the address changes. + // `checkCompliance` is memoized on `addresses`, so its identity changes + // exactly when the address set changes — that (not `addresses.length`, which + // stays constant across an account switch) is the correct re-fetch signal. useEffect(() => { if (!isComplianceEnabled) { prefetchBlockedRef.current = false; + prefetchRef.current = null; return; } + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; prefetchBlockedRef.current = false; // reset while in-flight - prefetchRef.current = checkCompliance() + const promise = checkCompliance() .then((results) => { - prefetchBlockedRef.current = results - ? results.some((r) => r.blocked) - : false; + if (requestIdRef.current === requestId) { + prefetchBlockedRef.current = results + ? results.some((r) => r.blocked) + : false; + } }) .catch(() => { - prefetchBlockedRef.current = false; // fail-open on error - DevLogger.log('[useComplianceGate] Prefetch compliance check failed'); + if (requestIdRef.current === requestId) { + prefetchBlockedRef.current = false; // fail-open on error + DevLogger.log('[useComplianceGate] Prefetch compliance check failed'); + } }); - }, [isComplianceEnabled, checkCompliance]); + prefetchRef.current = { addressKey, promise }; + }, [addressKey, checkCompliance, isComplianceEnabled]); const gate = useCallback( async (action: () => Promise): Promise => { @@ -98,16 +132,41 @@ export function useComplianceGate(address?: AddressInput) { return action(); } - // If the prefetch is still in-flight (user tapped very quickly), wait for - // it to settle so prefetchBlockedRef reflects the fresh API result. - // If it has already resolved this is effectively instant (~0ms). - await prefetchRef.current; + // Capture the address at gate() entry — used after the await to detect + // a wallet switch that happened while the check was in flight. + const gateAddressKey = currentAddressKeyRef.current; + const prefetch = prefetchRef.current; + + let blocked: boolean; + if (prefetch && prefetch.addressKey === gateAddressKey) { + // Common path: trust the prefetch for the current wallet. Await it in + // case the user tapped before it settled (instant if already resolved). + await prefetch.promise; + blocked = prefetchBlockedRef.current; + } else { + // Transient window after a wallet switch, before the effect fires — + // no prefetch exists yet for the current address. Do a one-off check, + // failing open on error. + let results: { blocked: boolean }[] | undefined; + try { + results = await checkComplianceRef.current(); + } catch { + results = undefined; + } + blocked = results?.some((r) => r.blocked) ?? false; + } - // Read the fresh result from the ref — it was written by the prefetch - // .then() handler, so no closure staleness or store access needed. - const freshIsBlocked = isComplianceEnabled && prefetchBlockedRef.current; + // If the selected wallet changed while the check was in flight, abandon + // silently. The action belonged to the previous wallet; the user retries + // under the new one, which will have its own prefetch. + if (currentAddressKeyRef.current !== gateAddressKey) { + DevLogger.log( + '[useComplianceGate] Wallet switched mid-check, abandoning', + ); + return; + } - if (freshIsBlocked) { + if (blocked) { DevLogger.log('[useComplianceGate] Wallet blocked, showing modal'); showAccessRestrictedModal(); return; diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx index b207b00a8d0..40d8384529a 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx @@ -36,6 +36,12 @@ import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountC import { MONEY_HOME_CARD_ORIGIN } from '../../../Card/hooks/useCardPostAuthRedirect'; import { moneyFormatFiat } from '../../utils/moneyFormatFiat'; import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance'; +import { + COMPONENT_NAMES, + MONEY_BUTTON_INTENTS, + MONEY_BUTTON_TYPES, + SCREEN_NAMES, +} from '../../constants/moneyEvents'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { CardActions, @@ -197,9 +203,10 @@ jest.mock('../../../Earn/hooks/useMusdBalance', () => ({ useMusdBalance: jest.fn(() => ({ tokenBalanceAggregated: '0' })), })); +const mockTrackButtonClicked = jest.fn(); jest.mock('../../hooks/useMoneyAnalytics', () => ({ useMoneyAnalytics: jest.fn(() => ({ - trackButtonClicked: jest.fn(), + trackButtonClicked: mockTrackButtonClicked, trackTooltipClicked: jest.fn(), trackSurfaceClicked: jest.fn(), trackTokenButtonClicked: jest.fn(), @@ -218,6 +225,18 @@ jest.mock('../../hooks/useMoneyAccount', () => ({ })), })); +const mockRouteAddMoney = jest.fn(); +let mockHasMusdBalance = false; +jest.mock('../../hooks/useMoneyAccountAddRouting', () => ({ + useMoneyAccountAddRouting: jest.fn(() => ({ + hasMusdBalance: mockHasMusdBalance, + convertCrypto: jest.fn(), + depositFunds: jest.fn(), + moveMusd: jest.fn(), + routeAddMoney: mockRouteAddMoney, + })), +})); + jest.mock('../../hooks/useOnboardingStep', () => ({ useOnboardingStep: jest.fn(() => ({ currentStep: 0, @@ -311,6 +330,7 @@ describe('MoneyHomeView', () => { beforeEach(() => { jest.clearAllMocks(); global.alert = jest.fn(); + mockHasMusdBalance = false; mockUseMoneyAccountCardTransactions.mockReturnValue({ cardTransactions: [], @@ -1432,18 +1452,45 @@ describe('MoneyHomeView', () => { expect(getByTestId(MoneyWhatYouGetTestIds.CONTAINER)).toBeOnTheScreen(); }); - it.each([['mUSD row Add', MoneyMusdTokenRowTestIds.ADD_BUTTON]])( - 'opens the Add money sheet from the %s button', - (_label, testId) => { - const { getByTestId } = renderWithProvider(); + it('routes directly via useMoneyAccountAddRouting when the mUSD row Add button is pressed', () => { + const { getByTestId } = renderWithProvider(); - fireEvent.press(getByTestId(testId)); + fireEvent.press(getByTestId(MoneyMusdTokenRowTestIds.ADD_BUTTON)); - expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { - screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, - }); - }, - ); + expect(mockRouteAddMoney).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, + }); + }); + + it('tracks the mUSD row Add click with the Buy flow redirect target when there is no mUSD balance', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyMusdTokenRowTestIds.ADD_BUTTON)); + + expect(mockTrackButtonClicked).toHaveBeenCalledWith({ + button_type: MONEY_BUTTON_TYPES.TEXT, + button_intent: MONEY_BUTTON_INTENTS.ADD_MONEY, + label_key: 'money.musd_row.add', + component_name: COMPONENT_NAMES.MONEY_MUSD_TOKEN_SECTION, + redirect_target: SCREEN_NAMES.RAMP_BUY, + }); + }); + + it('tracks the mUSD row Add click with the deposit redirect target when there is an mUSD balance', () => { + mockHasMusdBalance = true; + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyMusdTokenRowTestIds.ADD_BUTTON)); + + expect(mockTrackButtonClicked).toHaveBeenCalledWith({ + button_type: MONEY_BUTTON_TYPES.TEXT, + button_intent: MONEY_BUTTON_INTENTS.ADD_MONEY, + label_key: 'money.musd_row.add', + component_name: COMPONENT_NAMES.MONEY_MUSD_TOKEN_SECTION, + redirect_target: SCREEN_NAMES.MONEY_DEPOSIT, + }); + }); it('navigates to Asset details when the mUSD token row is pressed', () => { const NavigationService = jest.requireMock( diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx index cc3e6df0243..1493416f62b 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx @@ -36,6 +36,7 @@ import { mergeMoneyActivity } from '../../hooks/useMoneyActivityItems'; import MoneyActivityLoading from '../../components/MoneyActivityLoading/MoneyActivityLoading'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import useMoneyAccountInfo from '../../hooks/useMoneyAccountInfo'; +import { useMoneyAccountAddRouting } from '../../hooks/useMoneyAccountAddRouting'; import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; import { moneyFormatFiat, DUST_THRESHOLD } from '../../utils/moneyFormatFiat'; import { calculateProjectedEarnings } from '../../utils/projections'; @@ -139,6 +140,7 @@ const MoneyHomeView = () => { const { tokens: depositTokens, isNoFeeToken } = useMoneyDepositTokens(); const { initiateDeposit } = useMoneyAccountDeposit(); + const { hasMusdBalance, routeAddMoney } = useMoneyAccountAddRouting(); const { allTransactions, moneyAddress, mockDataEnabled } = useMoneyAccountTransactions(); const { cardTransactions, isLoading: isCardActivityLoading } = @@ -264,6 +266,20 @@ const MoneyHomeView = () => { [navigation, trackButtonClicked], ); + const handleMusdRowAddPress = useCallback(() => { + trackButtonClicked({ + button_type: MONEY_BUTTON_TYPES.TEXT, + button_intent: MONEY_BUTTON_INTENTS.ADD_MONEY, + label_key: 'money.musd_row.add', + component_name: COMPONENT_NAMES.MONEY_MUSD_TOKEN_SECTION, + redirect_target: hasMusdBalance + ? SCREEN_NAMES.MONEY_DEPOSIT + : SCREEN_NAMES.RAMP_BUY, + }); + + routeAddMoney(); + }, [hasMusdBalance, routeAddMoney, trackButtonClicked]); + const handleTransferPress = useCallback(() => { trackButtonClicked({ button_type: MONEY_BUTTON_TYPES.TEXT, @@ -635,12 +651,7 @@ const MoneyHomeView = () => { componentName: COMPONENT_NAMES.MONEY_MUSD_TOKEN_SECTION, }) } - onAddPress={() => - handleAddPress({ - labelKey: 'money.musd_row.add', - componentName: COMPONENT_NAMES.MONEY_MUSD_TOKEN_SECTION, - }) - } + onAddPress={handleMusdRowAddPress} balance={musdFiatFormatted} /> diff --git a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx index 666384fb1b2..3c2e24e3a5e 100644 --- a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx +++ b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import BigNumber from 'bignumber.js'; +import { useSelector } from 'react-redux'; import MoneyOnboardingCard, { MONEY_ONBOARDING_TOTAL_STEPS, } from './MoneyOnboardingCard'; @@ -23,6 +24,7 @@ import { MONEY_ONBOARDING_STEP_ACTIONS, SCREEN_NAMES, } from '../../constants/moneyEvents'; +import { selectIsCardholder } from '../../../../../selectors/cardController'; const mockTrackEvent = jest.fn(); const mockBuild = jest.fn(() => ({ name: 'built-event' })); @@ -97,6 +99,7 @@ const mockStartLinkFlow = jest.fn(); interface SetupOptions { currentStep?: number; + isCardholder?: boolean; isCardAuthenticated?: boolean; isCardLinkedToMoneyAccount?: boolean; isAggregatedBalanceLoading?: boolean; @@ -105,6 +108,7 @@ interface SetupOptions { const setupDefaultMocks = ({ currentStep = 0, + isCardholder = false, isCardAuthenticated = false, isCardLinkedToMoneyAccount = false, isAggregatedBalanceLoading = false, @@ -126,7 +130,9 @@ const setupDefaultMocks = ({ startLinkFlow: mockStartLinkFlow, isCardAuthenticated, isCardLinkedToMoneyAccount, + isLinking: false, }); + mockIsCardholder.mockReturnValue(isCardholder); }; describe('MoneyOnboardingCard', () => { @@ -278,7 +284,7 @@ describe('MoneyOnboardingCard', () => { screen: CardScreens.MONEY_HOME, entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, action: CardActions.MONEY_ACCOUNT_ONBOARDING_CARD_PRIMARY_BUTTON, - card_state: 'no_card', + card_state: 'non_cardholder', }); }); @@ -301,7 +307,7 @@ describe('MoneyOnboardingCard', () => { screen: CardScreens.MONEY_HOME, entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, action: CardActions.MONEY_ACCOUNT_ONBOARDING_CARD_SKIP_BUTTON, - card_state: 'no_card', + card_state: 'non_cardholder', }); }); @@ -316,7 +322,7 @@ describe('MoneyOnboardingCard', () => { expect(mockAddProperties).toHaveBeenCalledWith({ screen: CardScreens.MONEY_HOME, entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, - card_state: 'no_card', + card_state: 'non_cardholder', }); }); @@ -388,6 +394,7 @@ describe('MoneyOnboardingCard', () => { it('renders the unlinked-card title', () => { setupDefaultMocks({ currentStep: 1, + isCardholder: true, isCardAuthenticated: true, isCardLinkedToMoneyAccount: false, }); @@ -402,6 +409,7 @@ describe('MoneyOnboardingCard', () => { it('renders the unlinked-card description', () => { setupDefaultMocks({ currentStep: 1, + isCardholder: true, isCardAuthenticated: true, isCardLinkedToMoneyAccount: false, }); @@ -418,6 +426,7 @@ describe('MoneyOnboardingCard', () => { it('renders the Link card primary CTA', () => { setupDefaultMocks({ currentStep: 1, + isCardholder: true, isCardAuthenticated: true, isCardLinkedToMoneyAccount: false, }); @@ -432,6 +441,7 @@ describe('MoneyOnboardingCard', () => { it('calls startLinkFlow with Money home origin when Link card CTA is pressed', () => { setupDefaultMocks({ currentStep: 1, + isCardholder: true, isCardAuthenticated: true, isCardLinkedToMoneyAccount: false, }); @@ -453,6 +463,7 @@ describe('MoneyOnboardingCard', () => { it('calls incrementStep when Skip CTA is pressed', () => { setupDefaultMocks({ currentStep: 1, + isCardholder: true, isCardAuthenticated: true, isCardLinkedToMoneyAccount: false, }); @@ -477,6 +488,59 @@ describe('MoneyOnboardingCard', () => { }); }); + describe('step 2 — cardholder not authenticated', () => { + it('renders the unlinked-card title when user is a cardholder but not authenticated', () => { + setupDefaultMocks({ + currentStep: 1, + isCardholder: true, + isCardAuthenticated: false, + isCardLinkedToMoneyAccount: false, + }); + + const { getByTestId } = render(); + + expect(getByTestId('money-onboarding-card-title')).toHaveTextContent( + strings('money.onboarding.step_2.unlinked_card_account.title'), + ); + }); + + it('renders the Link card primary CTA when user is a cardholder but not authenticated', () => { + setupDefaultMocks({ + currentStep: 1, + isCardholder: true, + isCardAuthenticated: false, + isCardLinkedToMoneyAccount: false, + }); + + const { getByTestId } = render(); + + expect(getByTestId('money-onboarding-card-cta-button')).toHaveTextContent( + strings('money.onboarding.step_2.unlinked_card_account.cta_primary'), + ); + }); + + it('calls trackOnboardingEvent with LINK_CARD when cardholder (not authenticated) presses the CTA', () => { + setupDefaultMocks({ + currentStep: 1, + isCardholder: true, + isCardAuthenticated: false, + isCardLinkedToMoneyAccount: false, + }); + + const { getByTestId } = render(); + fireEvent.press(getByTestId('money-onboarding-card-cta-button')); + + expect(mockTrackOnboardingEvent).toHaveBeenCalledWith( + expect.objectContaining({ + step: 2, + step_action: MONEY_ONBOARDING_STEP_ACTIONS.LINK_CARD, + redirect_target: BOTTOM_SHEET_NAMES.CARD_LINK_SHEET, + total_steps: MONEY_ONBOARDING_TOTAL_STEPS, + }), + ); + }); + }); + describe('step 2 — cardholder with linked card (auto-skip)', () => { it('calls incrementStep on mount when card is authenticated and linked', () => { setupDefaultMocks({ diff --git a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx index 032b7d23740..172680482de 100644 --- a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx +++ b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx @@ -31,7 +31,6 @@ import { SCREEN_NAMES, BOTTOM_SHEET_NAMES, } from '../../constants/moneyEvents'; - // REMINDER: Must be updated when the number of steps is changed. export const MONEY_ONBOARDING_TOTAL_STEPS = 2; @@ -76,6 +75,9 @@ const MoneyOnboardingCard = () => { isCardLinkedToMoneyAccount, }); + const shouldShowLinkCardAction = + isCardholder || (isCardAuthenticated && !isCardLinkedToMoneyAccount); + const handleRedirectToCryptoDeposit = useCallback(async () => { await initiateDeposit().catch(() => undefined); }, [initiateDeposit]); @@ -88,7 +90,7 @@ const MoneyOnboardingCard = () => { total_steps: MONEY_ONBOARDING_TOTAL_STEPS, } as const; - if (isCardAuthenticated && !isCardLinkedToMoneyAccount) { + if (shouldShowLinkCardAction) { trackOnboardingEvent({ ...baseProperties, step_action: MONEY_ONBOARDING_STEP_ACTIONS.LINK_CARD, @@ -117,8 +119,7 @@ const MoneyOnboardingCard = () => { }, [ currentStep, - isCardAuthenticated, - isCardLinkedToMoneyAccount, + shouldShowLinkCardAction, startLinkFlow, trackOnboardingEvent, trackEvent, @@ -252,84 +253,76 @@ const MoneyOnboardingCard = () => { image: moneyOnboardingStepperStep1, }; - // Case 1: Has MetaMask card but not linked to Money account yet. - const step2: StepperCardStep = - isCardAuthenticated && !isCardLinkedToMoneyAccount - ? { - title: strings( - 'money.onboarding.step_2.unlinked_card_account.title', + // Case 1: Cardholder, or authenticated with a card not yet linked. + const step2: StepperCardStep = shouldShowLinkCardAction + ? { + title: strings('money.onboarding.step_2.unlinked_card_account.title'), + description: strings( + 'money.onboarding.step_2.unlinked_card_account.description', + ), + primaryCta: { + text: strings( + 'money.onboarding.step_2.unlinked_card_account.cta_primary', ), - description: strings( - 'money.onboarding.step_2.unlinked_card_account.description', - ), - primaryCta: { - text: strings( - 'money.onboarding.step_2.unlinked_card_account.cta_primary', + onPress: () => + handleCardCtaPress( + strings('money.onboarding.step_2.unlinked_card_account.title', { + locale: 'en', + }), ), - onPress: () => - handleCardCtaPress( - strings( - 'money.onboarding.step_2.unlinked_card_account.title', - { - locale: 'en', - }, - ), - ), - disabled: isLinking, - }, - secondaryCta: { - text: strings( - 'money.onboarding.step_2.unlinked_card_account.cta_secondary', + disabled: isLinking, + }, + secondaryCta: { + text: strings( + 'money.onboarding.step_2.unlinked_card_account.cta_secondary', + ), + onPress: () => + handleSkipPress( + strings('money.onboarding.step_2.unlinked_card_account.title', { + locale: 'en', + }), ), - onPress: () => - handleSkipPress( - strings( - 'money.onboarding.step_2.unlinked_card_account.title', - { locale: 'en' }, - ), - ), - }, - image: moneyOnboardingStepperStep2, - } - : // No MetaMask card yet. - { - title: strings('money.onboarding.step_2.no_card_account.title'), - description: strings( - 'money.onboarding.step_2.no_card_account.description', + }, + image: moneyOnboardingStepperStep2, + } + : // No MetaMask card yet. + { + title: strings('money.onboarding.step_2.no_card_account.title'), + description: strings( + 'money.onboarding.step_2.no_card_account.description', + ), + primaryCta: { + text: strings( + 'money.onboarding.step_2.no_card_account.cta_primary', ), - primaryCta: { - text: strings( - 'money.onboarding.step_2.no_card_account.cta_primary', + onPress: () => + handleCardCtaPress( + strings('money.onboarding.step_2.no_card_account.title', { + locale: 'en', + }), ), - onPress: () => - handleCardCtaPress( - strings('money.onboarding.step_2.no_card_account.title', { - locale: 'en', - }), - ), - disabled: isLinking, - }, - secondaryCta: { - text: strings( - 'money.onboarding.step_2.no_card_account.cta_secondary', + disabled: isLinking, + }, + secondaryCta: { + text: strings( + 'money.onboarding.step_2.no_card_account.cta_secondary', + ), + onPress: () => + handleSkipPress( + strings('money.onboarding.step_2.no_card_account.title', { + locale: 'en', + }), ), - onPress: () => - handleSkipPress( - strings('money.onboarding.step_2.no_card_account.title', { - locale: 'en', - }), - ), - }, - image: moneyOnboardingStepperStep2, - }; + }, + image: moneyOnboardingStepperStep2, + }; return [step1, step2]; }, [ isOnboardingCardVisible, isVisibleAfterAutoSkip, handleStep1CtaPressed, - isCardAuthenticated, - isCardLinkedToMoneyAccount, + shouldShowLinkCardAction, isLinking, handleCardCtaPress, handleSkipPress, diff --git a/app/components/UI/Perps/Perps.testIds.ts b/app/components/UI/Perps/Perps.testIds.ts index 7310ee51f17..53ae56bd57b 100644 --- a/app/components/UI/Perps/Perps.testIds.ts +++ b/app/components/UI/Perps/Perps.testIds.ts @@ -142,13 +142,30 @@ export const PerpsMarketListViewSelectorsIDs = { BACK_BUTTON: 'perps-market-list-back-button', SEARCH_CLEAR_BUTTON: 'perps-market-list-search-bar-clear', SEARCH_BAR: 'perps-market-list-search-bar', + NO_RESULTS: 'perps-market-list-no-results', SKELETON_ROW: 'perps-market-list-skeleton-row', LIST_HEADER: 'perps-market-list-header', MARKET_LIST: 'perps-market-list', SORT_FILTERS: 'perps-market-list-sort-filters', WATCHLIST_TOGGLE: 'perps-market-list-watchlist-toggle', + /** Star badge in the category row that filters to watchlisted markets */ + WATCHLIST_FILTER_BADGE: 'perps-market-list-sort-filters-categories-watchlist', }; +// ======================================== +// PERPS WATCHLIST SECTION SELECTORS +// ======================================== + +export const PerpsWatchlistSelectorsIDs = { + SECTION: 'perps-watchlist-section', + HEADER: 'perps-watchlist-header', + EMPTY_STATE: 'perps-watchlist-empty-state', + SHOW_MORE_BUTTON: 'perps-watchlist-show-more-button', + SHOW_LESS_BUTTON: 'perps-watchlist-show-less-button', + SUGGESTED_SECTION: 'perps-watchlist-suggested-section', + SUGGESTED_HEADER: 'perps-watchlist-suggested-header', +} as const; + // ======================================== // PERPS MARKET ROW ITEM SELECTORS // ======================================== @@ -165,6 +182,8 @@ export const getPerpsMarketRowItemSelector = { `${PerpsMarketRowItemSelectorsIDs.ROW_ITEM}-${symbol}-token-logo`, badge: (symbol: string) => `${PerpsMarketRowItemSelectorsIDs.ROW_ITEM}-${symbol}-badge`, + addButton: (symbol: string) => + `${PerpsMarketRowItemSelectorsIDs.ROW_ITEM}-${symbol}-add-button`, }; // ======================================== @@ -218,6 +237,10 @@ export const PerpsHomeViewSelectorsIDs = { WITHDRAW_BUTTON: 'perps-home-withdraw-button', ADD_FUNDS_BUTTON: 'perps-home-add-funds-button', POSITIONS_PNL_VALUE: 'perps-home-positions-pnl-value', + /** Per-position card; suffixed with the list index, e.g. `perps-home-position-card-0` */ + POSITION_CARD: 'perps-home-position-card', + /** Per-order card; suffixed with the list index, e.g. `perps-home-order-card-0` */ + ORDER_CARD: 'perps-home-order-card', SERVICE_INTERRUPTION_BANNER: 'perps-service-interruption-banner', COMPETITION_BANNER: 'perps-home-competition-banner', PRODUCTS_SECTION: 'perps-products', @@ -233,6 +256,13 @@ export const PerpsHomeViewSelectorsIDs = { TAB_BAR_ACTIVITY: 'tab-bar-item-activity', }; +export const PerpsTabViewSelectorsIDs = { + START_NEW_TRADE_CTA: 'perps-tab-view-start-new-trade-cta', + GEO_BLOCK_BOTTOM_SHEET_TOOLTIP: + 'perps-tab-view-geo-block-bottom-sheet-tooltip', + SCROLL_VIEW: 'perps-tab-scroll-view', +} as const; + export const PerpsPositionsViewSelectorsIDs = { REFRESH_CONTROL: 'refresh-control', BACK_BUTTON: 'back-button', @@ -389,6 +419,7 @@ export const PerpsMarketHeaderSelectorsIDs = { PRICE_CHANGE_TITLE_SECTION: 'perps-market-header-price-change-title-section', MORE_BUTTON: 'perps-market-header-more-button', FAVORITE_BUTTON: 'perps-market-header-favorite-button', + CATEGORY_SEARCH_BUTTON: 'perps-market-header-category-search-button', }; // ======================================== diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx index f193a83195a..74fa281c1d5 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx @@ -243,6 +243,7 @@ const PerpsHomeView = ({ positions, orders, watchlistMarkets, + suggestedWatchlistMarkets, perpsMarkets, // Crypto markets (renamed from trendingMarkets) commoditiesMarkets, // Commodity markets stocksMarkets, // Equity markets only @@ -612,6 +613,7 @@ const PerpsHomeView = ({ showsVerticalScrollIndicator={false} onScroll={perpsScrollHandler} scrollEventThrottle={16} + testID={PerpsHomeViewSelectorsIDs.SCROLL_CONTENT} > @@ -705,6 +707,7 @@ const PerpsHomeView = ({ key={`${position.symbol}-${index}`} position={position} source={PERPS_EVENT_VALUE.SOURCE.PERPS_HOME} + testID={`${PerpsHomeViewSelectorsIDs.POSITION_CARD}-${index}`} /> ))} @@ -720,11 +723,12 @@ const PerpsHomeView = ({ renderSkeleton={() => } > - {orders.map((order) => ( + {orders.map((order, index) => ( ))} @@ -740,11 +744,21 @@ const PerpsHomeView = ({ {/* Watchlist Section */} 0 + ? () => + perpsNavigation.navigateToMarketList({ + showWatchlistOnly: true, + source: PERPS_EVENT_VALUE.SOURCE.PERPS_HOME, + }) + : undefined + } /> {/* Products Section - Category pills grid */} diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index c8fa81424c5..05557f94195 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -147,6 +147,7 @@ const mockNavigateToHome = jest.fn(); const mockNavigateToActivity = jest.fn(); const mockNavigateToOrder = jest.fn(); const mockNavigateToTutorial = jest.fn(); +const mockNavigateToMarketList = jest.fn(); const mockNavigateBack = jest.fn(); // Mock notification feature flag @@ -522,6 +523,7 @@ jest.mock('../../hooks', () => ({ navigateToActivity: mockNavigateToActivity, navigateToOrder: mockNavigateToOrder, navigateToTutorial: mockNavigateToTutorial, + navigateToMarketList: mockNavigateToMarketList, navigateBack: mockNavigateBack, canGoBack: mockCanGoBack(), })), @@ -2598,6 +2600,29 @@ describe('PerpsMarketDetailsView', () => { }); }); + describe('Category search shortcut', () => { + it('navigates to market list with crypto category when search button is pressed', () => { + const { getByTestId } = renderWithProvider( + + + , + { + state: initialState, + }, + ); + + const searchButton = getByTestId( + 'perps-market-header-category-search-button', + ); + fireEvent.press(searchButton); + + expect(mockNavigateToMarketList).toHaveBeenCalledWith({ + source: 'magnifying_glass', + defaultMarketTypeFilter: 'crypto', + }); + }); + }); + describe('Position opened timestamp', () => { // Note: The timestamp calculation logic has been moved to useHasExistingPosition hook // These tests verify the component correctly uses the hook's positionOpenedTimestamp diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 25cfa4ede4c..e2d0e5f7a14 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -31,6 +31,7 @@ import { PERPS_EVENT_VALUE, PERPS_CONSTANTS, getPerpsDisplaySymbol, + getMarketTypeFilter, type Position, type PerpsMarketData, type TPSLTrackingData, @@ -115,6 +116,8 @@ import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { usePerpsOICap } from '../../hooks/usePerpsOICap'; import { usePerpsTPSLUpdate } from '../../hooks/usePerpsTPSLUpdate'; import { useStopLossPrompt } from '../../hooks/useStopLossPrompt'; +import usePerpsToasts from '../../hooks/usePerpsToasts'; +import { WATCHLIST_LIMIT } from '../../utils/marketUtils'; import { selectPerpsChartPreferredCandlePeriod } from '../../selectors/chartPreferences'; import { selectPerpsButtonColorTestVariant, @@ -161,6 +164,7 @@ const PerpsMarketDetailsView: React.FC = () => { // Use centralized navigation hook for all Perps navigation const { navigateToHome, + navigateToMarketList, navigateToOrder, navigateToTutorial, navigateToClosePosition, @@ -198,6 +202,7 @@ const PerpsMarketDetailsView: React.FC = () => { transactionActiveAbTests, } = route.params || {}; const { track } = usePerpsEventTracking(); + const { showToast, PerpsToastOptions } = usePerpsToasts(); // Get full market data from stream to ensure all fields (including maxLeverage) are available // This handles cases where navigation passes minimal market data (e.g., from Recent Activity) @@ -672,12 +677,23 @@ const PerpsMarketDetailsView: React.FC = () => { const handleWatchlistPress = useCallback(() => { if (!market?.symbol) return; + const controller = Engine.context.PerpsController; + const isAdding = !isWatchlist; + + // Guard: block adding when the watchlist is already full + if ( + isAdding && + controller.getWatchlistMarkets().length >= WATCHLIST_LIMIT + ) { + showToast(PerpsToastOptions.watchlist.limitReached); + return; + } + // Optimistic update - instant UI feedback - const newWatchlistState = !isWatchlist; + const newWatchlistState = isAdding; setOptimisticWatchlist(newWatchlistState); // Actual state update - const controller = Engine.context.PerpsController; controller.toggleWatchlistMarket(market.symbol); // Track watchlist toggle event @@ -693,7 +709,29 @@ const PerpsMarketDetailsView: React.FC = () => { [PERPS_EVENT_PROPERTY.SOURCE]: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, [PERPS_EVENT_PROPERTY.FAVORITES_COUNT]: watchlistCount, }); - }, [market, isWatchlist, track]); + }, [market, isWatchlist, track, showToast, PerpsToastOptions]); + + const handleCategorySearchPress = useCallback(() => { + if (!market) return; + + const resolvedCategory = getMarketTypeFilter(market); + + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.BUTTON_CLICKED, + [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: + PERPS_EVENT_VALUE.BUTTON_CLICKED.MAGNIFYING_GLASS, + [PERPS_EVENT_PROPERTY.BUTTON_LOCATION]: + PERPS_EVENT_VALUE.BUTTON_LOCATION.PERP_MARKET_DETAILS, + [PERPS_EVENT_PROPERTY.ASSET]: market.symbol, + [PERPS_EVENT_PROPERTY.MARKET_CATEGORY]: resolvedCategory, + }); + + navigateToMarketList({ + source: PERPS_EVENT_VALUE.SOURCE.MAGNIFYING_GLASS, + defaultMarketTypeFilter: resolvedCategory, + }); + }, [market, track, navigateToMarketList]); const handleTradeAction = useCallback( (direction: 'long' | 'short') => @@ -1249,6 +1287,12 @@ const PerpsMarketDetailsView: React.FC = () => { onPress: handleWatchlistPress, testID: PerpsMarketHeaderSelectorsIDs.FAVORITE_BUTTON, }, + { + iconName: IconName.Search, + onPress: handleCategorySearchPress, + testID: PerpsMarketHeaderSelectorsIDs.CATEGORY_SEARCH_BUTTON, + accessibilityLabel: strings('perps.market_details.category_search'), + }, ]} testID={PerpsMarketDetailsViewSelectorsIDs.HEADER} /> diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index 7db107beaa1..4fc32501347 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -16,6 +16,7 @@ import PerpsMarketBalanceActions from '../../components/PerpsMarketBalanceAction import PerpsMarketSortFieldBottomSheet from '../../components/PerpsMarketSortFieldBottomSheet'; import PerpsMarketFiltersBar from './components/PerpsMarketFiltersBar'; import PerpsMarketList from '../../components/PerpsMarketList'; +import PerpsWatchlistMarkets from '../../components/PerpsWatchlistMarkets/PerpsWatchlistMarkets'; import { usePerpsMarketListView, usePerpsMeasurement, @@ -92,7 +93,13 @@ const PerpsMarketListView = ({ const { selectedOptionId, sortBy, direction, handleOptionChange } = sortState; // Destructure favorites state for easier access - const { showFavoritesOnly } = favoritesState; + const { + showFavoritesOnly, + setShowFavoritesOnly, + hasWatchlistMarkets, + watchlistMarketObjects, + suggestedMarkets, + } = favoritesState; // Destructure market type filter state const { marketTypeFilter, setMarketTypeFilter } = marketTypeFilterState; @@ -115,7 +122,7 @@ const PerpsMarketListView = ({ const { track } = usePerpsEventTracking(); - // Handle category badge selection + // Handle category badge selection — clears watchlist filter (mutual exclusivity) const handleCategorySelect = useCallback( (category: MarketTypeFilter) => { if (category !== 'all') { @@ -128,10 +135,23 @@ const PerpsMarketListView = ({ }); } setMarketTypeFilter(category); + // Deactivate the watchlist filter whenever a category badge is activated + if (category !== 'all') { + setShowFavoritesOnly(false); + } }, - [setMarketTypeFilter, track], + [setMarketTypeFilter, setShowFavoritesOnly, track], ); + // Toggle watchlist-only filter — clears category filter (mutual exclusivity) + const handleWatchlistToggle = useCallback(() => { + const willActivate = !showFavoritesOnly; + setShowFavoritesOnly(willActivate); + if (willActivate) { + setMarketTypeFilter('all'); + } + }, [showFavoritesOnly, setShowFavoritesOnly, setMarketTypeFilter]); + useEffect(() => { if (filteredMarkets.length > 0) { Animated.timing(fadeAnimation, { @@ -221,38 +241,24 @@ const PerpsMarketListView = ({ ); } - // Empty favorites results - show when favorites filter is active but no favorites found - if (showFavoritesOnly && filteredMarkets.length === 0) { + // Empty watchlist — show suggested markets with the same default state as PerpsHome + if (showFavoritesOnly && !hasWatchlistMarkets) { return ( - - - - {strings('perps.no_favorites_found')} - - - {strings('perps.no_favorites_description')} - - + ); } // Empty search results - show when user has typed and no markets match if (searchQuery.trim() && filteredMarkets.length === 0) { return ( - + setIsSortFieldSheetVisible(true)} marketTypeFilter={marketTypeFilter} onCategorySelect={handleCategorySelect} + showWatchlistBadge + isWatchlistSelected={showFavoritesOnly} + onWatchlistToggle={handleWatchlistToggle} testID={PerpsMarketListViewSelectorsIDs.SORT_FILTERS} /> )} diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.view.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.view.test.tsx index 274da1719aa..a7891b79a9a 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.view.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.view.test.tsx @@ -94,16 +94,14 @@ describe('PerpsMarketListView', () => { }); }); - it('shows empty favorites state when view starts in watchlist-only mode with no favorites', async () => { + it('shows empty watchlist state when view starts in watchlist-only mode with no favorites', async () => { renderPerpsMarketListView({ initialParams: { showWatchlistOnly: true }, + streamOverrides: { marketData: marketDataWithCategories }, }); expect( - await screen.findByText(strings('perps.no_favorites_found')), - ).toBeOnTheScreen(); - expect( - screen.getByText(strings('perps.no_favorites_description')), + await screen.findByText(strings('perps.watchlist.empty_subtitle')), ).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.tsx index abaa8261268..f5de93c7464 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.tsx @@ -28,27 +28,35 @@ const PerpsMarketFiltersBar: React.FC = ({ onSortPress, marketTypeFilter, onCategorySelect, + showWatchlistBadge, + isWatchlistSelected, + onWatchlistToggle, testID, }) => { const { styles } = useStyles(styleSheet, {}); return ( - {/* Row 1: Category Badges */} + {/* Row 1: Category Badges (+ optional watchlist star badge) */} - {/* Row 2: Sort Dropdown */} - - - + {/* Row 2: Sort Dropdown — hidden when watchlist filter is active */} + {!isWatchlistSelected && ( + + + + )} ); }; diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.types.ts b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.types.ts index fdef3843a9f..e71c2510c5c 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.types.ts +++ b/app/components/UI/Perps/Views/PerpsMarketListView/components/PerpsMarketFiltersBar/PerpsMarketFiltersBar.types.ts @@ -27,6 +27,21 @@ export interface PerpsMarketFiltersBarProps { */ onCategorySelect: (category: MarketTypeFilter) => void; + /** + * Whether to show the watchlist (star) filter badge. + */ + showWatchlistBadge?: boolean; + + /** + * Whether the watchlist filter badge is currently active + */ + isWatchlistSelected?: boolean; + + /** + * Callback when the watchlist badge is pressed + */ + onWatchlistToggle?: () => void; + /** * Optional test ID for testing */ diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.styles.ts b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.styles.ts new file mode 100644 index 00000000000..6ff9584cc7e --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.styles.ts @@ -0,0 +1,189 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + tradeInfoContainer: { + paddingBottom: 30, + }, + emptyStateContainer: { + paddingBottom: 30, + }, + wrapper: { + flex: 1, + backgroundColor: colors.background.default, + }, + content: { + flex: 1, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + paddingTop: 16, + }, + sectionTitle: { + // Removed paddingTop - now on parent sectionHeader for consistent alignment + }, + emptyContainer: { + padding: 24, + alignItems: 'center', + }, + emptyText: { + textAlign: 'center', + marginTop: 8, + }, + loadingContainer: { + padding: 24, + alignItems: 'center', + marginTop: 10, + borderRadius: 12, + }, + // Order card styles to match position cards + positionCard: { + paddingVertical: 12, + paddingHorizontal: 16, + marginVertical: 2, + }, + positionCardContent: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + positionLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + assetIcon: { + width: 40, + height: 40, + borderRadius: 20, + marginRight: 12, + }, + positionInfo: { + flex: 1, + }, + positionRight: { + alignItems: 'flex-end', + }, + startTradeCTA: { + paddingVertical: 12, + marginVertical: 2, + borderRadius: 8, + }, + startTradeContent: { + flexDirection: 'row', + alignItems: 'center', + }, + startTradeIconContainer: { + width: 40, + height: 40, + borderWidth: 0, + borderRadius: 20, + backgroundColor: colors.background.muted, + justifyContent: 'center', + alignItems: 'center', + }, + startTradeText: { + marginLeft: 12, + flex: 1, + }, + // Watchlist section — style overrides passed to the shared PerpsWatchlistMarkets component. + // These reset the default border/spacing from PerpsWatchlistMarkets.styles so the section + // blends into the PerpsTabView empty-state card without a top divider. + watchlistSection: { + marginBottom: 0, + borderTopWidth: 0, + paddingTop: 0, + }, + watchlistHeaderStyleNoBalance: { + paddingTop: 16, + paddingBottom: 4, + marginBottom: 0, + }, + watchlistHeaderStyleWithBalance: { + paddingTop: 24, + paddingBottom: 4, + marginBottom: 0, + }, + // Custom explore section styles - isolated from shared components + exploreSection: { + marginBottom: 0, + }, + // Explore header: at top, no balance - 16px/4px + exploreSectionHeaderNoBalance: { + paddingTop: 16, + paddingBottom: 4, + marginBottom: 0, + }, + // Explore header: at top, with balance - 24px/4px + exploreSectionHeaderWithBalance: { + paddingTop: 24, + paddingBottom: 4, + marginBottom: 0, + }, + // Explore header: below watchlist - 20px/8px + exploreSectionHeaderBelowWatchlist: { + paddingTop: 20, + paddingBottom: 8, + marginBottom: 0, + }, + exploreMarketRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 16, + }, + exploreMarketLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + exploreMarketIcon: { + marginRight: 12, + }, + exploreMarketInfo: { + flex: 1, + }, + exploreMarketHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + exploreMarketSecondRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginTop: 2, + }, + exploreMarketRight: { + alignItems: 'flex-end', + flex: 1, + }, + exploreMarketPrice: { + marginBottom: 2, + }, + exploreMarketChange: { + marginTop: 2, + }, + // "See all perps" button at bottom of explore section + seeAllButton: { + backgroundColor: colors.background.muted, + borderRadius: 12, + height: 48, + paddingHorizontal: 16, + alignItems: 'center', + justifyContent: 'center', + marginTop: 12, + marginBottom: 12, + marginHorizontal: 16, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx new file mode 100644 index 00000000000..c434af30c37 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx @@ -0,0 +1,744 @@ +// Mock theme utility FIRST to ensure proper hoisting +jest.mock('../../../../../util/theme', () => ({ + useAssetFromTheme: jest.fn(() => 123), // Return a simple number like import() would +})); + +import { useNavigation } from '@react-navigation/native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import Routes from '../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../locales/i18n'; +import { + PERPS_EVENT_VALUE, + type Position, + type PerpsMarketData, +} from '@metamask/perps-controller'; +import PerpsTabView from './PerpsTabView'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); + +// Mock useStyles hook +jest.mock('../../../../../component-library/hooks', () => ({ + useStyles: () => ({ + styles: {}, + theme: {}, + }), +})); + +// Mock the selector module first +jest.mock('../../selectors/perpsController', () => ({ + selectPerpsEligibility: jest.fn(), +})); + +// Mock Redux +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +// Mock the multichain selector +jest.mock('../../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(() => () => ({ + address: '0x1234567890123456789012345678901234567890', + id: 'mock-account-id', + type: 'eip155:eoa', + })), +})); + +// Mock homepage redesign selector +jest.mock( + '../../../../../selectors/featureFlagController/homepage', + () => ({}), +); + +// Mock PerpsConnectionProvider +jest.mock('../../providers/PerpsConnectionProvider', () => ({ + PerpsConnectionProvider: ({ children }: { children: React.ReactNode }) => + children, + usePerpsConnection: jest.fn(() => ({ + isConnected: true, + isInitialized: true, + isConnecting: false, + error: null, + connect: jest.fn(), + disconnect: jest.fn(), + resetError: jest.fn(), + })), +})); + +// Mock PerpsStreamProvider +jest.mock('../../providers/PerpsStreamManager', () => ({ + PerpsStreamProvider: ({ children }: { children: React.ReactNode }) => + children, + usePerpsStream: jest.fn(() => ({ + prices: { + subscribe: jest.fn(() => jest.fn()), + getSnapshot: jest.fn(() => null), + }, + positions: { + subscribe: jest.fn(() => jest.fn()), + getSnapshot: jest.fn(() => null), + }, + orders: { + subscribe: jest.fn(() => jest.fn()), + getSnapshot: jest.fn(() => null), + }, + account: { + subscribe: jest.fn(() => jest.fn()), + getSnapshot: jest.fn(() => null), + }, + marketData: { + subscribe: jest.fn(() => jest.fn()), + getSnapshot: jest.fn(() => null), + }, + })), +})); + +// Mock hooks +jest.mock('../../hooks', () => ({ + usePerpsConnection: jest.fn(), + usePerpsTrading: jest.fn(), + usePerpsFirstTimeUser: jest.fn(), + usePerpsAccount: jest.fn(), + usePerpsEventTracking: jest.fn(() => ({ + track: jest.fn(), + })), + usePerpsLivePositions: jest.fn(() => ({ + positions: [], + isInitialLoading: false, + })), + usePerpsTabExploreData: jest.fn(() => ({ + exploreMarkets: [], + watchlistMarkets: [], + suggestedWatchlistMarkets: [], + isLoading: false, + hasWatchlistSymbols: false, + })), +})); +jest.mock('../../hooks/stream', () => ({ + usePerpsLiveOrders: jest.fn(() => ({ orders: [] })), + usePerpsLiveAccount: jest.fn(() => ({ + account: { + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '0.00', + unrealizedPnl: '0.00', + returnOnEquity: '0.00', + totalBalance: '1000.00', + }, + isInitialLoading: false, + })), + usePerpsLivePrices: jest.fn(() => ({})), +})); + +// Mock formatUtils +jest.mock('../../utils/formatUtils', () => ({ + ...jest.requireActual('../../utils/formatUtils'), + formatPrice: jest.fn((value) => `$${value}`), + formatPnl: jest.fn((value) => `${value >= 0 ? '+' : ''}$${Math.abs(value)}`), +})); + +// Mock asset metadata hook +jest.mock('../../hooks/usePerpsAssetsMetadata', () => ({ + usePerpsAssetMetadata: jest.fn(() => ({ + assetUrl: 'https://example.com/eth.png', + })), +})); + +// Mock RemoteImage +jest.mock('../../../../Base/RemoteImage', () => jest.fn(() => null)); + +// Mock usePerpsTabExploreData from its direct import path (PerpsTabView imports it directly) +jest.mock('../../hooks/usePerpsTabExploreData', () => ({ + usePerpsTabExploreData: jest.fn(() => ({ + exploreMarkets: [], + watchlistMarkets: [], + suggestedWatchlistMarkets: [], + isLoading: false, + hasWatchlistSymbols: false, + })), +})); + +// Mock selectors +jest.mock('../../Perps.testIds', () => ({ + PerpsTabViewSelectorsIDs: { + START_NEW_TRADE_CTA: 'perps-tab-view-start-new-trade-cta', + SCROLL_VIEW: 'perps-tab-scroll-view', + }, + PerpsPositionsViewSelectorsIDs: { + POSITIONS_SECTION_TITLE: 'perps-positions-section-title', + POSITION_ITEM: 'perps-positions-item', + }, + getPerpsMarketRowItemSelector: { + rowItem: (symbol: string) => `perps-market-row-${symbol}`, + tokenLogo: (symbol: string) => `perps-market-logo-${symbol}`, + badge: (symbol: string) => `perps-market-badge-${symbol}`, + }, +})); + +// Import after mock to use the mocked values +const { PerpsTabViewSelectorsIDs } = jest.requireMock('../../Perps.testIds'); + +jest.mock('../../components/PerpsBottomSheetTooltip', () => ({ + __esModule: true, + default: ({ onClose, testID }: { onClose: () => void; testID?: string }) => { + const { TouchableOpacity, Text } = jest.requireActual('react-native'); + return ( + + Geo Block Tooltip + + ); + }, +})); + +// Mock PerpsWatchlistMarkets component +jest.mock( + '../../components/PerpsWatchlistMarkets/PerpsWatchlistMarkets', + () => ({ + __esModule: true, + default: ({ markets }: { markets: unknown[] }) => { + const { View, Text } = jest.requireActual('react-native'); + if (markets.length === 0) return null; + return ( + + Watchlist ({markets.length} markets) + + ); + }, + }), +); + +// Mock PerpsMarketTypeSection component +jest.mock('../../components/PerpsMarketTypeSection', () => ({ + __esModule: true, + default: ({ + title, + markets, + marketType, + }: { + title: string; + markets: unknown[]; + marketType: string; + }) => { + const { View, Text } = jest.requireActual('react-native'); + if (markets.length === 0) return null; + return ( + + + {title} ({markets.length} markets, type: {marketType}) + + + ); + }, +})); + +describe('PerpsTabView', () => { + const mockNavigation = { + navigate: jest.fn(), + }; + + const mockUsePerpsConnection = + jest.requireMock('../../hooks').usePerpsConnection; + const mockUsePerpsLivePositions = + jest.requireMock('../../hooks').usePerpsLivePositions; + const mockUsePerpsLiveOrders = + jest.requireMock('../../hooks/stream').usePerpsLiveOrders; + const mockUsePerpsTrading = jest.requireMock('../../hooks').usePerpsTrading; + const mockUsePerpsFirstTimeUser = + jest.requireMock('../../hooks').usePerpsFirstTimeUser; + const mockUsePerpsAccount = jest.requireMock('../../hooks').usePerpsAccount; + + // Mock selectors + const mockSelectPerpsEligibility = jest.requireMock( + '../../selectors/perpsController', + ).selectPerpsEligibility; + const mockSelectSelectedInternalAccountByScope = jest.requireMock( + '../../../../../selectors/multichainAccounts/accounts', + ).selectSelectedInternalAccountByScope; + + const mockPosition: Position = { + symbol: 'ETH', + size: '2.5', + entryPrice: '2000.00', + positionValue: '5000.00', + unrealizedPnl: '250.00', + marginUsed: '500.00', + leverage: { + type: 'isolated', + value: 10, + }, + liquidationPrice: '1800.00', + maxLeverage: 20, + returnOnEquity: '12.5', + cumulativeFunding: { + allTime: '10.00', + sinceOpen: '5.00', + sinceChange: '2.00', + }, + takeProfitCount: 0, + stopLossCount: 0, + }; + + const mockMarket: PerpsMarketData = { + symbol: 'ETH', + name: 'Ethereum', + price: '$2,000.00', + change24h: '+$50.00', + change24hPercent: '+2.5%', + volume: '$1.5B', + maxLeverage: '50x', + marketType: 'crypto', + }; + + const mockMarketBTC: PerpsMarketData = { + symbol: 'BTC', + name: 'Bitcoin', + price: '$50,000.00', + change24h: '-$500.00', + change24hPercent: '-1.0%', + volume: '$5B', + maxLeverage: '100x', + marketType: 'crypto', + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue(mockNavigation); + + // Default hook mocks + mockUsePerpsConnection.mockReturnValue({ + isConnected: true, + isInitialized: true, + error: null, + connect: jest.fn(), + resetError: jest.fn(), + }); + + mockUsePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: false, + }); + + mockUsePerpsLiveOrders.mockReturnValue({ orders: [] }); + + mockUsePerpsTrading.mockReturnValue({ + getAccountState: jest.fn(), + }); + + mockUsePerpsFirstTimeUser.mockReturnValue({ + isFirstTimeUser: false, + isLoading: false, + error: null, + refresh: jest.fn(), + }); + + mockUsePerpsAccount.mockReturnValue(null); + + // Setup selector mocks + (useSelector as jest.Mock).mockImplementation((selector: unknown) => { + if (selector === mockSelectPerpsEligibility) { + return true; + } + if (selector === mockSelectSelectedInternalAccountByScope) { + return () => ({ + address: '0x1234567890123456789012345678901234567890', + id: 'mock-account-id', + type: 'eip155:eoa', + }); + } + return undefined; + }); + }); + + describe('Hook Integration', () => { + it('passes TP/SL and reduce-only filtering options to usePerpsLiveOrders', () => { + render(); + + expect(mockUsePerpsLiveOrders).toHaveBeenCalledWith( + expect.objectContaining({ + hideTpSl: true, + hideReduceOnly: true, + throttleMs: 1000, + }), + ); + }); + + it('should use live account data when component mounts', () => { + const mockUsePerpsLiveAccount = + jest.requireMock('../../hooks/stream').usePerpsLiveAccount; + + mockUsePerpsConnection.mockReturnValue({ + isConnected: true, + isInitialized: true, + }); + + render(); + + expect(mockUsePerpsLiveAccount).toHaveBeenCalled(); + }); + + it('should still use live account data even when not connected', () => { + const mockUsePerpsLiveAccount = + jest.requireMock('../../hooks/stream').usePerpsLiveAccount; + + mockUsePerpsConnection.mockReturnValue({ + isConnected: false, + isInitialized: true, + }); + + render(); + + // usePerpsLiveAccount should still be called regardless of connection status + expect(mockUsePerpsLiveAccount).toHaveBeenCalled(); + }); + + it('should still use live account data even when not initialized', () => { + const mockUsePerpsLiveAccount = + jest.requireMock('../../hooks/stream').usePerpsLiveAccount; + + mockUsePerpsConnection.mockReturnValue({ + isConnected: true, + isInitialized: false, + }); + + render(); + + // usePerpsLiveAccount should still be called regardless of initialization status + expect(mockUsePerpsLiveAccount).toHaveBeenCalled(); + }); + }); + + describe('User Interactions', () => { + it('shows explore section when no positions or orders exist', () => { + const mockUsePerpsTabExploreData = jest.requireMock( + '../../hooks/usePerpsTabExploreData', + ).usePerpsTabExploreData; + mockUsePerpsTabExploreData.mockReturnValue({ + exploreMarkets: [mockMarket, mockMarketBTC], + watchlistMarkets: [], + isLoading: false, + }); + + mockUsePerpsFirstTimeUser.mockReturnValue({ + isFirstTimeUser: true, + markTutorialCompleted: jest.fn(), + }); + + render(); + + // Confirm the explore state is rendered (market data should be visible) + expect(screen.getByText('ETH')).toBeOnTheScreen(); + expect(screen.getByText('BTC')).toBeOnTheScreen(); + }); + + it('should render Start a new trade CTA when positions exist', () => { + mockUsePerpsLivePositions.mockReturnValue({ + positions: [mockPosition], + isInitialLoading: false, + }); + + render(); + + const startNewTradeCTA = screen.getByTestId( + 'perps-tab-view-start-new-trade-cta', + ); + expect(startNewTradeCTA).toBeOnTheScreen(); + expect( + screen.getByText(strings('perps.position.list.start_new_trade')), + ).toBeOnTheScreen(); + }); + + it('should navigate to tutorial when Start a new trade CTA is pressed by first-time user', () => { + mockUsePerpsFirstTimeUser.mockReturnValue({ + isFirstTimeUser: true, + markTutorialCompleted: jest.fn(), + }); + + mockUsePerpsLivePositions.mockReturnValue({ + positions: [mockPosition], + isInitialLoading: false, + }); + + render(); + + const startNewTradeCTA = screen.getByTestId( + 'perps-tab-view-start-new-trade-cta', + ); + act(() => { + fireEvent.press(startNewTradeCTA); + }); + + expect(mockNavigation.navigate).toHaveBeenCalledWith( + Routes.PERPS.TUTORIAL, + ); + }); + + it('should navigate to markets when Start a new trade CTA is pressed by returning user', () => { + mockUsePerpsFirstTimeUser.mockReturnValue({ + isFirstTimeUser: false, + markTutorialCompleted: jest.fn(), + }); + + mockUsePerpsLivePositions.mockReturnValue({ + positions: [mockPosition], + isInitialLoading: false, + }); + + render(); + + const startNewTradeCTA = screen.getByTestId( + 'perps-tab-view-start-new-trade-cta', + ); + act(() => { + fireEvent.press(startNewTradeCTA); + }); + + expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.PERPS_HOME, + params: { source: PERPS_EVENT_VALUE.SOURCE.POSITION_TAB }, + }); + }); + + it('should render Start Trade CTA in orders section when there are orders but no positions', () => { + // Given orders exist but no positions + mockUsePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: false, + }); + + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [ + { orderId: '123', symbol: 'ETH', size: '1.0', orderType: 'limit' }, + { orderId: '456', symbol: 'BTC', size: '0.5', orderType: 'market' }, + ], + }); + + // When the view is rendered + render(); + + // Then Start Trade CTA should be present in the orders section + const startNewTradeCTA = screen.getByTestId( + 'perps-tab-view-start-new-trade-cta', + ); + expect(startNewTradeCTA).toBeOnTheScreen(); + expect( + screen.getByText(strings('perps.position.list.start_new_trade')), + ).toBeOnTheScreen(); + + // And orders should be displayed + expect(screen.getByText('Orders')).toBeOnTheScreen(); + }); + + it('should NOT render empty state text when there are no positions and no orders', () => { + // Given no positions and no orders + mockUsePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: false, + }); + + mockUsePerpsLiveOrders.mockReturnValue({ orders: [] }); + + // When the view is rendered + render(); + + // Then empty state text should NOT be present (returns null now) + expect( + screen.queryByText(strings('perps.position.list.empty_title')), + ).not.toBeOnTheScreen(); + expect( + screen.queryByText(strings('perps.position.list.empty_description')), + ).not.toBeOnTheScreen(); + + // And Start Trade CTA should NOT be present + expect( + screen.queryByTestId('perps-tab-view-start-new-trade-cta'), + ).not.toBeOnTheScreen(); + }); + + it('should render Start Trade CTA below positions when positions exist', () => { + // Given positions exist + mockUsePerpsLivePositions.mockReturnValue({ + positions: [mockPosition], + isInitialLoading: false, + }); + + mockUsePerpsLiveOrders.mockReturnValue({ orders: [] }); + + // When the view is rendered + render(); + + // Then Start Trade CTA should be present below positions + const startNewTradeCTA = screen.getByTestId( + 'perps-tab-view-start-new-trade-cta', + ); + expect(startNewTradeCTA).toBeOnTheScreen(); + + // And positions section should be visible + expect(screen.getByText('Positions')).toBeOnTheScreen(); + }); + + it('should NOT show Start Trade CTA in orders section when both orders and positions exist', () => { + // Given both orders and positions exist + mockUsePerpsLivePositions.mockReturnValue({ + positions: [mockPosition], + isInitialLoading: false, + }); + + mockUsePerpsLiveOrders.mockReturnValue({ + orders: [ + { orderId: '123', symbol: 'ETH', size: '1.0', orderType: 'limit' }, + ], + }); + + // When the view is rendered + render(); + + // Then only one Start Trade CTA should be present (in positions section) + const startTradeCTAs = screen.getAllByTestId( + 'perps-tab-view-start-new-trade-cta', + ); + expect(startTradeCTAs).toHaveLength(1); + + // And both sections should be visible + expect(screen.getByText('Orders')).toBeOnTheScreen(); + expect(screen.getByText('Positions')).toBeOnTheScreen(); + }); + + it('should have pull-to-refresh functionality configured', async () => { + const mockLoadPositions = jest.fn(); + mockUsePerpsLivePositions.mockReturnValue({ + positions: [], + isLoading: false, + isRefreshing: false, + loadPositions: mockLoadPositions, + }); + + render(); + + // Verify that the component renders without errors and has the refresh capability + expect(screen.toJSON()).toBeTruthy(); + expect(mockLoadPositions).toHaveBeenCalledTimes(0); // Should not be called on render + }); + }); + + describe('State Management', () => { + it('should handle refresh state correctly', () => { + // Arrange - Mock refreshing state with no positions or orders + mockUsePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: false, + isRefreshing: true, + loadPositions: jest.fn(), + }); + + mockUsePerpsLiveOrders.mockReturnValue({ orders: [] }); + + // Mock explore data with markets + const mockUsePerpsTabExploreData = jest.requireMock( + '../../hooks/usePerpsTabExploreData', + ).usePerpsTabExploreData; + mockUsePerpsTabExploreData.mockReturnValue({ + exploreMarkets: [mockMarket, mockMarketBTC], + watchlistMarkets: [], + isLoading: false, + }); + + // Act - Render component + render(); + + // Assert - Component should render explore state with market data + expect(screen.toJSON()).toBeTruthy(); + expect(screen.getByText('ETH')).toBeOnTheScreen(); + }); + }); + + describe('Accessibility', () => { + it('renders positions section title when positions exist', () => { + mockUsePerpsLivePositions.mockReturnValue({ + positions: [mockPosition], + isInitialLoading: false, + }); + + render(); + + expect( + screen.getByText(strings('perps.position.title')), + ).toBeOnTheScreen(); + }); + }); + + describe('Scroll container behaviour', () => { + it('renders content without ScrollView on homepage', () => { + (useSelector as jest.Mock).mockImplementation((selector: unknown) => { + if (selector === mockSelectPerpsEligibility) { + return true; + } + if (selector === mockSelectSelectedInternalAccountByScope) { + return () => ({ + address: '0x1234567890123456789012345678901234567890', + id: 'mock-account-id', + type: 'eip155:eoa', + }); + } + return undefined; + }); + + mockUsePerpsLivePositions.mockReturnValue({ + positions: [mockPosition], + isInitialLoading: false, + }); + + render(); + + expect( + screen.queryByTestId(PerpsTabViewSelectorsIDs.SCROLL_VIEW), + ).toBeNull(); + expect( + screen.getByText(strings('perps.position.title')), + ).toBeOnTheScreen(); + }); + + it('displays explore state when no positions or orders', () => { + (useSelector as jest.Mock).mockImplementation((selector: unknown) => { + if (selector === mockSelectPerpsEligibility) { + return true; + } + if (selector === mockSelectSelectedInternalAccountByScope) { + return () => ({ + address: '0x1234567890123456789012345678901234567890', + id: 'mock-account-id', + type: 'eip155:eoa', + }); + } + return undefined; + }); + + mockUsePerpsLivePositions.mockReturnValue({ + positions: [], + isInitialLoading: false, + }); + + mockUsePerpsLiveOrders.mockReturnValue({ orders: [] }); + + // Mock explore data with markets + const mockUsePerpsTabExploreData = jest.requireMock( + '../../hooks/usePerpsTabExploreData', + ).usePerpsTabExploreData; + mockUsePerpsTabExploreData.mockReturnValue({ + exploreMarkets: [mockMarket, mockMarketBTC], + watchlistMarkets: [], + isLoading: false, + }); + + render(); + + expect(screen.getByText('ETH')).toBeOnTheScreen(); + expect(screen.getByText('BTC')).toBeOnTheScreen(); + }); + }); +}); + +// Tests for PerpsTabViewWithProvider wrapper component diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx new file mode 100644 index 00000000000..f87ef578d94 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.tsx @@ -0,0 +1,419 @@ +import { useNavigation } from '@react-navigation/native'; +import BigNumber from 'bignumber.js'; +import React, { useCallback, useState } from 'react'; +import { Modal, TouchableOpacity, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + PerpsPositionsViewSelectorsIDs, + PerpsTabViewSelectorsIDs, +} from '../../Perps.testIds'; +import { strings } from '../../../../../../locales/i18n'; +import Icon, { + IconColor, + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../../component-library/hooks'; +import Routes from '../../../../../constants/navigation/Routes'; +import { TraceName } from '../../../../../util/trace'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip'; +import PerpsCard from '../../components/PerpsCard'; +import { useSelector } from 'react-redux'; +import { selectPerpsEligibility } from '../../selectors/perpsController'; +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, + type PerpsMarketData, +} from '@metamask/perps-controller'; +import { + usePerpsEventTracking, + usePerpsFirstTimeUser, + usePerpsLivePositions, +} from '../../hooks'; +import { usePerpsLiveAccount, usePerpsLiveOrders } from '../../hooks/stream'; +import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; +import { getPositionDirection } from '../../utils/positionCalculations'; +import styleSheet from './PerpsTabView.styles'; +import PerpsWatchlistMarkets from '../../components/PerpsWatchlistMarkets/PerpsWatchlistMarkets'; +import PerpsMarketRowItem from '../../components/PerpsMarketRowItem'; +import PerpsRowSkeleton from '../../components/PerpsRowSkeleton'; + +import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; +import ConditionalScrollView from '../../../../../component-library/components-temp/ConditionalScrollView'; +import { usePerpsTabExploreData } from '../../hooks/usePerpsTabExploreData'; + +const PerpsTabView = () => { + const { styles } = useStyles(styleSheet, {}); + const [isEligibilityModalVisible, setIsEligibilityModalVisible] = + useState(false); + + const navigation = useNavigation(); + const { account } = usePerpsLiveAccount(); + const isEligible = useSelector(selectPerpsEligibility); + const { track } = usePerpsEventTracking(); + + const { positions, isInitialLoading } = usePerpsLivePositions({ + throttleMs: 1000, // Update positions every second + }); + + // Track Perps tab load performance - measures time from tab mount to data ready + usePerpsMeasurement({ + traceName: TraceName.PerpsTabView, + conditions: [ + !isInitialLoading, + !!positions, + account?.totalBalance !== undefined, + ], + }); + + const { orders } = usePerpsLiveOrders({ + hideTpSl: true, // Filter out TP/SL orders + hideReduceOnly: true, // Filter out all reduce-only orders + throttleMs: 1000, // Update orders every second + }); + + const { isFirstTimeUser } = usePerpsFirstTimeUser(); + + const hasPositions = positions && positions.length > 0; + const hasOrders = orders && orders.length > 0; + const hasNoPositionsOrOrders = !hasPositions && !hasOrders; + + // Business hook for explore data when no positions/orders + const { + exploreMarkets, + watchlistMarkets, + suggestedWatchlistMarkets = [], + isLoading: isExploreLoading, + } = usePerpsTabExploreData({ enabled: hasNoPositionsOrOrders }); + + // Determine balance visibility for conditional header styling + const totalBalance = account?.totalBalance ?? '0'; + const isBalanceEmpty = BigNumber(totalBalance).isZero(); + const shouldShowBalance = !isBalanceEmpty; + + // Watchlist header: at top, varies by balance + const watchlistHeaderStyle = shouldShowBalance + ? styles.watchlistHeaderStyleWithBalance // 24px/4px + : styles.watchlistHeaderStyleNoBalance; // 16px/4px + + // The watchlist section is visible if the user has watchlist markets OR when suggested + // markets are available (empty state is shown instead of hiding the section entirely). + const isWatchlistVisible = + watchlistMarkets.length > 0 || + (suggestedWatchlistMarkets.length > 0 && !isExploreLoading); + + // Explore header: depends on position and balance + const exploreSectionHeaderStyle = isWatchlistVisible + ? styles.exploreSectionHeaderBelowWatchlist // 20px/8px + : shouldShowBalance + ? styles.exploreSectionHeaderWithBalance // 24px/4px + : styles.exploreSectionHeaderNoBalance; // 16px/4px + + // Track wallet home perps tab viewed - declarative (main's event name, privacy-compliant count) + usePerpsEventTracking({ + eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, + conditions: [ + !isInitialLoading, + !!positions, + account?.totalBalance !== undefined, + ], + properties: { + [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: + PERPS_EVENT_VALUE.SCREEN_TYPE.WALLET_HOME_PERPS_TAB, + [PERPS_EVENT_PROPERTY.OPEN_POSITION]: positions?.length || 0, + [PERPS_EVENT_PROPERTY.OPEN_ORDER]: orders?.length || 0, + [PERPS_EVENT_PROPERTY.SOURCE]: PERPS_EVENT_VALUE.SOURCE.HOMESCREEN_TAB, + }, + }); + + const handleManageBalancePress = useCallback(() => { + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.PERPS_HOME, + params: { source: PERPS_EVENT_VALUE.SOURCE.HOMESCREEN_TAB }, + }); + }, [navigation]); + + const handleNewTrade = useCallback(() => { + if (isFirstTimeUser) { + // Navigate to tutorial for first-time users + navigation.navigate(Routes.PERPS.TUTORIAL); + } else { + // Navigate to trading view for returning users + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.PERPS_HOME, + params: { source: PERPS_EVENT_VALUE.SOURCE.POSITION_TAB }, + }); + } + }, [navigation, isFirstTimeUser]); + + // Modal handlers - now using navigation to modal stack with geo-restriction check + const handleCloseAllPress = useCallback(() => { + // Geo-restriction check for close all positions + if (!isEligible) { + track(MetaMetricsEvents.PERPS_SCREEN_VIEWED, { + [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: + PERPS_EVENT_VALUE.SCREEN_TYPE.GEO_BLOCK_NOTIF, + [PERPS_EVENT_PROPERTY.SOURCE]: + PERPS_EVENT_VALUE.SOURCE.CLOSE_ALL_POSITIONS_BUTTON, + }); + setIsEligibilityModalVisible(true); + return; + } + navigation.navigate(Routes.PERPS.MODALS.ROOT, { + screen: Routes.PERPS.MODALS.CLOSE_ALL_POSITIONS, + }); + }, [isEligible, navigation, track]); + + const handleCancelAllPress = useCallback(() => { + navigation.navigate(Routes.PERPS.MODALS.ROOT, { + screen: Routes.PERPS.MODALS.CANCEL_ALL_ORDERS, + }); + }, [navigation]); + + const renderStartTradeCTA = () => ( + + + + + + + {strings('perps.position.list.start_new_trade')} + + + + ); + + const renderOrdersSection = () => { + // Only show orders section if there are active orders + if (!orders || orders.length === 0) { + return null; + } + + return ( + <> + + + {strings('perps.order.open_orders')} + + + + {strings('perps.home.cancel_all')} + + + + + {orders.map((order) => ( + + ))} + {(!positions || positions.length === 0) && renderStartTradeCTA()} + + + ); + }; + + const renderPositionsSection = () => { + if (isInitialLoading) { + // Removed loading state as it was redundant to the first loading state and only appeared for very little time + return ( + + ); + } + + if (positions.length === 0) { + return null; + } + + return ( + <> + + + {strings('perps.position.title')} + + + + {strings('perps.home.close_all')} + + + + + {positions.map((position, index) => { + const directionSegment = getPositionDirection(position.size); + return ( + + + + ); + })} + {renderStartTradeCTA()} + + + ); + }; + + // Custom explore market row - isolated styling for PerpsTabView only + const handleExploreMarketPress = useCallback( + (market: PerpsMarketData) => { + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { market }, + }); + }, + [navigation], + ); + + const handleSeeAllPerps = useCallback(() => { + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_LIST, + params: { + defaultMarketTypeFilter: 'all', + source: PERPS_EVENT_VALUE.SOURCE.HOMESCREEN_TAB, + }, + }); + }, [navigation]); + + const renderExploreSection = useCallback(() => { + if (isExploreLoading) { + return ( + + + + {strings('perps.home.explore_markets')} + + + + + ); + } + + if (exploreMarkets.length === 0) { + return null; + } + + return ( + + + + {strings('perps.home.explore_markets')} + + + {exploreMarkets.map((market) => ( + handleExploreMarketPress(market)} + /> + ))} + + + {strings('perps.home.see_all_perps')} + + + + ); + }, [ + isExploreLoading, + exploreMarkets, + styles, + exploreSectionHeaderStyle, + handleExploreMarketPress, + handleSeeAllPerps, + ]); + + return ( + + {/* */} + + {!isInitialLoading && hasNoPositionsOrOrders ? ( + + {/* Watchlist section — shared component with PerpsTabView style overrides */} + + + {/* Explore markets section - custom render for PerpsTabView styling */} + {renderExploreSection()} + + ) : ( + + {renderPositionsSection()} + {renderOrdersSection()} + + )} + + {isEligibilityModalVisible && ( + // Android Compatibility: Wrap the in a plain component to prevent rendering issues and freezing. + + + setIsEligibilityModalVisible(false)} + contentKey={'geo_block'} + testID={PerpsTabViewSelectorsIDs.GEO_BLOCK_BOTTOM_SHEET_TOOLTIP} + /> + + + )} + + ); +}; + +// Enable WDYR tracking in development +// Uncomment to enable WDYR for debugging re-renders +// if (__DEV__) { +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// (PerpsTabView as any).whyDidYouRender = { +// logOnDifferentValues: true, +// customName: 'PerpsTabView', +// }; +// } + +export default PerpsTabView; diff --git a/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.styles.ts b/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.styles.ts index 33c176980a3..2bf0c3891c0 100644 --- a/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.styles.ts +++ b/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.styles.ts @@ -31,5 +31,8 @@ export const styleSheet = (params: { badgeText: { color: isSelected ? theme.colors.icon.inverse : theme.colors.text.default, }, + badgeIcon: { + color: isSelected ? theme.colors.icon.inverse : theme.colors.icon.default, + }, }); }; diff --git a/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.test.tsx b/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.test.tsx index a81132e4f04..ad47d0c7c19 100644 --- a/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.test.tsx @@ -5,6 +5,7 @@ import PerpsMarketCategoryBadge from './PerpsMarketCategoryBadge'; describe('PerpsMarketCategoryBadge', () => { const defaultProps = { label: 'Crypto', + accessibilityLabel: 'Crypto', isSelected: false, onPress: jest.fn(), }; diff --git a/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.tsx b/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.tsx index fded3daa69f..735aeeffd5d 100644 --- a/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.tsx +++ b/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.tsx @@ -4,6 +4,10 @@ import { FontWeight, Text, TextVariant, + Icon, + IconSize, + IconColor, + IconName, } from '@metamask/design-system-react-native'; import { useStyles } from '../../../../../component-library/hooks'; import { styleSheet } from './PerpsMarketCategoryBadge.styles'; @@ -12,19 +16,33 @@ import type { PerpsMarketCategoryBadgeProps } from './PerpsMarketCategoryBadge.t /** * PerpsMarketCategoryBadge - Interactive badge for category filtering * - * Displays a pressable badge/pill for selecting market categories. + * Supports two modes: + * - Text mode (default): renders a text label pill. + * - Icon mode: when `icon` is provided, renders an icon-only pill (same size/shape). * * @example * ```tsx + * // Text badge * handleCategoryPress('crypto')} * /> + * + * // Icon-only badge (e.g. watchlist star) + * * ``` */ const PerpsMarketCategoryBadge: React.FC = ({ label, + icon, + accessibilityLabel, isSelected, onPress, testID, @@ -38,15 +56,23 @@ const PerpsMarketCategoryBadge: React.FC = ({ testID={testID} accessibilityRole="button" accessibilityState={{ selected: isSelected }} - accessibilityLabel={label} + accessibilityLabel={accessibilityLabel} > - - {label} - + {icon ? ( + + ) : ( + + {label} + + )} ); }; diff --git a/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.types.ts b/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.types.ts index 6bd2aa1c6a4..8efb3c69e01 100644 --- a/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.types.ts +++ b/app/components/UI/Perps/components/PerpsMarketCategoryBadge/PerpsMarketCategoryBadge.types.ts @@ -1,8 +1,19 @@ +import { IconName } from '../../../../../component-library/components/Icons/Icon'; + export interface PerpsMarketCategoryBadgeProps { /** - * Display label for the badge + * Display label for the badge. Mutually exclusive with `icon` — provide one or the other. */ - label: string; + label?: string; + /** + * Icon to render inside the badge (icon-only mode). Takes precedence over `label`. + */ + icon?: IconName; + /** + * Accessibility label — required when using icon-only mode so screen readers can + * still announce the badge's purpose. + */ + accessibilityLabel: string; /** * Whether this badge is currently selected */ diff --git a/app/components/UI/Perps/components/PerpsMarketCategoryBadges/PerpsMarketCategoryBadges.tsx b/app/components/UI/Perps/components/PerpsMarketCategoryBadges/PerpsMarketCategoryBadges.tsx index 172589070b8..140f133e9a3 100644 --- a/app/components/UI/Perps/components/PerpsMarketCategoryBadges/PerpsMarketCategoryBadges.tsx +++ b/app/components/UI/Perps/components/PerpsMarketCategoryBadges/PerpsMarketCategoryBadges.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from 'react'; import Animated, { FadeIn, LinearTransition } from 'react-native-reanimated'; import { strings } from '../../../../../../locales/i18n'; import { useStyles } from '../../../../../component-library/hooks'; +import { IconName } from '../../../../../component-library/components/Icons/Icon'; import PerpsMarketCategoryBadge from '../PerpsMarketCategoryBadge'; import { styleSheet } from './PerpsMarketCategoryBadges.styles'; import type { PerpsMarketCategoryBadgesProps } from './PerpsMarketCategoryBadges.types'; @@ -32,6 +33,9 @@ const NEW_CATEGORY: PerpsCategory = { const PerpsMarketCategoryBadges: React.FC = ({ selectedCategory, onCategorySelect, + showWatchlistBadge = false, + isWatchlistSelected = false, + onWatchlistToggle, testID, }) => { const { styles } = useStyles(styleSheet, {}); @@ -62,6 +66,21 @@ const PerpsMarketCategoryBadges: React.FC = ({ style={styles.scrollContainer} testID={testID} > + {/* Watchlist star badge — shown first */} + {showWatchlistBadge && ( + + undefined)} + testID={testID ? `${testID}-watchlist` : undefined} + /> + + )} {displayCategories.map((category, index) => { const isCategorySelected = selectedCategory === category.id; return ( @@ -72,6 +91,7 @@ const PerpsMarketCategoryBadges: React.FC = ({ > handleCategoryPress(category.id)} testID={testID ? `${testID}-${category.id}` : undefined} diff --git a/app/components/UI/Perps/components/PerpsMarketCategoryBadges/PerpsMarketCategoryBadges.types.ts b/app/components/UI/Perps/components/PerpsMarketCategoryBadges/PerpsMarketCategoryBadges.types.ts index 13424012fe2..c61e7c6e576 100644 --- a/app/components/UI/Perps/components/PerpsMarketCategoryBadges/PerpsMarketCategoryBadges.types.ts +++ b/app/components/UI/Perps/components/PerpsMarketCategoryBadges/PerpsMarketCategoryBadges.types.ts @@ -9,6 +9,19 @@ export interface PerpsMarketCategoryBadgesProps { * Callback when a category is selected */ onCategorySelect: (category: MarketTypeFilter) => void; + /** + * Whether to show the watchlist (star) filter badge. + * Should be true only when the user has at least one market on their watchlist. + */ + showWatchlistBadge?: boolean; + /** + * Whether the watchlist filter is currently active + */ + isWatchlistSelected?: boolean; + /** + * Callback when the watchlist badge is pressed + */ + onWatchlistToggle?: () => void; /** * Optional test ID for E2E testing */ diff --git a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx index 3eee913d1e5..ec883e48928 100644 --- a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.test.tsx @@ -18,6 +18,20 @@ jest.mock('../PerpsTokenLogo', () => { }; }); +jest.mock('../../../../../component-library/hooks', () => ({ + useStyles: () => ({ + styles: { + addButton: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + }, + }), +})); + jest.mock('../../hooks/stream', () => ({ usePerpsLivePrices: jest.fn(() => ({})), // Return empty object - no live prices in tests })); @@ -805,30 +819,65 @@ describe('PerpsMarketRowItem', () => { expect(mockOnPress).toHaveBeenCalledTimes(1); expect(mockOnPress.mock.calls[0][0].fundingRate).toBe(0.0005); }); + }); - it('handles funding rate update when live data funding is undefined', () => { - const marketWithFundingRate: PerpsMarketData = { - ...mockMarketData, - fundingRate: 0.0005, // Initial: 0.05% - }; + describe('Add to Watchlist Button', () => { + it('does not render an add button when onAddPress is not provided', () => { + render(); - // Mock live data without funding rate - mockUsePerpsLivePrices.mockReturnValue({ - BTC: { - price: '52000.00', - // funding is undefined - }, - }); + expect( + screen.queryByTestId(getPerpsMarketRowItemSelector.addButton('BTC')), + ).toBeNull(); + }); + it('renders a trailing add button when onAddPress is provided', () => { + const mockOnAddPress = jest.fn(); render( , ); - // Should keep the original funding rate when live data doesn't have funding - expect(screen.getByText('0.0500% Funding')).toBeOnTheScreen(); + expect( + screen.getByTestId(getPerpsMarketRowItemSelector.addButton('BTC')), + ).toBeOnTheScreen(); + }); + + it('calls onAddPress with current market data when the add button is pressed', () => { + const mockOnAddPress = jest.fn(); + render( + , + ); + + fireEvent.press( + screen.getByTestId(getPerpsMarketRowItemSelector.addButton('BTC')), + ); + + expect(mockOnAddPress).toHaveBeenCalledTimes(1); + expect(mockOnAddPress.mock.calls[0][0].symbol).toBe('BTC'); + }); + + it('pressing the add button does not trigger onPress', () => { + const mockOnPress = jest.fn(); + const mockOnAddPress = jest.fn(); + render( + , + ); + + fireEvent.press( + screen.getByTestId(getPerpsMarketRowItemSelector.addButton('BTC')), + ); + + expect(mockOnAddPress).toHaveBeenCalledTimes(1); + expect(mockOnPress).not.toHaveBeenCalled(); }); }); }); diff --git a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx index 841187603da..b5e8845496c 100644 --- a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx +++ b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.tsx @@ -1,4 +1,5 @@ import React, { useMemo } from 'react'; +import { StyleSheet, TouchableOpacity, View } from 'react-native'; import { getPerpsMarketRowItemSelector } from '../../Perps.testIds'; import { strings } from '../../../../../../locales/i18n'; import Text, { @@ -32,6 +33,25 @@ import PerpsBadge from '../PerpsBadge'; import PerpsLeverage from '../PerpsLeverage/PerpsLeverage'; import PerpsTokenLogo from '../PerpsTokenLogo'; import { PerpsMarketRowItemProps } from './PerpsMarketRowItem.types'; +import Icon, { + IconColor, + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import { useStyles } from '../../../../../component-library/hooks'; +import type { Theme } from '@metamask/design-tokens'; + +const styleSheet = ({ theme }: { theme: Theme }) => + StyleSheet.create({ + addButton: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: theme.colors.background.defaultPressed, + alignItems: 'center', + justifyContent: 'center', + }, + }); const PerpsMarketRowItem = ({ market, @@ -40,6 +60,7 @@ const PerpsMarketRowItem = ({ displayMetric = 'volume', showBadge = false, compact = false, + onAddPress, }: PerpsMarketRowItemProps) => { // Subscribe to live prices for just this symbol const livePrices = usePerpsLivePrices({ @@ -47,6 +68,8 @@ const PerpsMarketRowItem = ({ throttleMs: 3000, // 3 seconds for list view }); + const { styles } = useStyles(styleSheet, {}); + // Merge live price into market data const displayMarket = useMemo(() => { const livePrice = livePrices[market.symbol]; @@ -214,22 +237,43 @@ const PerpsMarketRowItem = ({ - {/* Right section: Price + change */} + {/* Right section: Price + 24h change + optional add button */} - - {displayMarket.price} - - - {displayMarket.change24hPercent} - + + + {displayMarket.price} + + + {displayMarket.change24hPercent} + + + + {onAddPress && ( + onAddPress(displayMarket)} + testID={getPerpsMarketRowItemSelector.addButton( + displayMarket.symbol, + )} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + activeOpacity={0.6} + > + + + + + )} ); diff --git a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.types.ts b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.types.ts index a959efd8a72..00f5da90be9 100644 --- a/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.types.ts +++ b/app/components/UI/Perps/components/PerpsMarketRowItem/PerpsMarketRowItem.types.ts @@ -40,4 +40,9 @@ export interface PerpsMarketRowItemProps { * @default false */ compact?: boolean; + /** + * When provided, renders a trailing circular "+" button (e.g. add to watchlist). + * The callback receives the current displayMarket (with live price merged in). + */ + onAddPress?: (market: PerpsMarketData) => void; } diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx index eec1478d317..a3774430ad0 100644 --- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx +++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx @@ -422,7 +422,7 @@ const PerpsTutorialCarousel: React.FC = () => { onChangeTab={handleTabChange} initialPage={0} > - {tutorialScreens.map((screen) => ( + {tutorialScreens.map((screen, index) => ( { {screen.content} )} - {screen?.riveArtboardName && ( + {screen?.riveArtboardName && currentTab === index && ( { borderTopColor: theme.colors.border.muted, paddingTop: 16, }, + sectionNoHeader: { + borderTopWidth: 0, + paddingTop: 0, + }, header: { flexDirection: 'row', alignItems: 'center', + justifyContent: 'space-between', paddingHorizontal: 16, marginBottom: 8, }, + headerLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, titleRow: { flexDirection: 'row', alignItems: 'center', @@ -24,6 +34,32 @@ const styleSheet = (params: { theme: Theme }) => { listContent: { paddingHorizontal: 16, }, + // Empty state + emptyStateContainer: { + paddingHorizontal: 16, + paddingTop: 4, + paddingBottom: 8, + }, + // Suggested sub-section + suggestedSection: { + paddingHorizontal: 16, + paddingTop: 4, + paddingBottom: 8, + }, + suggestedSubtitle: { + marginBottom: 4, + }, + suggestedHeader: { + marginBottom: 4, + }, + // Show more / show less toggle + showMoreButton: { + marginTop: 4, + marginBottom: 8, + }, + showMoreButtonContainer: { + paddingHorizontal: 16, + }, }); }; diff --git a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx index ee2a205054c..07a24caaa90 100644 --- a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx +++ b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.test.tsx @@ -1,11 +1,15 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; import PerpsWatchlistMarkets from './PerpsWatchlistMarkets'; import { type PerpsMarketData } from '@metamask/perps-controller'; import Routes from '../../../../../constants/navigation/Routes'; import { createActiveABTestAssignment } from '../../../../../util/analytics/activeABTestAssignments'; -// Mock dependencies +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(() => ({ navigate: jest.fn(), @@ -15,22 +19,37 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('../../../../../component-library/hooks', () => ({ useStyles: () => ({ styles: { - container: {}, + section: {}, header: {}, + headerLeft: {}, + titleRow: {}, listContent: {}, - emptyText: {}, + emptyStateContainer: {}, + suggestedSection: {}, + suggestedHeader: {}, + showMoreRow: {}, }, }), })); +const mockAddToWatchlist = jest.fn(); +jest.mock('../../hooks/usePerpsWatchlistActions', () => ({ + usePerpsWatchlistActions: () => ({ + addToWatchlist: mockAddToWatchlist, + removeFromWatchlist: jest.fn(), + }), +})); + jest.mock('../PerpsMarketRowItem', () => { const { TouchableOpacity, Text } = jest.requireActual('react-native'); return function MockPerpsMarketRowItem({ market, onPress, + onAddPress, }: { market: PerpsMarketData; - onPress: () => void; + onPress?: () => void; + onAddPress?: (market: PerpsMarketData) => void; }) { return ( { > {market.symbol} {market.name} + {onAddPress && ( + onAddPress(market)} + testID={`perps-market-row-${market.symbol}-add-button`} + > + + + + )} ); }; @@ -52,113 +79,352 @@ jest.mock('../PerpsRowSkeleton', () => { }); jest.mock('../../../../../../locales/i18n', () => ({ - strings: (key: string) => { + strings: (key: string, params?: Record) => { const translations: Record = { 'perps.home.watchlist': 'Watchlist', 'perps.home.see_all': 'See all', + 'perps.watchlist.suggested': 'Suggested', + 'perps.watchlist.empty_subtitle': + 'Tap + to add a market to your watchlist.', + 'perps.watchlist.show_more': `Show ${params?.count} more`, + 'perps.watchlist.show_less': 'Show less', }; return translations[key] || key; }, })); -// Mock Perps hooks -jest.mock('../../hooks/stream', () => ({ - usePerpsLivePositions: () => ({ - positions: [], - isInitialLoading: false, - }), - usePerpsLiveOrders: () => ({ - orders: [], - isInitialLoading: false, - }), +jest.mock('../../../../../component-library/components/Icons/Icon', () => ({ + __esModule: true, + default: 'Icon', + Icon: 'Icon', + IconName: { + ArrowRight: 'ArrowRight', + ArrowDown: 'ArrowDown', + ArrowUp: 'ArrowUp', + }, + IconSize: { Sm: 'Sm', Xs: 'Xs' }, + IconColor: { Default: 'Default', Primary: 'Primary' }, +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(() => []), })); +// --------------------------------------------------------------------------- +// Test data helpers +// --------------------------------------------------------------------------- + +const makeMarket = (symbol: string, name: string): PerpsMarketData => ({ + symbol, + name, + maxLeverage: '50x', + price: '$1,000', + change24h: '+$10', + change24hPercent: '+1.00%', + volume: '$1B', +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + describe('PerpsWatchlistMarkets', () => { const mockNavigate = jest.fn(); + const mockMarkets: PerpsMarketData[] = [ - { - symbol: 'BTC', - name: 'Bitcoin', - maxLeverage: '50x', - price: '$52,000', - change24h: '+$2,000', - change24hPercent: '+4.00%', - volume: '$2.5B', - }, - { - symbol: 'ETH', - name: 'Ethereum', - maxLeverage: '25x', - price: '$3,000', - change24h: '-$50', - change24hPercent: '-1.64%', - volume: '$1.2B', - }, + makeMarket('BTC', 'Bitcoin'), + makeMarket('ETH', 'Ethereum'), + ]; + + // Suggested list symbols deliberately differ from watchlist to avoid testID collision + const mockSuggestedMarkets: PerpsMarketData[] = [ + makeMarket('SOL', 'Solana'), + makeMarket('BNB', 'Binance Coin'), + makeMarket('AVAX', 'Avalanche'), ]; beforeEach(() => { jest.clearAllMocks(); + (useSelector as jest.Mock).mockReturnValue([]); const { useNavigation } = jest.requireMock('@react-navigation/native'); - useNavigation.mockReturnValue({ - navigate: mockNavigate, - }); + useNavigation.mockReturnValue({ navigate: mockNavigate }); }); afterEach(() => { jest.resetAllMocks(); }); - describe('Component Rendering', () => { - it('renders watchlist header with title', () => { - render(); + // ------------------------------------------------------------------------- + // Empty / null states + // ------------------------------------------------------------------------- + + describe('Null state', () => { + it('returns null when markets is empty and no suggestedMarkets provided', () => { + const { toJSON } = render(); + expect(toJSON()).toBeNull(); + }); + it('returns null when markets is empty and suggestedMarkets is empty array', () => { + const { toJSON } = render( + , + ); + expect(toJSON()).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // Empty watchlist + suggested sub-section + // ------------------------------------------------------------------------- + + describe('Empty watchlist with suggested markets', () => { + it('renders the placeholder text when watchlist is empty', () => { + render( + , + ); + expect( + screen.getByText('Tap + to add a market to your watchlist.'), + ).toBeOnTheScreen(); + expect(screen.queryByText('Suggested')).not.toBeOnTheScreen(); + }); + + it('renders the "Suggested" sub-header when watchlist is empty', () => { + render( + , + ); + expect( + screen.getByTestId('perps-watchlist-suggested-header'), + ).toBeOnTheScreen(); + expect( + screen.getByText('Tap + to add a market to your watchlist.'), + ).toBeOnTheScreen(); + }); + + it('renders the suggested section wrapper testID', () => { + render( + , + ); + expect( + screen.getByTestId('perps-watchlist-suggested-section'), + ).toBeOnTheScreen(); + }); + + it('renders suggested market rows with add buttons', () => { + render( + , + ); + for (const market of mockSuggestedMarkets) { + expect( + screen.getByTestId(`perps-market-row-${market.symbol}`), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(`perps-market-row-${market.symbol}-add-button`), + ).toBeOnTheScreen(); + } + }); + + it('calls addToWatchlist with the correct symbol when an add button is pressed', () => { + render( + , + ); + fireEvent.press(screen.getByTestId('perps-market-row-SOL-add-button')); + expect(mockAddToWatchlist).toHaveBeenCalledTimes(1); + expect(mockAddToWatchlist).toHaveBeenCalledWith('SOL'); + }); + + it('renders the Watchlist section header', () => { + render( + , + ); expect(screen.getByText('Watchlist')).toBeOnTheScreen(); }); - it('renders component with markets', () => { - const { root } = render(); + it('does not render old empty-state title copy', () => { + render( + , + ); + expect( + screen.queryByText('Start with the most-traded markets'), + ).not.toBeOnTheScreen(); + }); + }); - expect(root).toBeTruthy(); + // ------------------------------------------------------------------------- + // Populated watchlist — ≤ 3 markets, no suggested + // ------------------------------------------------------------------------- + + describe('Populated watchlist (≤ 3 markets)', () => { + it('renders all markets and no show-more toggle', () => { + render(); + expect(screen.getByText('BTC')).toBeOnTheScreen(); + expect(screen.getByText('ETH')).toBeOnTheScreen(); + expect(screen.queryByText(/Show \d+ more/)).not.toBeOnTheScreen(); + expect(screen.queryByText('Show less')).not.toBeOnTheScreen(); + }); + + it('renders the section header', () => { + render(); + expect(screen.getByText('Watchlist')).toBeOnTheScreen(); }); - it('renders all watchlisted markets', () => { + it('watchlist rows do not have add buttons', () => { render(); + expect( + screen.queryByTestId('perps-market-row-BTC-add-button'), + ).not.toBeOnTheScreen(); + }); + }); + + // ------------------------------------------------------------------------- + // Populated watchlist + suggested rendered together + // ------------------------------------------------------------------------- + + describe('Populated watchlist + suggested sub-section together', () => { + it('renders both watchlist rows and suggested rows simultaneously', () => { + render( + , + ); + // Watchlist rows (no add button) + expect(screen.getByTestId('perps-market-row-BTC')).toBeOnTheScreen(); + expect(screen.getByTestId('perps-market-row-ETH')).toBeOnTheScreen(); + expect( + screen.queryByTestId('perps-market-row-BTC-add-button'), + ).not.toBeOnTheScreen(); + + // Suggested rows (with add button) + for (const market of mockSuggestedMarkets) { + expect( + screen.getByTestId(`perps-market-row-${market.symbol}`), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(`perps-market-row-${market.symbol}-add-button`), + ).toBeOnTheScreen(); + } + }); + + it('renders the "Suggested" sub-header (not placeholder) when both lists are present', () => { + render( + , + ); + expect(screen.getByText('Suggested')).toBeOnTheScreen(); + expect( + screen.queryByText('Tap + to add a market to your watchlist.'), + ).not.toBeOnTheScreen(); + }); + }); + // ------------------------------------------------------------------------- + // Populated watchlist — > 3 markets (expand / collapse) + // ------------------------------------------------------------------------- + + describe('Populated watchlist (> 3 markets) — expand/collapse', () => { + const fiveMarkets = [ + makeMarket('BTC', 'Bitcoin'), + makeMarket('ETH', 'Ethereum'), + makeMarket('SOL', 'Solana'), + makeMarket('AVAX', 'Avalanche'), + makeMarket('DOT', 'Polkadot'), + ]; + + it('shows first 3 markets and a "Show 2 more" button', () => { + render(); expect(screen.getByText('BTC')).toBeOnTheScreen(); - expect(screen.getByText('Bitcoin')).toBeOnTheScreen(); expect(screen.getByText('ETH')).toBeOnTheScreen(); - expect(screen.getByText('Ethereum')).toBeOnTheScreen(); + expect(screen.getByText('SOL')).toBeOnTheScreen(); + expect(screen.queryByText('AVAX')).not.toBeOnTheScreen(); + expect(screen.queryByText('DOT')).not.toBeOnTheScreen(); + expect(screen.getByText('Show 2 more')).toBeOnTheScreen(); }); - it('returns null when markets array is empty', () => { - const { toJSON } = render(); + it('expands to show all markets when "Show more" is pressed', () => { + render(); + fireEvent.press(screen.getByText('Show 2 more')); + expect(screen.getByText('AVAX')).toBeOnTheScreen(); + expect(screen.getByText('DOT')).toBeOnTheScreen(); + }); - // Component returns null for empty markets - expect(toJSON()).toBeNull(); + it('shows "Show less" button after expanding', () => { + render(); + fireEvent.press(screen.getByText('Show 2 more')); + expect(screen.getByText('Show less')).toBeOnTheScreen(); + expect(screen.queryByText('Show 2 more')).not.toBeOnTheScreen(); }); - it('shows loading skeleton when isLoading is true', () => { - render(); + it('collapses back to 3 markets when "Show less" is pressed', () => { + render(); + fireEvent.press(screen.getByText('Show 2 more')); + fireEvent.press(screen.getByText('Show less')); + expect(screen.queryByText('AVAX')).not.toBeOnTheScreen(); + expect(screen.queryByText('DOT')).not.toBeOnTheScreen(); + expect(screen.getByText('Show 2 more')).toBeOnTheScreen(); + }); + + it('does not show toggle when exactly 3 markets are present', () => { + const threeMarkets = fiveMarkets.slice(0, 3); + render(); + expect(screen.queryByText(/Show \d+ more/)).not.toBeOnTheScreen(); + expect(screen.queryByText('Show less')).not.toBeOnTheScreen(); + }); + }); + + // ------------------------------------------------------------------------- + // Loading state + // ------------------------------------------------------------------------- - // Component shows skeleton while loading + describe('Loading state', () => { + it('shows skeleton when isLoading is true', () => { + render(); expect(screen.getByTestId('perps-row-skeleton-3')).toBeOnTheScreen(); expect(screen.getByText('Watchlist')).toBeOnTheScreen(); }); + + it('shows markets when isLoading transitions from true to false', () => { + const { rerender } = render( + , + ); + rerender( + , + ); + expect(screen.getByText('BTC')).toBeOnTheScreen(); + }); }); - describe('Navigation Handling', () => { - // Note: "See all" button was removed from PerpsWatchlistMarkets - // Navigation to watchlist is now handled elsewhere + // ------------------------------------------------------------------------- + // Navigation + // ------------------------------------------------------------------------- - it('navigates to market details when market row is pressed', () => { + describe('Navigation', () => { + it('navigates to market details when a watchlist row is pressed', () => { render( , ); - - const btcRow = screen.getByTestId('perps-market-row-BTC'); - fireEvent.press(btcRow); - - expect(mockNavigate).toHaveBeenCalledTimes(1); + fireEvent.press(screen.getByTestId('perps-market-row-BTC')); expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: { @@ -169,26 +435,26 @@ describe('PerpsWatchlistMarkets', () => { }); }); - it('passes correct market data to navigation for different markets', () => { + it('navigates to market details when a suggested market row is pressed', () => { render( - , + , ); - - const ethRow = screen.getByTestId('perps-market-row-ETH'); - fireEvent.press(ethRow); - - expect(mockNavigate).toHaveBeenCalledTimes(1); + fireEvent.press(screen.getByTestId('perps-market-row-SOL')); expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: { - market: mockMarkets[1], + market: mockSuggestedMarkets[0], initialTab: undefined, source: 'perps_home', }, }); }); - it('carries transactionActiveAbTests when a watchlist market opens market details', () => { + it('carries transactionActiveAbTests when opening market details', () => { const transactionActiveAbTests = [ createActiveABTestAssignment( 'homeTMCU725AbtestHomepagePerpsPillsEmptyState', @@ -202,9 +468,7 @@ describe('PerpsWatchlistMarkets', () => { transactionActiveAbTests={transactionActiveAbTests} />, ); - fireEvent.press(screen.getByTestId('perps-market-row-BTC')); - expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: { @@ -217,249 +481,67 @@ describe('PerpsWatchlistMarkets', () => { }); }); - describe('Market Data Display', () => { - it('displays markets in provided order', () => { - const { rerender } = render( - , - ); - - const reversedMarkets = [...mockMarkets].reverse(); - rerender(); - - expect(screen.getByText('ETH')).toBeOnTheScreen(); - expect(screen.getByText('BTC')).toBeOnTheScreen(); - }); - - it('handles single market', () => { - const singleMarket = [mockMarkets[0]]; - - render(); - - expect(screen.getByText('BTC')).toBeOnTheScreen(); - expect(screen.queryByText('ETH')).not.toBeOnTheScreen(); - }); - - it('handles multiple markets', () => { - const manyMarkets: PerpsMarketData[] = [ - ...mockMarkets, - { - symbol: 'SOL', - name: 'Solana', - maxLeverage: '20x', - price: '$150', - change24h: '+$5', - change24hPercent: '+3.45%', - volume: '$800M', - }, - ]; - - render(); - - expect(screen.getByText('BTC')).toBeOnTheScreen(); - expect(screen.getByText('ETH')).toBeOnTheScreen(); - expect(screen.getByText('SOL')).toBeOnTheScreen(); - }); - - it('updates when markets prop changes', () => { - const { rerender } = render( - , - ); - - expect(screen.getByText('BTC')).toBeOnTheScreen(); - - const newMarkets: PerpsMarketData[] = [ - { - symbol: 'AVAX', - name: 'Avalanche', - maxLeverage: '30x', - price: '$40', - change24h: '+$2', - change24hPercent: '+5.26%', - volume: '$500M', - }, - ]; - - rerender(); - - expect(screen.queryByText('BTC')).not.toBeOnTheScreen(); - expect(screen.getByText('AVAX')).toBeOnTheScreen(); - }); - }); - - describe('Edge Cases', () => { - it('handles empty string symbols gracefully', () => { - const marketsWithEmptySymbol: PerpsMarketData[] = [ - { - symbol: '', - name: 'Unknown', - maxLeverage: '10x', - price: '$0', - change24h: '$0', - change24hPercent: '0.00%', - volume: '$0', - }, - ]; - - render(); - - expect(screen.getByText('Unknown')).toBeOnTheScreen(); - }); - - it('handles markets with special characters in symbols', () => { - const specialMarkets: PerpsMarketData[] = [ - { - symbol: 'BTC-USD', - name: 'Bitcoin USD', - maxLeverage: '50x', - price: '$52,000', - change24h: '+$2,000', - change24hPercent: '+4.00%', - volume: '$2.5B', - }, - ]; - - render(); - - expect(screen.getByText('BTC-USD')).toBeOnTheScreen(); - }); - - it('handles very long market lists', () => { - const longMarketList: PerpsMarketData[] = Array.from( - { length: 50 }, - (_, i) => ({ - symbol: `TOKEN${i}`, - name: `Token ${i}`, - maxLeverage: '20x', - price: `$${100 * (i + 1)}`, - change24h: `+$${10 * (i + 1)}`, - change24hPercent: '+5.00%', - volume: '$1M', - }), - ); - - const { root } = render( - , - ); - - // Component renders with long list - expect(root).toBeTruthy(); - }); + // ------------------------------------------------------------------------- + // onSeeAllPress header behaviour + // ------------------------------------------------------------------------- - it('handles markets with undefined optional fields', () => { - const incompleteMarkets: PerpsMarketData[] = [ - { - symbol: 'BTC', - name: 'Bitcoin', - maxLeverage: '50x', - price: '$52,000', - change24h: '+$2,000', - change24hPercent: '+4.00%', - volume: '$2.5B', - }, - ]; - - render(); - - expect(screen.getByText('BTC')).toBeOnTheScreen(); - }); - }); - - describe('Loading State Handling', () => { - it('shows skeleton when isLoading transitions from false to true', () => { - const { rerender } = render( - , - ); - - // Component renders markets initially - expect(screen.getByText('BTC')).toBeOnTheScreen(); - - rerender(); - - // Component shows skeleton during loading - expect(screen.getByTestId('perps-row-skeleton-3')).toBeOnTheScreen(); - }); - - it('shows markets when isLoading transitions from true to false', () => { - const { rerender } = render( - , - ); - - // Component shows skeleton during loading - expect(screen.getByTestId('perps-row-skeleton-3')).toBeOnTheScreen(); - - rerender( - , + describe('onSeeAllPress (header press)', () => { + it('calls onSeeAllPress when header is pressed', () => { + const onSeeAllPress = jest.fn(); + render( + , ); - - // Component renders markets after loading completes - expect(screen.getByText('BTC')).toBeOnTheScreen(); - expect(screen.getByText('ETH')).toBeOnTheScreen(); + fireEvent.press(screen.getByTestId('perps-watchlist-header')); + expect(onSeeAllPress).toHaveBeenCalledTimes(1); }); - }); - describe('Interaction Edge Cases', () => { - it('handles multiple rapid presses on market rows', () => { + it('does not throw when header is pressed without onSeeAllPress', () => { render(); - - const btcRow = screen.getByTestId('perps-market-row-BTC'); - fireEvent.press(btcRow); - fireEvent.press(btcRow); - - expect(mockNavigate).toHaveBeenCalledTimes(2); - }); - - it('handles pressing different market rows in sequence', () => { - render( - , - ); - - const btcRow = screen.getByTestId('perps-market-row-BTC'); - const ethRow = screen.getByTestId('perps-market-row-ETH'); - - fireEvent.press(btcRow); - fireEvent.press(ethRow); - - expect(mockNavigate).toHaveBeenCalledTimes(2); - expect(mockNavigate).toHaveBeenNthCalledWith(1, Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKET_DETAILS, - params: { market: mockMarkets[0], source: 'perps_home' }, - }); - expect(mockNavigate).toHaveBeenNthCalledWith(2, Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKET_DETAILS, - params: { market: mockMarkets[1], source: 'perps_home' }, - }); + expect(() => + fireEvent.press(screen.getByTestId('perps-watchlist-header')), + ).not.toThrow(); }); }); - describe('Component Lifecycle', () => { - it('does not throw error on unmount', () => { + // ------------------------------------------------------------------------- + // Component lifecycle + // ------------------------------------------------------------------------- + + describe('Component lifecycle', () => { + it('does not throw on unmount', () => { const { unmount } = render( , ); - expect(() => unmount()).not.toThrow(); }); - it('cleans up properly when remounted with different props', () => { + it('transitions correctly from empty → has-suggested → has-watchlist', () => { const { toJSON, rerender } = render( - , + , ); - - // Component renders with markets - expect(toJSON()).not.toBeNull(); - expect(screen.getByText('BTC')).toBeOnTheScreen(); - - // Hides when empty - rerender(); expect(toJSON()).toBeNull(); - // Shows skeleton when loading - rerender(); - expect(screen.getByTestId('perps-row-skeleton-3')).toBeOnTheScreen(); + rerender( + , + ); + expect( + screen.getByText('Tap + to add a market to your watchlist.'), + ).toBeOnTheScreen(); + expect( + screen.queryByText('Start with the most-traded markets'), + ).not.toBeOnTheScreen(); - // Shows markets again when ready rerender(); expect(screen.getByText('BTC')).toBeOnTheScreen(); + expect( + screen.queryByText('Tap + to add a market to your watchlist.'), + ).not.toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx index c6f2423a6ea..12319024a8e 100644 --- a/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx +++ b/app/components/UI/Perps/components/PerpsWatchlistMarkets/PerpsWatchlistMarkets.tsx @@ -1,25 +1,57 @@ -import React, { useCallback } from 'react'; -import { FlatList, View, type StyleProp, type ViewStyle } from 'react-native'; +import React, { useCallback, useState } from 'react'; +import { + FlatList, + TouchableOpacity, + View, + type StyleProp, + type ViewStyle, +} from 'react-native'; +import { useSelector } from 'react-redux'; +import Animated, { + FadeIn, + FadeOut, + LinearTransition, +} from 'react-native-reanimated'; import { useNavigation } from '@react-navigation/native'; -import Text, { - TextVariant, - TextColor, -} from '../../../../../component-library/components/Texts/Text'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { + PERPS_EVENT_VALUE, type PerpsMarketData, type Position, type Order, } from '@metamask/perps-controller'; import PerpsMarketRowItem from '../PerpsMarketRowItem'; -import { useStyles } from '../../../../../component-library/hooks'; -import styleSheet from './PerpsWatchlistMarkets.styles'; import PerpsRowSkeleton from '../PerpsRowSkeleton'; import type { TransactionActiveAbTestEntry } from '../../../../../util/transactions/transaction-active-ab-test-attribution-registry'; +import { usePerpsWatchlistActions } from '../../hooks/usePerpsWatchlistActions'; +import { PerpsWatchlistSelectorsIDs } from '../../Perps.testIds'; +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './PerpsWatchlistMarkets.styles'; +import { WATCHLIST_LIMIT } from '../../utils/marketUtils'; +import { selectPerpsWatchlistMarkets } from '../../selectors/perpsController'; +import { + Text, + TextVariant, + TextColor, + Icon, + IconName, + IconSize, + IconColor, + Button, + ButtonVariant, + ButtonSize, +} from '@metamask/design-system-react-native'; + +const ANIMATION_DURATION = 250; + +/** Markets with ≤ this count are shown without a "Show more" toggle. */ +const INITIAL_DISPLAY_COUNT = 3; interface PerpsWatchlistMarketsProps { markets: PerpsMarketData[]; + /** Markets to offer as suggestions below the watchlist; omit to hide the section */ + suggestedMarkets?: PerpsMarketData[]; isLoading?: boolean; /** Positions from parent - avoids duplicate WebSocket subscriptions */ positions?: Position[]; @@ -35,10 +67,15 @@ interface PerpsWatchlistMarketsProps { headerStyle?: StyleProp; /** Override content container styles (e.g., to remove horizontal margin) */ contentContainerStyle?: StyleProp; + /** Called when the "Watchlist >" header is pressed */ + onSeeAllPress?: () => void; + /** Whether to render the "Watchlist" section header. Defaults to true. */ + showHeader?: boolean; } const PerpsWatchlistMarkets: React.FC = ({ markets, + suggestedMarkets, isLoading, positions = [], orders = [], @@ -47,17 +84,23 @@ const PerpsWatchlistMarkets: React.FC = ({ sectionStyle, headerStyle, contentContainerStyle, + onSeeAllPress, + showHeader = true, }) => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); + const [expanded, setExpanded] = useState(false); + const watchlistSymbols = useSelector(selectPerpsWatchlistMarkets); + + const { addToWatchlist } = usePerpsWatchlistActions( + PERPS_EVENT_VALUE.SOURCE.PERPS_HOME_WATCHLIST, + ); const handleMarketPress = useCallback( (market: PerpsMarketData) => { - // Check if user has a position or order for this market const hasPosition = positions.some((p) => p.symbol === market.symbol); const hasOrder = orders.some((o) => o.symbol === market.symbol); - // Determine which tab to open (same logic as PerpsCard) let initialTab: 'position' | 'orders' | undefined; if (hasPosition) { initialTab = 'position'; @@ -80,34 +123,39 @@ const PerpsWatchlistMarkets: React.FC = ({ [navigation, positions, orders, source, transactionActiveAbTests], ); - const renderMarket = useCallback( - ({ item }: { item: PerpsMarketData }) => ( - handleMarketPress(item)} - /> - ), - [handleMarketPress], - ); - - // Header component const SectionHeader = useCallback( () => ( - - - {strings('perps.home.watchlist')} - - + + + + {strings('perps.home.watchlist')} + + {onSeeAllPress && ( + + )} + + ), - [styles.header, headerStyle], + [styles.header, styles.headerLeft, headerStyle, onSeeAllPress], ); - // Show skeleton during initial load if (isLoading) { return ( - - + + {showHeader && } @@ -115,25 +163,122 @@ const PerpsWatchlistMarkets: React.FC = ({ ); } - // Hide section entirely when no markets - if (markets.length === 0) { + const hasWatchlist = markets.length > 0; + const hasSuggested = (suggestedMarkets?.length ?? 0) > 0; + // Use the Redux symbol count — not markets.length — so symbols that haven't + // loaded yet still count toward the cap. + const isWatchlistFull = watchlistSymbols.length >= WATCHLIST_LIMIT; + + if (!hasWatchlist && !hasSuggested) { return null; } - // Render market list + // Expand/collapse applies only to the watchlist rows + const hasMore = hasWatchlist && markets.length > INITIAL_DISPLAY_COUNT; + const displayedMarkets = + hasMore && !expanded ? markets.slice(0, INITIAL_DISPLAY_COUNT) : markets; + const hiddenCount = markets.length - INITIAL_DISPLAY_COUNT; + + const renderMarket = ({ item }: { item: PerpsMarketData }) => ( + + handleMarketPress(item)} + /> + + ); + return ( - - - - item.symbol} - showsVerticalScrollIndicator={false} - scrollEnabled={false} - contentContainerStyle={styles.listContent} - /> - + + {showHeader && } + + {hasWatchlist && ( + <> + item.symbol} + showsVerticalScrollIndicator={false} + scrollEnabled={false} + contentContainerStyle={styles.listContent} + /> + + {hasMore && ( + + + + )} + + )} + + {hasSuggested && !isWatchlistFull && ( + + + {hasWatchlist + ? strings('perps.watchlist.suggested') + : strings('perps.watchlist.empty_subtitle')} + + {suggestedMarkets?.map((market) => ( + + handleMarketPress(market)} + onAddPress={() => addToWatchlist(market.symbol)} + /> + + ))} + + )} + ); }; diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index 5bfd44315dc..6e0eb5ddf2c 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -165,9 +165,9 @@ export const HOME_SCREEN_CONFIG = { // Can be controlled via feature flag in the future ShowHeaderActionButtons: true, - // Maximum number of items to show in each carousel - PositionsCarouselLimit: 10, - OrdersCarouselLimit: 10, + // Maximum number of items to show in each carousel. + // Note: positions and orders are intentionally uncapped on the home screen — + // they render in a vertical ScrollView and must show every open entry. TrendingMarketsLimit: 5, RecentActivityLimit: 3, diff --git a/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts b/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts index 11ee73fc6dd..5c8c879d3ef 100644 --- a/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts @@ -221,6 +221,8 @@ describe('usePerpsDepositStatus', () => { dataFetching: {} as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any contentSharing: {} as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + watchlist: {} as any, }; mockUsePerpsToasts.mockReturnValue({ diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts index b70c5417e02..327a65adbf0 100644 --- a/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.test.ts @@ -348,8 +348,7 @@ describe('usePerpsHomeData', () => { ]); }); - it('applies default limits to data', () => { - // Create more data than default limits + it('leaves positions and orders uncapped while limiting recent activity', () => { const manyPositions = Array.from({ length: 10 }, (_, i) => createMockPosition({ symbol: `COIN${i}` }), ); @@ -375,12 +374,43 @@ describe('usePerpsHomeData', () => { const { result } = renderHook(() => usePerpsHomeData()); - // Default limits from HOME_SCREEN_CONFIG - expect(result.current.positions.length).toBeLessThanOrEqual(10); - expect(result.current.orders.length).toBeLessThanOrEqual(10); + // Positions and orders are intentionally uncapped on the home screen. + expect(result.current.positions).toHaveLength(10); + expect(result.current.orders).toHaveLength(10); + // Recent activity keeps its dedicated limit from HOME_SCREEN_CONFIG. expect(result.current.recentActivity.length).toBeLessThanOrEqual(3); }); + it('displays every open position when more than ten are open', () => { + const twelvePositions = Array.from({ length: 12 }, (_, i) => + createMockPosition({ symbol: `COIN${i}` }), + ); + + mockUsePerpsLivePositions.mockReturnValue({ + positions: twelvePositions, + isInitialLoading: false, + }); + + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.positions).toHaveLength(12); + }); + + it('displays every open order when more than ten are open', () => { + const twelveOrders = Array.from({ length: 12 }, (_, i) => + createMockOrder({ symbol: `COIN${i}`, orderId: `order-${i}` }), + ); + + mockUsePerpsLiveOrders.mockReturnValue({ + orders: twelveOrders, + isInitialLoading: false, + }); + + const { result } = renderHook(() => usePerpsHomeData()); + + expect(result.current.orders).toHaveLength(12); + }); + it('respects custom limits from parameters', () => { const manyPositions = Array.from({ length: 10 }, (_, i) => createMockPosition({ symbol: `COIN${i}` }), diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.ts index eb337f41224..24ec4fdf1f9 100644 --- a/app/components/UI/Perps/hooks/usePerpsHomeData.ts +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.ts @@ -26,6 +26,7 @@ import Engine from '../../../../core/Engine'; import { HOME_SCREEN_CONFIG } from '../constants/perpsConfig'; import { selectPerpsWatchlistMarkets } from '../selectors/perpsController'; import { usePerpsConnection } from './usePerpsConnection'; +import { getSuggestedWatchlistMarkets } from '../utils/marketUtils'; interface UsePerpsHomeDataParams { positionsLimit?: number; @@ -39,6 +40,8 @@ interface UsePerpsHomeDataReturn { positions: Position[]; orders: Order[]; watchlistMarkets: PerpsMarketData[]; + /** Top 5 markets by 24h volume, used as suggestions when watchlist is empty */ + suggestedWatchlistMarkets: PerpsMarketData[]; perpsMarkets: PerpsMarketData[]; // Crypto markets (renamed from trending) stocksMarkets: PerpsMarketData[]; // Equity markets commoditiesMarkets: PerpsMarketData[]; // Commodity markets @@ -61,8 +64,8 @@ interface UsePerpsHomeDataReturn { * Uses object parameters pattern for maintainability */ export const usePerpsHomeData = ({ - positionsLimit = HOME_SCREEN_CONFIG.PositionsCarouselLimit, - ordersLimit = HOME_SCREEN_CONFIG.OrdersCarouselLimit, + positionsLimit, + ordersLimit, trendingLimit = HOME_SCREEN_CONFIG.TrendingMarketsLimit, activityLimit = HOME_SCREEN_CONFIG.RecentActivityLimit, searchQuery = '', @@ -165,6 +168,13 @@ export const usePerpsHomeData = ({ [allMarkets, watchlistSymbols], ); + // Top markets by volume — shown as suggestions below the watchlist. + // Excludes already-watchlisted markets so the list shrinks as items are added. + const suggestedWatchlistMarkets = useMemo( + () => getSuggestedWatchlistMarkets(allMarkets, watchlistSymbols), + [allMarkets, watchlistSymbols], + ); + const sortBy = MARKET_SORTING_CONFIG.SortFields.Volume; const direction = MARKET_SORTING_CONFIG.DefaultDirection; @@ -298,12 +308,21 @@ export const usePerpsHomeData = ({ [filterBySearchQuery, searchQuery], ); + // The Perps home screen renders positions/orders in a vertical ScrollView with + // no "see all" page, so it must show every open position/order. Only apply a + // cap when a caller explicitly passes a finite limit (default: no cap). const limitedPositions = useMemo( - () => filteredData.positions.slice(0, positionsLimit), + () => + positionsLimit === undefined + ? filteredData.positions + : filteredData.positions.slice(0, positionsLimit), [filteredData.positions, positionsLimit], ); const limitedOrders = useMemo( - () => filteredData.orders.slice(0, ordersLimit), + () => + ordersLimit === undefined + ? filteredData.orders + : filteredData.orders.slice(0, ordersLimit), [filteredData.orders, ordersLimit], ); const limitedWatchlistMarkets = useMemo( @@ -365,6 +384,7 @@ export const usePerpsHomeData = ({ positions: limitedPositions, orders: limitedOrders, watchlistMarkets: limitedWatchlistMarkets, + suggestedWatchlistMarkets, perpsMarkets: searchedPerpsMarkets, // Crypto markets (renamed from trendingMarkets) stocksMarkets: searchedStocksMarkets, commoditiesMarkets: searchedCommoditiesMarkets, diff --git a/app/components/UI/Perps/hooks/usePerpsMarketListView.ts b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts index d561dd8370f..c700410727c 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketListView.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts @@ -19,6 +19,7 @@ import { selectPerpsMarketFilterPreferences, } from '../selectors/perpsController'; import Engine from '../../../../core/Engine'; +import { getSuggestedWatchlistMarkets } from '../utils/marketUtils'; interface UsePerpsMarketListViewParams { /** @@ -85,6 +86,12 @@ interface UsePerpsMarketListViewReturn { favoritesState: { showFavoritesOnly: boolean; setShowFavoritesOnly: (show: boolean) => void; + /** True when the user has at least one market on their watchlist */ + hasWatchlistMarkets: boolean; + /** Full market data objects for each watchlisted market */ + watchlistMarketObjects: PerpsMarketData[]; + /** Top suggested markets to show when the watchlist is empty */ + suggestedMarkets: PerpsMarketData[]; }; /** * Market type filter state (not persisted, UI-only) @@ -167,12 +174,40 @@ export const usePerpsMarketListView = ({ defaultMarketTypeFilter, ); + // Sync favorites filter when route params change (useState ignores new initials + // if the screen is already mounted, e.g. navigating from home watchlist header). + // Watchlist and category filters are mutually exclusive: activating the watchlist + // filter clears any active category so all watchlisted markets are visible. + useEffect(() => { + setShowFavoritesOnly(showWatchlistOnly); + if (showWatchlistOnly) { + setMarketTypeFilter('all'); + } + }, [showWatchlistOnly]); + // Sync filter when route params change (e.g. navigating from PerpsProducts // to an already-mounted market list screen — useState ignores new initials). useEffect(() => { setMarketTypeFilter(defaultMarketTypeFilter); }, [defaultMarketTypeFilter]); + // Wrapped setters that enforce mutual exclusivity between watchlist and category + // filters: turning on the watchlist clears the category, and selecting a category + // turns off the watchlist. + const handleSetShowFavoritesOnly = useCallback((show: boolean) => { + setShowFavoritesOnly(show); + if (show) { + setMarketTypeFilter('all'); + } + }, []); + + const handleSetMarketTypeFilter = useCallback((filter: MarketTypeFilter) => { + setMarketTypeFilter(filter); + if (filter !== 'all') { + setShowFavoritesOnly(false); + } + }, []); + // Use search hook for search state and filtering (search bar always visible in UI) const searchHook = usePerpsSearch({ markets: allMarkets }); @@ -243,6 +278,18 @@ export const usePerpsMarketListView = ({ ); }, [marketTypeFilteredMarkets, showFavoritesOnly, watchlistMarkets]); + // Full market objects for watchlisted symbols (unaffected by search/category filters) + const watchlistMarketObjects = useMemo( + () => allMarkets.filter((m) => watchlistMarkets.includes(m.symbol)), + [allMarkets, watchlistMarkets], + ); + + // Top suggested markets (excludes already-watchlisted) for the empty watchlist state + const suggestedMarkets = useMemo( + () => getSuggestedWatchlistMarkets(allMarkets, watchlistMarkets), + [allMarkets, watchlistMarkets], + ); + // Apply sorting to searched and favorites-filtered markets // Use useMemo to ensure sorting is applied with current sortBy/direction when markets change const finalMarkets = useMemo( @@ -291,11 +338,14 @@ export const usePerpsMarketListView = ({ }, favoritesState: { showFavoritesOnly, - setShowFavoritesOnly, + setShowFavoritesOnly: handleSetShowFavoritesOnly, + hasWatchlistMarkets: watchlistMarkets.length > 0, + watchlistMarketObjects, + suggestedMarkets, }, marketTypeFilterState: { marketTypeFilter, - setMarketTypeFilter, + setMarketTypeFilter: handleSetMarketTypeFilter, }, marketCounts, isLoading: isLoadingMarkets, diff --git a/app/components/UI/Perps/hooks/usePerpsTabExploreData.ts b/app/components/UI/Perps/hooks/usePerpsTabExploreData.ts new file mode 100644 index 00000000000..c88aa9ebbbc --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsTabExploreData.ts @@ -0,0 +1,76 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { usePerpsMarkets } from './usePerpsMarkets'; +import { selectPerpsWatchlistMarkets } from '../selectors/perpsController'; +import { type PerpsMarketData } from '@metamask/perps-controller'; +import { getSuggestedWatchlistMarkets } from '../utils/marketUtils'; + +const EXPLORE_MARKETS_LIMIT = 8; + +interface UsePerpsTabExploreDataOptions { + /** Skip fetching when user has positions/orders */ + enabled: boolean; +} + +interface UsePerpsTabExploreDataResult { + /** Top 8 markets by volume (all types) */ + exploreMarkets: PerpsMarketData[]; + /** Markets in user's watchlist */ + watchlistMarkets: PerpsMarketData[]; + /** Top 5 markets by 24h volume, used as suggestions when watchlist is empty */ + suggestedWatchlistMarkets: PerpsMarketData[]; + /** Loading state for markets data */ + isLoading: boolean; + /** Whether user has any watchlist symbols (available before markets load) */ + hasWatchlistSymbols: boolean; +} + +/** + * Business logic hook for Perps tab explore data. + * Used when user has no open positions or orders. + * + * Provides: + * - Top 8 markets by volume (all types) + * - Watchlist markets + */ +export const usePerpsTabExploreData = ({ + enabled, +}: UsePerpsTabExploreDataOptions): UsePerpsTabExploreDataResult => { + // Fetch markets (skip when disabled to avoid unnecessary data fetching) + const { markets, isLoading } = usePerpsMarkets({ + skipInitialFetch: !enabled, + showZeroVolume: false, + }); + + // Get watchlist symbols from Redux + const watchlistSymbols = useSelector(selectPerpsWatchlistMarkets); + + // Filter explore markets: all market types, top 8 by volume + // Markets are already sorted by volume from usePerpsMarkets + const exploreMarkets = useMemo(() => { + if (!enabled) return []; + return markets.slice(0, EXPLORE_MARKETS_LIMIT); + }, [markets, enabled]); + + // Filter watchlist markets + const watchlistMarkets = useMemo(() => { + if (!enabled) return []; + return markets.filter((m) => watchlistSymbols.includes(m.symbol)); + }, [markets, watchlistSymbols, enabled]); + + // Top markets by 24h volume — shown as suggestions below the watchlist. + // Excludes already-watchlisted markets so the list shrinks as items are added, + // with a floor of one via volume-ranked refill. + const suggestedWatchlistMarkets = useMemo(() => { + if (!enabled) return []; + return getSuggestedWatchlistMarkets(markets, watchlistSymbols); + }, [markets, watchlistSymbols, enabled]); + + return { + exploreMarkets, + watchlistMarkets, + suggestedWatchlistMarkets, + isLoading, + hasWatchlistSymbols: watchlistSymbols.length > 0, + }; +}; diff --git a/app/components/UI/Perps/hooks/usePerpsToasts.tsx b/app/components/UI/Perps/hooks/usePerpsToasts.tsx index 9f30df53e00..64b35a36acc 100644 --- a/app/components/UI/Perps/hooks/usePerpsToasts.tsx +++ b/app/components/UI/Perps/hooks/usePerpsToasts.tsx @@ -189,6 +189,10 @@ export interface PerpsToastOptionsConfig { shareFailed: PerpsToastOptions; }; }; + watchlist: { + addError: PerpsToastOptions; + limitReached: PerpsToastOptions; + }; } const getPerpsToastLabels = ( @@ -1003,6 +1007,20 @@ const usePerpsToasts = (): { }, }, }, + watchlist: { + addError: { + ...perpsBaseToastOptions.error, + labelOptions: getPerpsToastLabels( + strings('perps.watchlist.add_error'), + ), + }, + limitReached: { + ...perpsBaseToastOptions.info, + labelOptions: getPerpsToastLabels( + strings('perps.watchlist.limit_reached', { limit: 10 }), + ), + }, + }, }), [ navigationHandlers, diff --git a/app/components/UI/Perps/hooks/usePerpsWatchlistActions.test.ts b/app/components/UI/Perps/hooks/usePerpsWatchlistActions.test.ts new file mode 100644 index 00000000000..098518477c3 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsWatchlistActions.test.ts @@ -0,0 +1,211 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { usePerpsWatchlistActions } from './usePerpsWatchlistActions'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '@metamask/perps-controller'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockToggleWatchlistMarket = jest.fn(); +const mockGetWatchlistMarkets = jest.fn(() => ['BTC']); + +jest.mock('../../../../core/Engine', () => ({ + context: { + PerpsController: { + toggleWatchlistMarket: (...args: unknown[]) => + mockToggleWatchlistMarket(...args), + getWatchlistMarkets: () => mockGetWatchlistMarkets(), + }, + }, +})); + +jest.mock('../../../../util/Logger', () => ({ + error: jest.fn(), +})); + +jest.mock('../../../../util/errorUtils', () => ({ + ensureError: jest.fn((err: unknown, context: string) => + err instanceof Error ? err : new Error(`${context}: ${String(err)}`), + ), +})); + +const mockTrack = jest.fn(); +jest.mock('./usePerpsEventTracking', () => ({ + usePerpsEventTracking: jest.fn(() => ({ track: mockTrack })), +})); + +const mockShowToast = jest.fn(); +const mockAddError = { variant: 'error', labelOptions: [] }; +jest.mock('./usePerpsToasts', () => ({ + __esModule: true, + default: jest.fn(() => ({ + showToast: mockShowToast, + PerpsToastOptions: { + watchlist: { addError: mockAddError }, + }, + })), +})); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('usePerpsWatchlistActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetWatchlistMarkets.mockReturnValue(['BTC']); + }); + + describe('addToWatchlist', () => { + it('calls toggleWatchlistMarket with the correct symbol', async () => { + const { result } = renderHook(() => usePerpsWatchlistActions()); + + await act(async () => { + await result.current.addToWatchlist('ETH'); + }); + + expect(mockToggleWatchlistMarket).toHaveBeenCalledWith('ETH'); + }); + + it('fires PERPS_UI_INTERACTION analytics with FAVORITE_TOGGLED and FAVORITE_MARKET', async () => { + mockGetWatchlistMarkets.mockReturnValue(['BTC', 'ETH']); + + const { result } = renderHook(() => usePerpsWatchlistActions()); + + await act(async () => { + await result.current.addToWatchlist('ETH'); + }); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_UI_INTERACTION, + expect.objectContaining({ + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.FAVORITE_TOGGLED, + [PERPS_EVENT_PROPERTY.ACTION_TYPE]: + PERPS_EVENT_VALUE.ACTION_TYPE.FAVORITE_MARKET, + [PERPS_EVENT_PROPERTY.ASSET]: 'ETH', + [PERPS_EVENT_PROPERTY.FAVORITES_COUNT]: 2, + }), + ); + }); + + it('uses the provided source in analytics', async () => { + const { result } = renderHook(() => + usePerpsWatchlistActions(PERPS_EVENT_VALUE.SOURCE.PERPS_HOME_WATCHLIST), + ); + + await act(async () => { + await result.current.addToWatchlist('BTC'); + }); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_UI_INTERACTION, + expect.objectContaining({ + [PERPS_EVENT_PROPERTY.SOURCE]: + PERPS_EVENT_VALUE.SOURCE.PERPS_HOME_WATCHLIST, + }), + ); + }); + + it('calls Logger.error and shows error toast on failure', async () => { + const testError = new Error('controller failure'); + mockToggleWatchlistMarket.mockImplementationOnce(() => { + throw testError; + }); + + const Logger = jest.requireMock('../../../../util/Logger'); + const { result } = renderHook(() => usePerpsWatchlistActions()); + + await act(async () => { + await result.current.addToWatchlist('BTC'); + }); + + expect(Logger.error).toHaveBeenCalledWith( + testError, + expect.objectContaining({ + tags: expect.objectContaining({ + component: 'usePerpsWatchlistActions', + action: 'add_to_watchlist', + }), + }), + ); + expect(mockShowToast).toHaveBeenCalledWith(mockAddError); + }); + + it('does not call track when toggleWatchlistMarket throws', async () => { + mockToggleWatchlistMarket.mockImplementationOnce(() => { + throw new Error('fail'); + }); + + const { result } = renderHook(() => usePerpsWatchlistActions()); + + await act(async () => { + await result.current.addToWatchlist('BTC'); + }); + + expect(mockTrack).not.toHaveBeenCalled(); + }); + }); + + describe('removeFromWatchlist', () => { + it('calls toggleWatchlistMarket with the correct symbol', async () => { + const { result } = renderHook(() => usePerpsWatchlistActions()); + + await act(async () => { + await result.current.removeFromWatchlist('BTC'); + }); + + expect(mockToggleWatchlistMarket).toHaveBeenCalledWith('BTC'); + }); + + it('fires UNFAVORITE_MARKET analytics', async () => { + mockGetWatchlistMarkets.mockReturnValue([]); + + const { result } = renderHook(() => usePerpsWatchlistActions()); + + await act(async () => { + await result.current.removeFromWatchlist('BTC'); + }); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_UI_INTERACTION, + expect.objectContaining({ + [PERPS_EVENT_PROPERTY.ACTION_TYPE]: + PERPS_EVENT_VALUE.ACTION_TYPE.UNFAVORITE_MARKET, + [PERPS_EVENT_PROPERTY.ASSET]: 'BTC', + [PERPS_EVENT_PROPERTY.FAVORITES_COUNT]: 0, + }), + ); + }); + + it('calls Logger.error on failure without showing toast', async () => { + const testError = new Error('remove fail'); + mockToggleWatchlistMarket.mockImplementationOnce(() => { + throw testError; + }); + + const Logger = jest.requireMock('../../../../util/Logger'); + const { result } = renderHook(() => usePerpsWatchlistActions()); + + await act(async () => { + await result.current.removeFromWatchlist('BTC'); + }); + + expect(Logger.error).toHaveBeenCalledWith( + testError, + expect.objectContaining({ + tags: expect.objectContaining({ + component: 'usePerpsWatchlistActions', + action: 'remove_from_watchlist', + }), + }), + ); + // No toast on remove failure (intentional — no addError toast for removes) + expect(mockShowToast).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsWatchlistActions.ts b/app/components/UI/Perps/hooks/usePerpsWatchlistActions.ts new file mode 100644 index 00000000000..ebef459276b --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsWatchlistActions.ts @@ -0,0 +1,139 @@ +import { useCallback } from 'react'; +import { + PERPS_CONSTANTS, + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '@metamask/perps-controller'; +import Engine from '../../../../core/Engine'; +import Logger from '../../../../util/Logger'; +import { ensureError } from '../../../../util/errorUtils'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { usePerpsEventTracking } from './usePerpsEventTracking'; +import usePerpsToasts from './usePerpsToasts'; +import { WATCHLIST_LIMIT } from '../utils/marketUtils'; + +interface UsePerpsWatchlistActionsResult { + /** + * Adds a market to the watchlist. + * + * Currently wraps the synchronous PerpsController.toggleWatchlistMarket call. + * + * TODO(TAT-2663 — optimistic UI): Before the await, apply an optimistic local + * state update (e.g. pass an onOptimisticUpdate callback from the caller). + * On failure, roll back that state and show the addError toast below. + * + * TODO(TAT-2663 — User Storage API): Replace / augment the controller call + * with a POST to the User Storage API once it is available. + */ + addToWatchlist: (symbol: string) => Promise; + /** + * Removes a market from the watchlist. + * + * Same optimistic UI and User Storage seams as addToWatchlist above. + */ + removeFromWatchlist: (symbol: string) => Promise; +} + +/** + * Provides add/remove watchlist actions with analytics, error reporting, + * and structural seams for optimistic UI and the future User Storage API. + * + * @param source - Analytics source identifying where the action was triggered + * (e.g. PERPS_EVENT_VALUE.SOURCE.PERPS_HOME_WATCHLIST). + */ +export const usePerpsWatchlistActions = ( + source: string = PERPS_EVENT_VALUE.SOURCE.PERPS_HOME_WATCHLIST, +): UsePerpsWatchlistActionsResult => { + const { track } = usePerpsEventTracking(); + const { showToast, PerpsToastOptions } = usePerpsToasts(); + + const addToWatchlist = useCallback( + async (symbol: string): Promise => { + try { + const controller = Engine.context.PerpsController; + + if (controller.getWatchlistMarkets().length >= WATCHLIST_LIMIT) { + showToast(PerpsToastOptions.watchlist.limitReached); + return; + } + + // TODO(TAT-2663 — optimistic UI): Call onOptimisticUpdate?.() here + // to instantly reflect the add in the UI before the async call resolves. + + controller.toggleWatchlistMarket(symbol); + + // TODO(TAT-2663 — User Storage API): await userStorageApi.addWatchlistMarket(symbol) + + const watchlistCount = controller.getWatchlistMarkets().length; + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.FAVORITE_TOGGLED, + [PERPS_EVENT_PROPERTY.ACTION_TYPE]: + PERPS_EVENT_VALUE.ACTION_TYPE.FAVORITE_MARKET, + [PERPS_EVENT_PROPERTY.ASSET]: symbol, + [PERPS_EVENT_PROPERTY.SOURCE]: source, + [PERPS_EVENT_PROPERTY.FAVORITES_COUNT]: watchlistCount, + }); + } catch (error) { + // TODO(TAT-2663 — optimistic UI): Roll back the optimistic state + // update here (e.g. call onRollback?.()) before showing the error toast. + + Logger.error(ensureError(error, 'usePerpsWatchlistActions.add'), { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + component: 'usePerpsWatchlistActions', + action: 'add_to_watchlist', + }, + context: { + name: 'usePerpsWatchlistActions', + data: { symbol, source }, + }, + }); + + showToast(PerpsToastOptions.watchlist.addError); + } + }, + [source, track, showToast, PerpsToastOptions], + ); + + const removeFromWatchlist = useCallback( + async (symbol: string): Promise => { + try { + // TODO(TAT-2663 — optimistic UI): Apply optimistic remove here. + + const controller = Engine.context.PerpsController; + controller.toggleWatchlistMarket(symbol); + + // TODO(TAT-2663 — User Storage API): await userStorageApi.removeWatchlistMarket(symbol) + + const watchlistCount = controller.getWatchlistMarkets().length; + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.FAVORITE_TOGGLED, + [PERPS_EVENT_PROPERTY.ACTION_TYPE]: + PERPS_EVENT_VALUE.ACTION_TYPE.UNFAVORITE_MARKET, + [PERPS_EVENT_PROPERTY.ASSET]: symbol, + [PERPS_EVENT_PROPERTY.SOURCE]: source, + [PERPS_EVENT_PROPERTY.FAVORITES_COUNT]: watchlistCount, + }); + } catch (error) { + // TODO(TAT-2663 — optimistic UI): Roll back the optimistic remove here. + + Logger.error(ensureError(error, 'usePerpsWatchlistActions.remove'), { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + component: 'usePerpsWatchlistActions', + action: 'remove_from_watchlist', + }, + context: { + name: 'usePerpsWatchlistActions', + data: { symbol, source }, + }, + }); + } + }, + [source, track], + ); + + return { addToWatchlist, removeFromWatchlist }; +}; diff --git a/app/components/UI/Perps/hooks/usePerpsWithdrawStatus.test.ts b/app/components/UI/Perps/hooks/usePerpsWithdrawStatus.test.ts index f3669b6b510..8ac207a6ef1 100644 --- a/app/components/UI/Perps/hooks/usePerpsWithdrawStatus.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsWithdrawStatus.test.ts @@ -105,6 +105,8 @@ describe('usePerpsWithdrawStatus', () => { dataFetching: {} as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any contentSharing: {} as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + watchlist: {} as any, }; mockUsePerpsToasts.mockReturnValue({ diff --git a/app/components/UI/Perps/utils/marketDataTransform.test.ts b/app/components/UI/Perps/utils/marketDataTransform.test.ts index ddb9237edc6..c31c9b3a571 100644 --- a/app/components/UI/Perps/utils/marketDataTransform.test.ts +++ b/app/components/UI/Perps/utils/marketDataTransform.test.ts @@ -68,7 +68,7 @@ describe('marketDataTransform', () => { expect(result).toHaveLength(1); expect(result[0]).toEqual({ symbol: 'BTC', - name: 'BTC', + name: 'Bitcoin', // Human-readable name from HYPERLIQUID_ASSET_NAMES (@metamask/perps-controller 8.1.0) maxLeverage: '50x', price: '$52,000', // PRICE_RANGES_UNIVERSAL: 5 sig figs, 0 decimals for $10k-$100k change24h: '+$2,000', // No trailing zeros diff --git a/app/components/UI/Perps/utils/marketUtils.test.ts b/app/components/UI/Perps/utils/marketUtils.test.ts index bd955c4cd8f..547474fff8c 100644 --- a/app/components/UI/Perps/utils/marketUtils.test.ts +++ b/app/components/UI/Perps/utils/marketUtils.test.ts @@ -12,7 +12,12 @@ import { type CandleData, type PerpsMarketData, } from '@metamask/perps-controller'; -import { getAssetIconUrl, getAssetIconUrls } from './marketUtils'; +import { + getAssetIconUrl, + getAssetIconUrls, + getSuggestedWatchlistMarkets, + SUGGESTED_WATCHLIST_LIMIT, +} from './marketUtils'; jest.mock('@metamask/perps-controller', () => { const actual = jest.requireActual('@metamask/perps-controller'); @@ -988,6 +993,92 @@ describe('marketUtils', () => { }); }); + describe('getSuggestedWatchlistMarkets', () => { + const makeMarket = (symbol: string): Partial => ({ + symbol, + }); + // 8 markets sorted by volume desc (index = rank) + const pool = ['BTC', 'ETH', 'SOL', 'BNB', 'ARB', 'AVAX', 'DOT', 'LINK'].map( + makeMarket, + ) as PerpsMarketData[]; + + it('returns limit suggestions when watchlist is empty', () => { + const result = getSuggestedWatchlistMarkets(pool, []); + expect(result.map((m) => m.symbol)).toEqual([ + 'BTC', + 'ETH', + 'SOL', + 'BNB', + 'ARB', + ]); + }); + + it('returns limit-1 suggestions when 1 item is watchlisted', () => { + const result = getSuggestedWatchlistMarkets(pool, ['BTC']); + expect(result).toHaveLength(4); + expect(result.map((m) => m.symbol)).toEqual(['ETH', 'SOL', 'BNB', 'ARB']); + }); + + it('returns limit-2 suggestions when 2 items are watchlisted', () => { + const result = getSuggestedWatchlistMarkets(pool, ['BTC', 'ETH']); + expect(result).toHaveLength(3); + expect(result.map((m) => m.symbol)).toEqual(['SOL', 'BNB', 'ARB']); + }); + + it('returns limit-3 suggestions when 3 items are watchlisted', () => { + const result = getSuggestedWatchlistMarkets(pool, ['BTC', 'ETH', 'SOL']); + expect(result).toHaveLength(2); + expect(result.map((m) => m.symbol)).toEqual(['BNB', 'ARB']); + }); + + it('returns 1 suggestion when 4 items are watchlisted (floor)', () => { + const result = getSuggestedWatchlistMarkets(pool, [ + 'BTC', + 'ETH', + 'SOL', + 'BNB', + ]); + expect(result).toHaveLength(1); + expect(result.map((m) => m.symbol)).toEqual(['ARB']); + }); + + it('returns 1 suggestion when more than limit items are watchlisted (floor)', () => { + const result = getSuggestedWatchlistMarkets(pool, [ + 'BTC', + 'ETH', + 'SOL', + 'BNB', + 'ARB', + 'AVAX', + ]); + expect(result).toHaveLength(1); + expect(result.map((m) => m.symbol)).toEqual(['DOT']); + }); + + it('respects a custom limit', () => { + const result = getSuggestedWatchlistMarkets(pool, [], 3); + expect(result.map((m) => m.symbol)).toEqual(['BTC', 'ETH', 'SOL']); + }); + + it('reduces by watchlist count even when watchlisted items are outside the top pool', () => { + // LINK is rank 8 — well outside the default pool of 5 + const result = getSuggestedWatchlistMarkets(pool, ['LINK']); + // max(1, 5-1) = 4 suggestions, top-4 non-watchlisted = BTC ETH SOL BNB + expect(result).toHaveLength(4); + expect(result.map((m) => m.symbol)).toEqual(['BTC', 'ETH', 'SOL', 'BNB']); + }); + + it('returns empty array when every market is watchlisted', () => { + const symbols = pool.map((m) => m.symbol); + const result = getSuggestedWatchlistMarkets(pool, symbols); + expect(result).toEqual([]); + }); + + it('exports SUGGESTED_WATCHLIST_LIMIT as 5', () => { + expect(SUGGESTED_WATCHLIST_LIMIT).toBe(5); + }); + }); + describe('filterMarketsByQuery', () => { const mockMarkets: Partial[] = [ { symbol: 'BTC', name: 'Bitcoin' }, diff --git a/app/components/UI/Perps/utils/marketUtils.ts b/app/components/UI/Perps/utils/marketUtils.ts index b38f5240fdb..11e109e54ba 100644 --- a/app/components/UI/Perps/utils/marketUtils.ts +++ b/app/components/UI/Perps/utils/marketUtils.ts @@ -97,3 +97,34 @@ export const getAssetIconUrls = ( fallback: `${HYPERLIQUID_ASSET_ICONS_BASE_URL}${processedSymbol}.svg`, }; }; + +/** How many markets to offer as suggestions before watchlist exclusions. */ +export const SUGGESTED_WATCHLIST_LIMIT = 5; + +/** Maximum number of markets a user can add to their watchlist. */ +export const WATCHLIST_LIMIT = 10; + +/** + * Returns suggested watchlist markets shown beneath the user's watchlist. + * + * Target count shrinks with the watchlist size so that watchlist + suggestions + * always sums to `limit`, with a floor of one suggestion: + * 0 watchlisted → limit suggestions + * 1 watchlisted → limit - 1 suggestions + * … + * + * limit+ watchlisted → 1 suggestion + * + * Returns [] only when every available market is already watchlisted. + */ +export const getSuggestedWatchlistMarkets = ( + markets: PerpsMarketData[], + watchlistSymbols: string[], + limit = SUGGESTED_WATCHLIST_LIMIT, +): PerpsMarketData[] => { + const targetCount = Math.max(1, limit - watchlistSymbols.length); + const nonWatchlisted = markets.filter( + (m) => !watchlistSymbols.includes(m.symbol), + ); + return nonWatchlisted.slice(0, targetCount); +}; diff --git a/app/components/Views/Homepage/Homepage.test.tsx b/app/components/Views/Homepage/Homepage.test.tsx index 3226114ed78..c40b60f1938 100644 --- a/app/components/Views/Homepage/Homepage.test.tsx +++ b/app/components/Views/Homepage/Homepage.test.tsx @@ -245,6 +245,11 @@ jest.mock('@tanstack/react-query', () => { const actual = jest.requireActual('@tanstack/react-query'); return { ...actual, + useQuery: jest.fn(() => ({ + data: 510, + isLoading: false, + refetch: jest.fn().mockResolvedValue(undefined), + })), useQueryClient: jest.fn(() => ({ invalidateQueries: jest.fn(() => Promise.resolve()), })), diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx index cea4918dcd7..7b5d3ae2def 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx @@ -52,6 +52,15 @@ const worldCupHomepageMarketsMock = ( fetchMore: jest.fn(), }); +const worldCupEventCountMock = ( + eventCount: number | undefined = 510, + opts: { isFetching?: boolean } = {}, +) => ({ + eventCount, + isFetching: opts.isFetching ?? false, + refetch: jest.fn(), +}); + const HOMEPAGE_DISCOVERY_WINNER_MARKET = { id: 'market-1', title: '2026 FIFA World Cup Winner', @@ -204,6 +213,7 @@ jest.mock('./hooks', () => { const worldCupMock = jest.fn(() => worldCupMarketsWithDiscoveryChampionship(), ); + const worldCupEventCount = jest.fn(() => worldCupEventCountMock()); const nbaMock = jest.fn(() => worldCupHomepageMarketsMock([HOMEPAGE_DISCOVERY_NBA_CHAMPION_PARENT]), ); @@ -222,6 +232,7 @@ jest.mock('./hooks', () => { refetch: jest.fn(), })), useHomepagePredictWorldCupMarkets: worldCupMock, + useHomepagePredictWorldCupEventCount: worldCupEventCount, useHomepagePredictTaggedMarkets: jest.fn( ({ customQueryParams }: { customQueryParams: string }) => customQueryParams === tagQueries.nbaChampion @@ -229,6 +240,7 @@ jest.mock('./hooks', () => { : worldCupHomepageMarketsMock([]), ), __mockUsePredictWorldCupHomepageMarkets: worldCupMock, + __mockUsePredictWorldCupEventCount: worldCupEventCount, __mockUsePredictNbaChampionHomepageMarkets: nbaMock, }; }); @@ -254,6 +266,8 @@ const mockUsePredictPositionsForHomepage = jest.requireMock('./hooks').usePredictPositionsForHomepage; const mockUsePredictWorldCupHomepageMarkets = jest.requireMock('./hooks') .__mockUsePredictWorldCupHomepageMarkets as jest.Mock; +const mockUsePredictWorldCupEventCount = jest.requireMock('./hooks') + .__mockUsePredictWorldCupEventCount as jest.Mock; const mockUsePredictNbaChampionHomepageMarkets = jest.requireMock('./hooks') .__mockUsePredictNbaChampionHomepageMarkets as jest.Mock; const mockSelectPrivacyMode = jest.requireMock( @@ -386,6 +400,7 @@ describe('PredictionsSection', () => { mockUsePredictWorldCupHomepageMarkets.mockReturnValue( worldCupMarketsWithDiscoveryChampionship(), ); + mockUsePredictWorldCupEventCount.mockReturnValue(worldCupEventCountMock()); mockUsePredictNbaChampionHomepageMarkets.mockReturnValue( worldCupHomepageMarketsMock([HOMEPAGE_DISCOVERY_NBA_CHAMPION_PARENT]), ); @@ -860,6 +875,27 @@ describe('PredictionsSection', () => { expect(screen.getByText('Predictions')).toBeOnTheScreen(); }); + it('shows the World Cup API total with a plus sign in the discovery row', () => { + mockUsePredictMarketsForHomepage.mockReturnValue({ + markets: [], + isLoading: false, + error: null, + refetch: jest.fn(), + }); + mockUsePredictWorldCupHomepageMarkets.mockReturnValue( + worldCupHomepageMarketsMock([HOMEPAGE_DISCOVERY_WINNER_MARKET]), + ); + mockUsePredictWorldCupEventCount.mockReturnValue( + worldCupEventCountMock(48), + ); + + renderWithProvider( + , + ); + + expect(screen.getByText('48+ markets in total')).toBeOnTheScreen(); + }); + it('still renders treatment discovery when trending markets fail', async () => { mockUsePredictMarketsForHomepage.mockReturnValue({ markets: [], diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx index 95c57dc34b4..ad045370658 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx @@ -26,6 +26,7 @@ import { usePredictPositionsForHomepage, useHomepagePredictTaggedMarkets, useHomepagePredictWorldCupMarkets, + useHomepagePredictWorldCupEventCount, HOMEPAGE_PREDICT_TAG_QUERIES, usePredictHomepageDiscoveryExperiment, } from './hooks'; @@ -54,6 +55,7 @@ import type { TransactionActiveAbTestEntry } from '../../../../../util/transacti /** Loads both feeds the World Cup discovery rail needs (World Cup tag + NBA Champion event). */ const useWorldCupDiscoveryFeeds = (enabled: boolean) => ({ worldCup: useHomepagePredictWorldCupMarkets({ enabled }), + worldCupEventCount: useHomepagePredictWorldCupEventCount({ enabled }), nbaChampion: useHomepagePredictTaggedMarkets({ enabled, customQueryParams: HOMEPAGE_PREDICT_TAG_QUERIES.nbaChampion, @@ -336,14 +338,17 @@ const PredictionsSectionDefault = forwardRef< const { worldCup: worldCupHomepageMarkets, + worldCupEventCount, nbaChampion: nbaChampionHomepageMarkets, } = useWorldCupDiscoveryFeeds(isPredictEnabled && isTreatmentDiscovery); const { refetch: refetchWorldCupHomepageMarkets } = worldCupHomepageMarkets; + const { refetch: refetchWorldCupEventCount } = worldCupEventCount; const { refetch: refetchNbaChampionHomepageMarkets } = nbaChampionHomepageMarkets; const isLoadingWorldCupHomepage = useTreatmentDiscoveryFeedsLoading({ isTreatmentDiscovery, - isWorldCupFetching: worldCupHomepageMarkets.isFetching, + isWorldCupFetching: + worldCupHomepageMarkets.isFetching || worldCupEventCount.isFetching, isNbaChampionFetching: nbaChampionHomepageMarkets.isFetching, }); @@ -410,6 +415,7 @@ const PredictionsSectionDefault = forwardRef< const tasks: Promise[] = [refreshPositions(), refetchMarkets()]; if (isTreatmentDiscovery) { tasks.push(refetchWorldCupHomepageMarkets()); + tasks.push(refetchWorldCupEventCount()); tasks.push(refetchNbaChampionHomepageMarkets()); } await Promise.all(tasks); @@ -418,6 +424,7 @@ const PredictionsSectionDefault = forwardRef< refetchMarkets, isTreatmentDiscovery, refetchWorldCupHomepageMarkets, + refetchWorldCupEventCount, refetchNbaChampionHomepageMarkets, ]); @@ -458,6 +465,7 @@ const PredictionsSectionDefault = forwardRef< markets={markets} transactionActiveAbTests={discoveryTransactionActiveAbTests} worldCupHomepage={worldCupHomepageMarkets} + worldCupEventCount={worldCupEventCount.eventCount} nbaChampionHomepage={nbaChampionHomepageMarkets} emptyStateTransactionActiveAbTests={ discoveryTransactionActiveAbTests @@ -495,6 +503,7 @@ const PredictionsSectionDefault = forwardRef< markets={markets} transactionActiveAbTests={discoveryTransactionActiveAbTests} worldCupHomepage={worldCupHomepageMarkets} + worldCupEventCount={worldCupEventCount.eventCount} nbaChampionHomepage={nbaChampionHomepageMarkets} emptyStateTransactionActiveAbTests={ discoveryTransactionActiveAbTests @@ -626,9 +635,11 @@ const PredictionsSectionTrendingOnly = forwardRef< const isListLayout = discoveryLayout === 'list'; const { worldCup: worldCupHomepageMarkets, + worldCupEventCount, nbaChampion: nbaChampionHomepageMarkets, } = useWorldCupDiscoveryFeeds(isPredictEnabled && isListLayout); const { refetch: refetchWorldCupHomepageMarkets } = worldCupHomepageMarkets; + const { refetch: refetchWorldCupEventCount } = worldCupEventCount; const { refetch: refetchNbaChampionHomepageMarkets } = nbaChampionHomepageMarkets; @@ -641,6 +652,7 @@ const PredictionsSectionTrendingOnly = forwardRef< const tasks: Promise[] = [refetchMarkets()]; if (isListLayout) { tasks.push(refetchWorldCupHomepageMarkets()); + tasks.push(refetchWorldCupEventCount()); tasks.push(refetchNbaChampionHomepageMarkets()); } await Promise.all(tasks); @@ -648,6 +660,7 @@ const PredictionsSectionTrendingOnly = forwardRef< refetchMarkets, isListLayout, refetchWorldCupHomepageMarkets, + refetchWorldCupEventCount, refetchNbaChampionHomepageMarkets, ]); @@ -663,6 +676,7 @@ const PredictionsSectionTrendingOnly = forwardRef< isLoading={ isListLayout ? worldCupHomepageMarkets.isFetching || + worldCupEventCount.isFetching || nbaChampionHomepageMarkets.isFetching : isLoadingMarkets } @@ -685,6 +699,7 @@ const PredictionsSectionTrendingOnly = forwardRef< trendingTransactionActiveAbTests } worldCupHomepage={worldCupHomepageMarkets} + worldCupEventCount={worldCupEventCount.eventCount} nbaChampionHomepage={nbaChampionHomepageMarkets} /> @@ -717,18 +732,25 @@ const PredictionsSectionSportsOnly = forwardRef< const { worldCup: worldCupHomepageMarkets, + worldCupEventCount, nbaChampion: nbaChampionHomepageMarkets, } = useWorldCupDiscoveryFeeds(isPredictEnabled); const { refetch: refetchWorldCupHomepageMarkets } = worldCupHomepageMarkets; + const { refetch: refetchWorldCupEventCount } = worldCupEventCount; const { refetch: refetchNbaChampionHomepageMarkets } = nbaChampionHomepageMarkets; const refresh = useCallback(async () => { await Promise.all([ refetchWorldCupHomepageMarkets(), + refetchWorldCupEventCount(), refetchNbaChampionHomepageMarkets(), ]); - }, [refetchWorldCupHomepageMarkets, refetchNbaChampionHomepageMarkets]); + }, [ + refetchWorldCupHomepageMarkets, + refetchWorldCupEventCount, + refetchNbaChampionHomepageMarkets, + ]); return ( diff --git a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx index e30838f1da2..fad010c4132 100644 --- a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictTrendingMarkets.tsx @@ -20,6 +20,7 @@ export interface HomepagePredictTrendingMarketsProps { transactionActiveAbTests?: TransactionActiveAbTestEntry[]; /** Required when `discoveryLayout` is `list` (World Cup discovery rail). */ worldCupHomepage?: UseHomepagePredictWorldCupMarketsResult; + worldCupEventCount?: number; /** Required when `discoveryLayout` is `list` (NBA champion event, separate from World Cup tag). */ nbaChampionHomepage?: UseHomepagePredictTaggedMarketsResult; emptyStateTransactionActiveAbTests?: TransactionActiveAbTestEntry[]; @@ -38,6 +39,7 @@ const HomepagePredictTrendingMarkets = ({ markets, transactionActiveAbTests, worldCupHomepage, + worldCupEventCount, nbaChampionHomepage, emptyStateTransactionActiveAbTests, onEmptyStateTreatmentCtaClick, @@ -65,6 +67,7 @@ const HomepagePredictTrendingMarkets = ({ onViewAll={onViewAll} headerTestIdKey={headerTestIdKey} worldCup={worldCupHomepage} + worldCupEventCount={worldCupEventCount} nbaChampion={nbaChampionHomepage} transactionActiveAbTests={emptyStateTransactionActiveAbTests} onTreatmentCtaClick={onEmptyStateTreatmentCtaClick} diff --git a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/MensWorldCupRow.tsx b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/MensWorldCupRow.tsx index a153c86ebde..2ce8e9d8c7b 100644 --- a/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/MensWorldCupRow.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/components/HomepagePredictWorldCupDiscovery/MensWorldCupRow.tsx @@ -17,7 +17,7 @@ import HomepagePredictDiscoveryMaterialGlyph from './HomepagePredictDiscoveryMat interface MensWorldCupRowProps { onPress: () => void; - eventCountLabel: string; + eventCountLabel?: string; } const MensWorldCupRow = ({ @@ -48,13 +48,15 @@ const MensWorldCupRow = ({ > {strings('predict.homepage_discovery.fifa_world_cup_2026_title')} - - {eventCountLabel} - + {eventCountLabel ? ( + + {eventCountLabel} + + ) : null} void; headerTestIdKey: PredictionsTrendingHeaderTestId; worldCup: UseHomepagePredictWorldCupMarketsResult; + worldCupEventCount?: number; nbaChampion: UseHomepagePredictTaggedMarketsResult; transactionActiveAbTests?: TransactionActiveAbTestEntry[]; onTreatmentCtaClick?: ( @@ -62,6 +63,7 @@ const HomepagePredictWorldCupDiscovery: React.FC< onViewAll, headerTestIdKey, worldCup, + worldCupEventCount, nbaChampion, transactionActiveAbTests, onTreatmentCtaClick, @@ -93,7 +95,7 @@ const HomepagePredictWorldCupDiscovery: React.FC< ? WORLD_CUP_CTA_CATEGORY_NAME : 'nba'; - const { marketData, isFetching, hasMore } = worldCup; + const { marketData, isFetching } = worldCup; const { marketData: nbaMarketData, isFetching: isNbaFetching } = nbaChampion; const isInitialLoad = isFetching && marketData.length === 0; @@ -102,14 +104,15 @@ const HomepagePredictWorldCupDiscovery: React.FC< isNbaFetching && nbaMarketData.length === 0; - const eventCountLabel = useMemo(() => { - const n = marketData.length; - const i18nKey = - n > 0 && hasMore - ? 'predict.homepage_discovery.events_in_total_overflow' - : 'predict.homepage_discovery.events_in_total'; - return strings(i18nKey, { count: n }); - }, [marketData.length, hasMore]); + const eventCountLabel = useMemo( + () => + worldCupEventCount === undefined + ? undefined + : strings('predict.homepage_discovery.events_in_total_overflow', { + count: worldCupEventCount, + }), + [worldCupEventCount], + ); const championshipRow: ChampionshipRowState = useMemo(() => { if (championshipRowKind === 'nba') { diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.test.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.test.ts new file mode 100644 index 00000000000..a85d9b25b11 --- /dev/null +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.test.ts @@ -0,0 +1,149 @@ +import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react-native'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useSelector } from 'react-redux'; +import { DEFAULT_PREDICT_WORLD_CUP_FLAG } from '../../../../../UI/Predict/constants/flags'; +import { usePredictWorldCupMarkets } from '../../../../../UI/Predict/hooks/usePredictWorldCup'; +import { + useHomepagePredictWorldCupEventCount, + useHomepagePredictWorldCupMarkets, +} from './useHomepagePredictWorldCupMarkets'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../../UI/Predict/hooks/usePredictWorldCup', () => ({ + usePredictWorldCupMarkets: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.Mock; +const mockUsePredictWorldCupMarkets = jest.mocked(usePredictWorldCupMarkets); +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { cacheTime: Infinity, retry: false } }, + }); + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + return Wrapper; +}; + +describe('useHomepagePredictWorldCupMarkets', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockReturnValue(DEFAULT_PREDICT_WORLD_CUP_FLAG); + mockUsePredictWorldCupMarkets.mockReturnValue({ + marketData: [{ id: 'market-1' }] as never, + isFetching: false, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); + }); + + it('fetches the homepage World Cup event count using the configured World Cup tag', async () => { + mockUseSelector.mockReturnValue({ + ...DEFAULT_PREDICT_WORLD_CUP_FLAG, + tagSlug: 'remote-configured-world-cup-tag', + }); + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + pagination: { totalResults: 48 }, + }), + }); + + const { result } = renderHook( + () => useHomepagePredictWorldCupEventCount({ enabled: true }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.eventCount).toBe(48)); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://gamma-api.polymarket.com/events/pagination?tag_slug=remote-configured-world-cup-tag&limit=1&active=true&closed=false&archived=false', + ); + }); + + it('omits the event count when pagination metadata is missing', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + pagination: {}, + }), + }); + + const { result } = renderHook( + () => useHomepagePredictWorldCupEventCount({ enabled: true }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.eventCount).toBeUndefined(); + }); + + it('omits the event count when pagination totalResults is not a number', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ + pagination: { totalResults: '48' }, + }), + }); + + const { result } = renderHook( + () => useHomepagePredictWorldCupEventCount({ enabled: true }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.eventCount).toBeUndefined(); + }); + + it('does not fall back to the loaded market count while the event count is loading', () => { + mockUsePredictWorldCupMarkets.mockReturnValue({ + marketData: Array.from({ length: 19 }, (_, index) => ({ + id: `market-${index}`, + })) as never, + isFetching: false, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: jest.fn(), + fetchMore: jest.fn(), + }); + mockFetch.mockReturnValue(new Promise(() => undefined)); + + const { result } = renderHook( + () => useHomepagePredictWorldCupEventCount({ enabled: true }), + { wrapper: createWrapper() }, + ); + + expect(result.current.eventCount).toBeUndefined(); + expect(result.current.isFetching).toBe(true); + }); + + it('does not request the event count when the homepage query is disabled', () => { + renderHook(() => useHomepagePredictWorldCupEventCount({ enabled: false }), { + wrapper: createWrapper(), + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('keeps the market hook scoped to market data only', () => { + const { result } = renderHook( + () => useHomepagePredictWorldCupMarkets({ enabled: true }), + { wrapper: createWrapper() }, + ); + + expect(result.current).not.toHaveProperty('eventCount'); + expect(result.current).not.toHaveProperty('totalResults'); + }); +}); diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.ts index f8d37dde11b..b856da5c145 100644 --- a/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.ts +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/useHomepagePredictWorldCupMarkets.ts @@ -1,20 +1,108 @@ import { useSelector } from 'react-redux'; +import { useQuery } from '@tanstack/react-query'; import { usePredictWorldCupMarkets } from '../../../../../UI/Predict/hooks/usePredictWorldCup'; import type { UsePredictMarketDataResult } from '../../../../../UI/Predict/hooks/usePredictMarketData'; import { PREDICT_WORLD_CUP_TAB_KEYS } from '../../../../../UI/Predict/constants/worldCupTabs'; import { selectPredictWorldCupConfig } from '../../../../../UI/Predict/selectors/featureFlags'; +import { getPolymarketEndpoints } from '../../../../../UI/Predict/providers/polymarket/utils'; interface UseHomepagePredictWorldCupMarketsArgs { enabled: boolean; } +interface UseHomepagePredictWorldCupEventCountResult { + eventCount?: number; + isFetching: boolean; + refetch: () => Promise; +} + +const HOMEPAGE_PREDICT_WORLD_CUP_EVENT_COUNT_LIMIT = 1; +const HOMEPAGE_PREDICT_WORLD_CUP_EVENT_COUNT_STALE_TIME = 10_000; + +const homepagePredictWorldCupEventCountKeys = { + all: (queryParams: string) => + [ + 'homepage', + 'predict', + 'worldCup', + 'eventCount', + 'paginationTotalResults', + queryParams, + ] as const, +}; + +const buildHomepagePredictWorldCupEventCountQuery = ( + tagSlug: string, +): string => { + const queryParams = new URLSearchParams(); + queryParams.set('tag_slug', tagSlug); + queryParams.set( + 'limit', + String(HOMEPAGE_PREDICT_WORLD_CUP_EVENT_COUNT_LIMIT), + ); + queryParams.set('active', 'true'); + queryParams.set('closed', 'false'); + queryParams.set('archived', 'false'); + + return queryParams.toString(); +}; + +const fetchHomepagePredictWorldCupEventCount = async ( + queryParams: string, +): Promise => { + const { GAMMA_API_ENDPOINT } = getPolymarketEndpoints(); + + const response = await fetch( + `${GAMMA_API_ENDPOINT}/events/pagination?${queryParams}`, + ); + + if (!response.ok) { + throw new Error('Failed to get homepage World Cup event count'); + } + + const data = await response.json(); + const totalResults = data?.pagination?.totalResults; + + return typeof totalResults === 'number' ? totalResults : null; +}; + +/** + * Homepage discovery: loads World Cup markets using the same ALL-tab query path + * as the dedicated World Cup screen, but keeps its event-count query private to + * this hook so `/events/pagination` is only requested by the homepage row. + */ +export function useHomepagePredictWorldCupEventCount({ + enabled, +}: UseHomepagePredictWorldCupMarketsArgs): UseHomepagePredictWorldCupEventCountResult { + const config = useSelector(selectPredictWorldCupConfig); + const eventCountQueryParams = buildHomepagePredictWorldCupEventCountQuery( + config.tagSlug, + ); + const eventCountQuery = useQuery({ + queryKey: homepagePredictWorldCupEventCountKeys.all(eventCountQueryParams), + queryFn: () => + fetchHomepagePredictWorldCupEventCount(eventCountQueryParams), + staleTime: HOMEPAGE_PREDICT_WORLD_CUP_EVENT_COUNT_STALE_TIME, + enabled, + }); + const refetch = async () => { + await eventCountQuery.refetch(); + }; + + return { + eventCount: eventCountQuery.data ?? undefined, + isFetching: eventCountQuery.isLoading, + refetch, + }; +} + /** * Homepage discovery: loads World Cup markets using the same ALL-tab query path - * as the dedicated World Cup screen (`buildPredictWorldCupAllQuery` → keyset API). + * as the dedicated World Cup screen. */ export function useHomepagePredictWorldCupMarkets({ enabled, -}: UseHomepagePredictWorldCupMarketsArgs): UsePredictMarketDataResult { +}: UseHomepagePredictWorldCupMarketsArgs): UseHomepagePredictWorldCupMarketsResult { const config = useSelector(selectPredictWorldCupConfig); return usePredictWorldCupMarkets({ diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx index 5dcbd2f262a..ebff26edc3c 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx @@ -23,7 +23,7 @@ jest.mock('./hooks/useQuickBuySetup', () => ({ })); let storedOnOpenCallback: (() => void) | undefined; -const mockOnCloseBottomSheet = jest.fn((cb?: () => void) => cb?.()); +const mockOnCloseDialog = jest.fn((cb?: () => void) => cb?.()); jest.mock('@metamask/design-system-react-native', () => { const actual = jest.requireActual('@metamask/design-system-react-native'); @@ -32,7 +32,7 @@ jest.mock('@metamask/design-system-react-native', () => { return { ...actual, - BottomSheet: ReactMock.forwardRef( + BottomSheetDialog: ReactMock.forwardRef( ( { children, @@ -44,14 +44,14 @@ jest.mock('@metamask/design-system-react-native', () => { ref: unknown, ) => { ReactMock.useImperativeHandle(ref, () => ({ - onOpenBottomSheet: (cb: () => void) => { + onOpenDialog: (cb: () => void) => { storedOnOpenCallback = cb; }, - onCloseBottomSheet: mockOnCloseBottomSheet, + onCloseDialog: mockOnCloseDialog, })); return ReactMock.createElement( View, - { testID: 'mock-bottom-sheet', onTouchEnd: onClose }, + { testID: 'mock-bottom-sheet-dialog', onTouchEnd: onClose }, children, ); }, @@ -254,6 +254,7 @@ describe('QuickBuyRoot', () => { beforeEach(() => { jest.clearAllMocks(); storedOnOpenCallback = undefined; + mockOnCloseDialog.mockImplementation((cb?: () => void) => cb?.()); (useQuickBuyController as jest.Mock).mockReturnValue(buildHookResult()); (useQuickBuySetup as jest.Mock).mockReturnValue({ chainId: '0x1', @@ -421,7 +422,7 @@ describe('QuickBuyRoot', () => { return ; }; - it('animates the sheet down via onCloseBottomSheet and runs the parent onClose', () => { + it('animates the sheet down via onCloseDialog and runs the parent onClose', () => { const onClose = jest.fn(); renderWithProvider( { fireEvent.press(screen.getByTestId('probe-close')); }); - expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + expect(mockOnCloseDialog).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx index 91e14365a59..4163df32c22 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.tsx @@ -1,6 +1,6 @@ import { - BottomSheet, - type BottomSheetRef, + BottomSheetDialog, + type BottomSheetDialogRef, Box, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -83,7 +83,7 @@ const QuickBuyRootInner: React.FC = ({ children, }) => { const tw = useTailwind(); - const bottomSheetRef = useRef(null); + const bottomSheetRef = useRef(null); const [isContentReady, setIsContentReady] = useState(false); const [activeScreen, setActiveScreen] = useState('amount'); const [lockedHeight, setLockedHeight] = useState(null); @@ -115,7 +115,7 @@ const QuickBuyRootInner: React.FC = ({ ); useEffect(() => { - bottomSheetRef.current?.onOpenBottomSheet(() => { + bottomSheetRef.current?.onOpenDialog(() => { setIsContentReady(true); }); }, []); @@ -126,8 +126,8 @@ const QuickBuyRootInner: React.FC = ({ const requestClose = useCallback(() => { setIsClosing(true); const sheet = bottomSheetRef.current; - if (sheet?.onCloseBottomSheet) { - sheet.onCloseBottomSheet(onClose); + if (sheet?.onCloseDialog) { + sheet.onCloseDialog(onClose); } else { onClose(); } @@ -147,7 +147,7 @@ const QuickBuyRootInner: React.FC = ({ ); return ( - = ({ )} - + ); }; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx index 37150175445..5638bd5c46d 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx @@ -28,7 +28,7 @@ jest.mock('./hooks/useQuickBuySetup', () => ({ useQuickBuySetup: jest.fn(), })); -// Captures the onOpenBottomSheet callback registered by QuickBuyBottomSheetInner. +// Captures the onOpenDialog callback registered by QuickBuyRootInner. // Call storedOnOpenCallback() inside act() after render to simulate the sheet // finishing its open animation and make isContentReady become true. let storedOnOpenCallback: (() => void) | undefined; @@ -41,7 +41,7 @@ jest.mock('@metamask/design-system-react-native', () => { return { ...actual, - BottomSheet: ReactMock.forwardRef( + BottomSheetDialog: ReactMock.forwardRef( ( { children, @@ -53,13 +53,14 @@ jest.mock('@metamask/design-system-react-native', () => { ref: unknown, ) => { ReactMock.useImperativeHandle(ref, () => ({ - onOpenBottomSheet: (cb: () => void) => { + onOpenDialog: (cb: () => void) => { storedOnOpenCallback = cb; }, + onCloseDialog: (cb?: () => void) => cb?.(), })); return ReactMock.createElement( View, - { testID: 'mock-bottom-sheet', onTouchEnd: onClose }, + { testID: 'mock-bottom-sheet-dialog', onTouchEnd: onClose }, children, ); }, @@ -305,7 +306,9 @@ describe('QuickBuy.Root', () => { />, ); - expect(screen.queryByTestId('mock-bottom-sheet')).not.toBeOnTheScreen(); + expect( + screen.queryByTestId('mock-bottom-sheet-dialog'), + ).not.toBeOnTheScreen(); }); it('mounts the inner sheet when visible with a valid position', () => { @@ -318,7 +321,7 @@ describe('QuickBuy.Root', () => { />, ); - expect(screen.getByTestId('mock-bottom-sheet')).toBeOnTheScreen(); + expect(screen.getByTestId('mock-bottom-sheet-dialog')).toBeOnTheScreen(); }); }); diff --git a/app/components/Views/confirmations/components/AccountSelector/AccountSelector.test.tsx b/app/components/Views/confirmations/components/AccountSelector/AccountSelector.test.tsx index d7246ba1d8b..16805d6f7f0 100644 --- a/app/components/Views/confirmations/components/AccountSelector/AccountSelector.test.tsx +++ b/app/components/Views/confirmations/components/AccountSelector/AccountSelector.test.tsx @@ -325,6 +325,114 @@ describe('AccountSelector', () => { expect(queryByText('confirm.label.to')).toBeNull(); }); + it('includes wallet name in label when multiple wallets exist', () => { + const multiWalletSections = [ + { + title: 'Wallet 1', + wallet: { id: 'wallet-1' }, + data: [ + { + id: 'group-1', + accounts: ['account-1'], + metadata: { name: 'Account 1' }, + }, + ], + }, + { + title: 'Wallet 2', + wallet: { id: 'wallet-2' }, + data: [ + { + id: 'group-2', + accounts: ['account-2'], + metadata: { name: 'Account 2' }, + }, + ], + }, + ]; + + const multiWalletAccountToGroupMap = { + ...mockAccountToGroupMap, + 'account-2': { + id: 'group-2', + accounts: ['account-2'], + metadata: { name: 'Account 2' }, + }, + }; + + const { useSelector } = jest.requireMock('react-redux'); + const { selectAccountGroupsByWallet, selectAccountToGroupMap } = + jest.requireMock( + '../../../../../selectors/multichainAccounts/accountTreeController', + ); + const { selectInternalAccountsById } = jest.requireMock( + '../../../../../selectors/accountsController', + ); + const { selectAvatarAccountType } = jest.requireMock( + '../../../../../selectors/settings', + ); + + useSelector.mockImplementation( + (selector: (...args: unknown[]) => unknown) => { + if (selector === selectInternalAccountsById) + return mockInternalAccountsById; + if (selector === selectAccountGroupsByWallet) + return multiWalletSections; + if (selector === selectAccountToGroupMap) + return multiWalletAccountToGroupMap; + if (selector === selectAvatarAccountType) return 'HD Key Tree'; + return undefined; + }, + ); + + const { getByText } = render( + , + ); + + expect(getByText('From Wallet 1')).toBeOnTheScreen(); + }); + + it('does not include wallet name in label with a single wallet', () => { + const { useSelector } = jest.requireMock('react-redux'); + const { selectAccountGroupsByWallet, selectAccountToGroupMap } = + jest.requireMock( + '../../../../../selectors/multichainAccounts/accountTreeController', + ); + const { selectInternalAccountsById } = jest.requireMock( + '../../../../../selectors/accountsController', + ); + const { selectAvatarAccountType } = jest.requireMock( + '../../../../../selectors/settings', + ); + + useSelector.mockImplementation( + (selector: (...args: unknown[]) => unknown) => { + if (selector === selectInternalAccountsById) + return mockInternalAccountsById; + if (selector === selectAccountGroupsByWallet) + return mockAccountGroupsByWallet; + if (selector === selectAccountToGroupMap) return mockAccountToGroupMap; + if (selector === selectAvatarAccountType) return 'HD Key Tree'; + return undefined; + }, + ); + + const { getByText, queryByText } = render( + , + ); + + expect(getByText('From')).toBeOnTheScreen(); + expect(queryByText('From Wallet 1')).toBeNull(); + }); + it('uses custom selector title in the sheet header when provided', () => { const { getByTestId, getByText } = render( = ({ const accountName = selectedAccountGroup?.metadata?.name; + const displayLabel = useMemo(() => { + if ( + !filteredAccountSections || + filteredAccountSections.length <= 1 || + !selectedAccountGroup + ) { + return label; + } + + const walletName = filteredAccountSections.find((section) => + section.data.some((group) => group.id === selectedAccountGroup.id), + )?.title; + + return walletName ? `${label} ${walletName}` : label; + }, [filteredAccountSections, selectedAccountGroup, label]); + return ( = ({ testID={ACCOUNT_SELECTOR_TEST_IDS.PILL} > - {label} + {displayLabel} {selectedAddress && accountName ? ( diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.styles.test.ts b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.styles.test.ts index 042909bc736..28354c82898 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.styles.test.ts +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.styles.test.ts @@ -13,8 +13,8 @@ describe('custom-amount-info.styles', () => { }); }); - describe('extraBottomPadding', () => { - it('applies 56dp paddingBottom on Android', () => { + describe('bottomBlock', () => { + it('applies 16dp paddingBottom on Android', () => { Object.defineProperty(Platform, 'OS', { value: 'android', writable: true, @@ -22,7 +22,7 @@ describe('custom-amount-info.styles', () => { const styles = styleSheet({ theme: mockTheme as Theme }); - expect(styles.extraBottomPadding.paddingBottom).toBe(56); + expect(styles.bottomBlock.paddingBottom).toBe(16); }); it('applies 0 paddingBottom on iOS so the iOS layout is unchanged', () => { @@ -33,7 +33,7 @@ describe('custom-amount-info.styles', () => { const styles = styleSheet({ theme: mockTheme as Theme }); - expect(styles.extraBottomPadding.paddingBottom).toBe(0); + expect(styles.bottomBlock.paddingBottom).toBe(0); }); }); }); diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.styles.ts b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.styles.ts index 9515d68a40e..a073155e5ef 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.styles.ts +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.styles.ts @@ -1,8 +1,6 @@ import { Platform, StyleSheet } from 'react-native'; import { Theme } from '../../../../../../util/theme/models'; -const EXTRA_ANDROID_BOTTOM_PADDING = 56; - const styleSheet = (params: { theme: Theme }) => { const { theme } = params; return StyleSheet.create({ @@ -19,9 +17,8 @@ const styleSheet = (params: { theme: Theme }) => { gap: 14, }, - extraBottomPadding: { - paddingBottom: - Platform.OS === 'android' ? EXTRA_ANDROID_BOTTOM_PADDING : 0, + bottomBlock: { + paddingBottom: Platform.OS === 'android' ? 16 : 0, }, disabledButton: { diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx index 2ac715f1f9c..aab04aea152 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx @@ -358,7 +358,7 @@ describe('CustomAmountInfo', () => { expect(getByTestId('deposit-keyboard')).toBeDefined(); }); - describe('hasExtraBottomPadding', () => { + describe('bottomBlock', () => { const originalPlatformOS = Platform.OS; afterEach(() => { @@ -368,43 +368,30 @@ describe('CustomAmountInfo', () => { }); }); - it('applies 56dp paddingBottom to the bottom block on Android when hasExtraBottomPadding is true', () => { + it('applies 16dp paddingBottom to the bottom block on Android', () => { Object.defineProperty(Platform, 'OS', { value: 'android', writable: true, }); - const { getByTestId } = render({ hasExtraBottomPadding: true }); + const { getByTestId } = render(); expect(getByTestId(CustomAmountInfoTestIds.BOTTOM_BLOCK)).toHaveStyle({ - paddingBottom: 56, + paddingBottom: 16, }); }); - it('does not apply paddingBottom to the bottom block when hasExtraBottomPadding is false (Android)', () => { - Object.defineProperty(Platform, 'OS', { - value: 'android', - writable: true, - }); - - const { getByTestId } = render({ hasExtraBottomPadding: false }); - - expect(getByTestId(CustomAmountInfoTestIds.BOTTOM_BLOCK)).not.toHaveStyle( - { paddingBottom: 56 }, - ); - }); - - it('does not apply paddingBottom to the bottom block on iOS even when hasExtraBottomPadding is true', () => { + it('does not apply paddingBottom to the bottom block on iOS', () => { Object.defineProperty(Platform, 'OS', { value: 'ios', writable: true, }); - const { getByTestId } = render({ hasExtraBottomPadding: true }); + const { getByTestId } = render(); - expect(getByTestId(CustomAmountInfoTestIds.BOTTOM_BLOCK)).not.toHaveStyle( - { paddingBottom: 56 }, - ); + expect(getByTestId(CustomAmountInfoTestIds.BOTTOM_BLOCK)).toHaveStyle({ + paddingBottom: 0, + }); }); }); diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx index 27d8da0488f..136172c3103 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx @@ -99,12 +99,6 @@ export interface CustomAmountInfoProps { * When true, the account selector is shown. */ supportAccountSelection?: boolean; - /** - * Adds bottom space to the bottom block (rows + keyboard/confirm button). - * Used by Perps Withdraw on Android, where this screen has one extra - * balance line and otherwise clips behind the system gesture bar. - */ - hasExtraBottomPadding?: boolean; } export const CustomAmountInfo: React.FC = memo( @@ -115,7 +109,6 @@ export const CustomAmountInfo: React.FC = memo( disableConfirm, disablePay, hasMax, - hasExtraBottomPadding, hideAccountSelector, onAmountSubmit, hidePayTokenAmount, @@ -260,7 +253,7 @@ export const CustomAmountInfo: React.FC = memo( {!isResultReady && ( diff --git a/app/components/Views/confirmations/components/info/perps-withdraw-info/perps-withdraw-info.test.tsx b/app/components/Views/confirmations/components/info/perps-withdraw-info/perps-withdraw-info.test.tsx index 88484f2a74c..a8cfac094c0 100644 --- a/app/components/Views/confirmations/components/info/perps-withdraw-info/perps-withdraw-info.test.tsx +++ b/app/components/Views/confirmations/components/info/perps-withdraw-info/perps-withdraw-info.test.tsx @@ -80,13 +80,4 @@ describe('PerpsWithdrawInfo', () => { undefined, ); }); - - it('passes hasExtraBottomPadding=true to CustomAmountInfo to clear the Android gesture bar', () => { - render(); - - expect(mockCustomAmountInfo).toHaveBeenCalledWith( - expect.objectContaining({ hasExtraBottomPadding: true }), - undefined, - ); - }); }); diff --git a/app/components/Views/confirmations/components/info/perps-withdraw-info/perps-withdraw-info.tsx b/app/components/Views/confirmations/components/info/perps-withdraw-info/perps-withdraw-info.tsx index 0955d984153..c4cd480ebdd 100644 --- a/app/components/Views/confirmations/components/info/perps-withdraw-info/perps-withdraw-info.tsx +++ b/app/components/Views/confirmations/components/info/perps-withdraw-info/perps-withdraw-info.tsx @@ -26,7 +26,6 @@ export function PerpsWithdrawInfo() { currency={PERPS_CURRENCY} disablePay={!canSelectWithdrawToken} hasMax - hasExtraBottomPadding > diff --git a/app/core/Engine/controllers/compliance/compliance-service-init.test.ts b/app/core/Engine/controllers/compliance/compliance-service-init.test.ts index ae97184c369..11d0a3c2d10 100644 --- a/app/core/Engine/controllers/compliance/compliance-service-init.test.ts +++ b/app/core/Engine/controllers/compliance/compliance-service-init.test.ts @@ -43,7 +43,7 @@ describe('complianceServiceInit', () => { process.env.COMPLIANCE_API_URL = COMPLIANCE_API_URL; }); - afterAll(() => { + afterEach(() => { process.env.COMPLIANCE_API_URL = originalComplianceApiUrl; }); diff --git a/app/core/Engine/messengers/token-balances-controller-messenger.ts b/app/core/Engine/messengers/token-balances-controller-messenger.ts index e0f2d1599ab..ad2980ae184 100644 --- a/app/core/Engine/messengers/token-balances-controller-messenger.ts +++ b/app/core/Engine/messengers/token-balances-controller-messenger.ts @@ -54,7 +54,6 @@ export function getTokenBalancesControllerMessenger( 'AccountActivityService:statusChanged', 'AccountsController:selectedEvmAccountChange', 'TransactionController:transactionConfirmed', - 'TransactionController:incomingTransactionsReceived', ], messenger, }); diff --git a/app/util/trace.ts b/app/util/trace.ts index 0a5f4a375e0..d3739e2a925 100644 --- a/app/util/trace.ts +++ b/app/util/trace.ts @@ -149,6 +149,7 @@ export enum TraceName { PerpsOrderSubmissionToast = 'Perps Order Submission Toast', PerpsMarketDataUpdate = 'Perps Market Data Update', PerpsOrderView = 'Perps Order View', + PerpsTabView = 'Perps Tab View', PerpsMarketListView = 'Perps Market List View', PerpsPositionDetailsView = 'Perps Position Details View', PerpsAdjustMarginView = 'Perps Adjust Margin View', diff --git a/locales/languages/en.json b/locales/languages/en.json index 78185653a36..d3566c5be44 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -2142,6 +2142,16 @@ "search_by_token_symbol": "Search by token symbol", "no_favorites_found": "No markets in watchlist", "no_favorites_description": "Tap the star icon on any market to add it to your watchlist", + "watchlist": { + "empty_title": "Start with the most-traded markets", + "empty_subtitle": "Tap + to add a market to your watchlist.", + "show_more": "Show {{count}} more", + "show_less": "Show less", + "add_error": "Failed to add market to watchlist", + "limit_reached": "Watchlist limit reached ({{limit}} markets max)", + "filter_badge_label": "Watchlist", + "suggested": "Suggested" + }, "no_tokens_found": "No tokens found", "no_tokens_found_description": "We couldn't find any tokens with the name \"{{searchQuery}}\". Try a different search.", "testnet": "Testnet", @@ -2274,6 +2284,9 @@ "share_failed": "Failed to export image", "export_success": "Exported image", "referral_code_text": "Use my referral code to earn extra rewards." + }, + "market_details": { + "category_search": "Browse related markets" } }, "market_insights": { @@ -2325,7 +2338,7 @@ "nba_2026_champion_title": "NBA 2026 Champion", "fifa_world_cup_2026_title": "FIFA World Cup 2026", "events_in_total": "{{count}} events in total", - "events_in_total_overflow": "{{count}}+ events in total", + "events_in_total_overflow": "{{count}}+ markets in total", "props_pill": "Props", "championship_unavailable": "No championship market to show yet.", "mens_world_cup_title": "Men's World Cup", diff --git a/package.json b/package.json index 00a348a9607..4b118c8e590 100644 --- a/package.json +++ b/package.json @@ -242,7 +242,7 @@ "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^9.0.0", "@metamask/assets-controller": "^8.3.1", - "@metamask/assets-controllers": "^108.4.0", + "@metamask/assets-controllers": "^109.0.0", "@metamask/authenticated-user-storage": "^2.0.0", "@metamask/base-controller": "^9.0.1", "@metamask/bitcoin-wallet-snap": "^1.12.0", @@ -297,7 +297,7 @@ "@metamask/mobile-wallet-protocol-wallet-client": "^0.3.0", "@metamask/money-account-balance-service": "^1.0.0", "@metamask/money-account-controller": "^0.2.0", - "@metamask/money-account-upgrade-controller": "^2.0.0", + "@metamask/money-account-upgrade-controller": "^2.0.5", "@metamask/multichain-account-service": "^10.0.2", "@metamask/multichain-api-client": "^0.10.1", "@metamask/multichain-api-middleware": "^3.1.1", @@ -308,7 +308,7 @@ "@metamask/network-enablement-controller": "^5.3.0", "@metamask/notification-services-controller": "24.1.1", "@metamask/permission-controller": "^13.1.1", - "@metamask/perps-controller": "^8.0.0", + "@metamask/perps-controller": "^8.1.0", "@metamask/phishing-controller": "^17.2.0", "@metamask/post-message-stream": "^10.0.0", "@metamask/preferences-controller": "^23.0.0", diff --git a/tests/reporters/PerformanceReporter.ts b/tests/reporters/PerformanceReporter.ts index 94890542286..429b8e27b45 100644 --- a/tests/reporters/PerformanceReporter.ts +++ b/tests/reporters/PerformanceReporter.ts @@ -13,6 +13,7 @@ import { BrowserStackEnricher } from './providers/browserstack/BrowserStackEnric import { HtmlReportGenerator } from './generators/HtmlReportGenerator'; import { CsvReportGenerator } from './generators/CsvReportGenerator'; import { JsonReportGenerator } from './generators/JsonReportGenerator'; +import { publishPerformanceScenarioToSentry } from './providers/sentry/PerformanceSentryPublisher'; import type { MetricsEntry, SessionData, @@ -502,6 +503,9 @@ class PerformanceReporter { // Merge session data into metrics (video URLs, profiling, network logs) const metricsWithSession = this.mergeSessionDataIntoMetrics(); + // Publish profiling data (FPS, CPU, memory) to Sentry for enriched metrics + await this.publishProfilingDataToSentry(metricsWithSession); + const reportData: ReportData = { metrics: metricsWithSession, sessions: this.sessions, @@ -551,6 +555,64 @@ class PerformanceReporter { } } + private async publishProfilingDataToSentry( + metrics: MetricsEntry[], + ): Promise { + for (const metric of metrics) { + // Skip if no profiling data or if enrichment produced an error object + // (BrowserStackEnricher sets profilingSummary to { error: '...' } on failure) + if (!metric.profilingSummary || metric.profilingSummary.error) continue; + + // Derive status that matches what the fixture sends: timedOut is a + // distinct Sentry status and must not be collapsed to 'failed'. + const testStatus = !metric.testFailed + ? 'passed' + : metric.failureReason === 'timedOut' + ? 'timedOut' + : 'failed'; + + // Anchor Sentry transaction to the actual test end time, not report time. + const testEndTimestamp = metric.timestamp + ? new Date(metric.timestamp).getTime() / 1000 + : undefined; + + try { + const sent = await publishPerformanceScenarioToSentry({ + metrics: { + steps: metric.steps || [], + timestamp: metric.timestamp || new Date().toISOString(), + thresholdMarginPercent: metric.thresholdMarginPercent ?? 10, + team: metric.team ?? null, + total: metric.total || 0, + totalThreshold: metric.totalThreshold ?? null, + hasThresholds: metric.hasThresholds ?? false, + totalValidation: metric.totalValidation ?? null, + device: metric.device, + ...(metric.sessionCreationDurationMs !== undefined && { + sessionCreationDurationMs: metric.sessionCreationDurationMs, + }), + }, + testTitle: metric.testName, + projectName: metric.projectName || 'unknown', + testFilePath: metric.testFilePath, + tags: metric.tags || [], + status: testStatus, + videoRecordingUrl: metric.videoURL ?? null, + profilingSummary: metric.profilingSummary, + testEndTimestamp, + }); + + if (sent) { + logger.info(`Profiling data for "${metric.testName}" sent to Sentry`); + } + } catch (error) { + logger.error( + `Failed to publish profiling data to Sentry for "${metric.testName}": ${error}`, + ); + } + } + } + private mergeSessionDataIntoMetrics(): MetricsEntry[] { return this.metrics.map((metric) => { const matchingSession = this.sessions.find( diff --git a/tests/reporters/providers/sentry/PerformanceSentryPublisher.test.ts b/tests/reporters/providers/sentry/PerformanceSentryPublisher.test.ts index 20a83f61b59..9c8b17f970c 100644 --- a/tests/reporters/providers/sentry/PerformanceSentryPublisher.test.ts +++ b/tests/reporters/providers/sentry/PerformanceSentryPublisher.test.ts @@ -370,6 +370,115 @@ describe('PerformanceSentryPublisher', () => { expect(longTimerKeys.every((key: string) => key.length <= 64)).toBe(true); }); + it('includes profiling measurements when profilingSummary is provided', async () => { + process.env.E2E_PERFORMANCE_SENTRY_DSN = + 'https://publicKey@o123.ingest.sentry.io/4567'; + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + + const sent = await publishPerformanceScenarioToSentry({ + metrics: createMetrics(), + testTitle: 'Import wallet flow', + projectName: 'browserstack-android', + testFilePath: 'tests/performance/onboarding/import-wallet.spec.js', + tags: ['@PerformanceOnboarding'], + status: 'passed', + retry: 0, + workerIndex: 0, + profilingSummary: { + status: 'completed', + issues: 1, + criticalIssues: 0, + uiRendering: { slowFrames: 12.5, frozenFrames: 2.1, anrs: 0 }, + cpu: { avg: 34.2, max: 78.5, unit: '%' }, + memory: { avg: 210.4, max: 310.8, unit: 'MB' }, + }, + }); + + expect(sent).toBe(true); + const [, requestInit] = fetchMock.mock.calls[0] ?? []; + const body = requestInit?.body as string; + const [, , payloadLine] = body.split('\n'); + const payload = JSON.parse(payloadLine); + + expect(payload.measurements.profiling_slow_frames_pct.value).toBe(12.5); + expect(payload.measurements.profiling_slow_frames_pct.unit).toBe('percent'); + expect(payload.measurements.profiling_frozen_frames_pct.value).toBe(2.1); + expect(payload.measurements.profiling_frozen_frames_pct.unit).toBe( + 'percent', + ); + expect(payload.measurements.profiling_anrs.value).toBe(0); + expect(payload.measurements.profiling_anrs.unit).toBe('none'); + expect(payload.measurements.profiling_cpu_avg_pct.value).toBe(34.2); + expect(payload.measurements.profiling_cpu_avg_pct.unit).toBe('percent'); + expect(payload.measurements.profiling_cpu_max_pct.value).toBe(78.5); + expect(payload.measurements.profiling_memory_avg_mb.value).toBe(210.4); + expect(payload.measurements.profiling_memory_avg_mb.unit).toBe('megabyte'); + expect(payload.measurements.profiling_memory_max_mb.value).toBe(310.8); + expect(payload.extra.profiling_summary.uiRendering.slowFrames).toBe(12.5); + }); + + it('does not include profiling measurements when profilingSummary is absent', async () => { + process.env.E2E_PERFORMANCE_SENTRY_DSN = + 'https://publicKey@o123.ingest.sentry.io/4567'; + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + + const sent = await publishPerformanceScenarioToSentry({ + metrics: createMetrics(), + testTitle: 'Import wallet flow', + projectName: 'browserstack-android', + testFilePath: 'tests/performance/onboarding/import-wallet.spec.js', + tags: ['@PerformanceOnboarding'], + status: 'passed', + retry: 0, + workerIndex: 0, + }); + + expect(sent).toBe(true); + const [, requestInit] = fetchMock.mock.calls[0] ?? []; + const body = requestInit?.body as string; + const [, , payloadLine] = body.split('\n'); + const payload = JSON.parse(payloadLine); + + expect(payload.measurements.profiling_slow_frames_pct).toBeUndefined(); + expect(payload.measurements.profiling_frozen_frames_pct).toBeUndefined(); + expect(payload.measurements.profiling_anrs).toBeUndefined(); + expect(payload.extra.profiling_summary).toBeNull(); + }); + + it('anchors transaction timestamps to testEndTimestamp when provided', async () => { + process.env.E2E_PERFORMANCE_SENTRY_DSN = + 'https://publicKey@o123.ingest.sentry.io/4567'; + fetchMock.mockResolvedValue({ ok: true, status: 200 } as Response); + + const testEndTimestamp = 1700000000; // fixed Unix seconds + await publishPerformanceScenarioToSentry({ + metrics: createMetrics({ total: 1.3 }), + testTitle: 'Import wallet flow', + projectName: 'browserstack-android', + testFilePath: 'tests/performance/onboarding/import-wallet.spec.js', + tags: [], + status: 'passed', + retry: 0, + workerIndex: 0, + testEndTimestamp, + }); + + const [, requestInit] = fetchMock.mock.calls[0] ?? []; + const body = requestInit?.body as string; + const [, , payloadLine] = body.split('\n'); + const payload = JSON.parse(payloadLine); + + expect(payload.timestamp).toBe(testEndTimestamp); + // startTimestamp = endTimestamp - totalDurationMs/1000 = 1700000000 - 1.3 + expect(payload.start_timestamp).toBeCloseTo(testEndTimestamp - 1.3, 3); + }); + it('does not send when sample rate is invalid', async () => { process.env.E2E_PERFORMANCE_SENTRY_DSN = 'https://publicKey@o123.ingest.sentry.io/4567'; diff --git a/tests/reporters/providers/sentry/PerformanceSentryPublisher.ts b/tests/reporters/providers/sentry/PerformanceSentryPublisher.ts index 0bdd441ae05..65c4cf875e6 100644 --- a/tests/reporters/providers/sentry/PerformanceSentryPublisher.ts +++ b/tests/reporters/providers/sentry/PerformanceSentryPublisher.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { createLogger } from '../../../framework/logger'; import type { MetricsOutput } from '../../PerformanceTracker'; +import type { ProfilingSummary } from '../../types'; const logger = createLogger({ name: 'PerformanceSentryPublisher' }); @@ -22,6 +23,13 @@ const RESERVED_MEASUREMENT_KEYS = [ 'scenario_total_time_ms', 'scenario_total_threshold_ms', 'bs_session_creation_ms', + 'profiling_slow_frames_pct', + 'profiling_frozen_frames_pct', + 'profiling_anrs', + 'profiling_cpu_avg_pct', + 'profiling_cpu_max_pct', + 'profiling_memory_avg_mb', + 'profiling_memory_max_mb', ] as const; interface PublishPerformanceScenarioOptions { @@ -34,6 +42,9 @@ interface PublishPerformanceScenarioOptions { retry?: number; workerIndex?: number; videoRecordingUrl?: string | null; + profilingSummary?: ProfilingSummary | null; + /** Unix epoch seconds. When provided, anchors the transaction to the actual test end time instead of the moment the publish call is made. */ + testEndTimestamp?: number; } interface ParsedSentryDsn { @@ -56,7 +67,7 @@ interface TimerMeasurement { interface SentryMeasurement { value: number; - unit: 'millisecond'; + unit: 'millisecond' | 'none' | 'percent' | 'megabyte'; } interface MirroredScenarioAttributes { @@ -252,7 +263,7 @@ export async function publishPerformanceScenarioToSentry( const transactionSpanId = createHexId(16); const totalDurationMs = Math.round(options.metrics.total * 1000); - const endTimestamp = Date.now() / 1000; + const endTimestamp = options.testEndTimestamp ?? Date.now() / 1000; const startTimestamp = endTimestamp - totalDurationMs / 1000; const usedMeasurementKeys = new Set(RESERVED_MEASUREMENT_KEYS); @@ -297,6 +308,42 @@ export async function publishPerformanceScenarioToSentry( }; } + if (options.profilingSummary?.uiRendering) { + const { slowFrames, frozenFrames, anrs } = + options.profilingSummary.uiRendering; + measurements.profiling_slow_frames_pct = { + value: slowFrames, + unit: 'percent', + }; + measurements.profiling_frozen_frames_pct = { + value: frozenFrames, + unit: 'percent', + }; + measurements.profiling_anrs = { value: anrs, unit: 'none' }; + } + + if (options.profilingSummary?.cpu) { + measurements.profiling_cpu_avg_pct = { + value: options.profilingSummary.cpu.avg, + unit: 'percent', + }; + measurements.profiling_cpu_max_pct = { + value: options.profilingSummary.cpu.max, + unit: 'percent', + }; + } + + if (options.profilingSummary?.memory) { + measurements.profiling_memory_avg_mb = { + value: options.profilingSummary.memory.avg, + unit: 'megabyte', + }; + measurements.profiling_memory_max_mb = { + value: options.profilingSummary.memory.max, + unit: 'megabyte', + }; + } + const provider = options.metrics.device.provider || 'unknown'; const teamId = options.metrics.team?.teamId || 'unknown'; const teamName = options.metrics.team?.teamName || 'unknown'; @@ -407,6 +454,7 @@ export async function publishPerformanceScenarioToSentry( total_validation: options.metrics.totalValidation, timer_steps: timerMeasurements, bs_session_creation_ms: options.metrics.sessionCreationDurationMs ?? null, + profiling_summary: options.profilingSummary ?? null, sentry_project_id: parsedDsn.projectId, }, }; diff --git a/yarn.lock b/yarn.lock index da7f08b4839..82ac3cad898 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7917,7 +7917,7 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:^108.4.0, @metamask/assets-controllers@npm:^108.6.0": +"@metamask/assets-controllers@npm:^108.6.0": version: 108.6.0 resolution: "@metamask/assets-controllers@npm:108.6.0" dependencies: @@ -7974,6 +7974,63 @@ __metadata: languageName: node linkType: hard +"@metamask/assets-controllers@npm:^109.0.0": + version: 109.0.0 + resolution: "@metamask/assets-controllers@npm:109.0.0" + dependencies: + "@ethereumjs/util": "npm:^9.1.0" + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@metamask/abi-utils": "npm:^2.0.3" + "@metamask/account-tree-controller": "npm:^7.5.2" + "@metamask/accounts-controller": "npm:^39.0.1" + "@metamask/approval-controller": "npm:^9.0.2" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/contract-metadata": "npm:^2.4.0" + "@metamask/controller-utils": "npm:^12.1.1" + "@metamask/core-backend": "npm:^6.3.3" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/keyring-api": "npm:^23.1.0" + "@metamask/keyring-controller": "npm:^27.0.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/multichain-account-service": "npm:^10.0.3" + "@metamask/network-controller": "npm:^32.0.0" + "@metamask/network-enablement-controller": "npm:^5.3.0" + "@metamask/permission-controller": "npm:^13.1.1" + "@metamask/phishing-controller": "npm:^17.2.0" + "@metamask/polling-controller": "npm:^16.0.6" + "@metamask/preferences-controller": "npm:^23.1.0" + "@metamask/profile-sync-controller": "npm:^28.1.1" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/snaps-controllers": "npm:^19.0.0" + "@metamask/snaps-sdk": "npm:^11.0.0" + "@metamask/snaps-utils": "npm:^12.1.2" + "@metamask/storage-service": "npm:^1.0.2" + "@metamask/transaction-controller": "npm:^67.1.0" + "@metamask/utils": "npm:^11.9.0" + "@tanstack/query-core": "npm:^5.62.16" + "@types/bn.js": "npm:^5.1.5" + "@types/uuid": "npm:^8.3.0" + async-mutex: "npm:^0.5.0" + bitcoin-address-validation: "npm:^2.2.3" + bn.js: "npm:^5.2.1" + immer: "npm:^9.0.6" + lodash: "npm:^4.17.21" + multiformats: "npm:^9.9.0" + reselect: "npm:^5.1.1" + single-call-balance-checker-abi: "npm:^1.0.0" + uuid: "npm:^8.3.2" + peerDependencies: + "@metamask/providers": ^22.0.0 + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: 10/726f9073e9930f9232fa0f0b6338574aac9491da11c136ed65db6802bf9d562c09016187bc41e41b16a45cfafde60a4ff9ac626aa54af5338774ed26f93dc0bb + languageName: node + linkType: hard + "@metamask/auth-network-utils@npm:^0.3.0": version: 0.3.1 resolution: "@metamask/auth-network-utils@npm:0.3.1" @@ -8008,19 +8065,6 @@ __metadata: languageName: node linkType: hard -"@metamask/authenticated-user-storage@npm:^1.0.1": - version: 1.0.1 - resolution: "@metamask/authenticated-user-storage@npm:1.0.1" - dependencies: - "@metamask/base-data-service": "npm:^0.1.2" - "@metamask/controller-utils": "npm:^12.0.0" - "@metamask/messenger": "npm:^1.2.0" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.9.0" - checksum: 10/e6ae2f9a094536e269ec73f87a9939701bf2ce9fd3bf32671da49341da8d13179d55a4e34a1c9d899e48e707c7a1d12e36faaf1f0b4e5fe82445ca43b5265fc5 - languageName: node - linkType: hard - "@metamask/authenticated-user-storage@npm:^2.0.0": version: 2.0.0 resolution: "@metamask/authenticated-user-storage@npm:2.0.0" @@ -8264,10 +8308,11 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^12.0.0, @metamask/controller-utils@npm:^12.1.0, @metamask/controller-utils@npm:^12.1.1": - version: 12.1.1 - resolution: "@metamask/controller-utils@npm:12.1.1" +"@metamask/controller-utils@npm:^12.0.0, @metamask/controller-utils@npm:^12.1.0, @metamask/controller-utils@npm:^12.1.1, @metamask/controller-utils@npm:^12.2.0": + version: 12.2.0 + resolution: "@metamask/controller-utils@npm:12.2.0" dependencies: + "@ethersproject/abi": "npm:^5.7.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-unit": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" @@ -8281,7 +8326,7 @@ __metadata: lodash: "npm:^4.17.21" peerDependencies: "@babel/runtime": ^7.0.0 - checksum: 10/39e22e0247ee26a83316563c35ec206053bf95310e66441a6d416f33837f62da97abea1d52429b208e22b23fc97ad13b8a6d93d94607638b0567a45efdab1933 + checksum: 10/24551cec486319b39e0db6792e5d26bafa7fb87dce63d516cc80a5645f7c185b7911e89d21189e7270d338436074bc6e1e6e0983b008cb130292ee1e1902c28d languageName: node linkType: hard @@ -8315,15 +8360,15 @@ __metadata: languageName: node linkType: hard -"@metamask/delegation-controller@npm:^3.0.0": - version: 3.0.0 - resolution: "@metamask/delegation-controller@npm:3.0.0" +"@metamask/delegation-controller@npm:^3.0.2": + version: 3.0.2 + resolution: "@metamask/delegation-controller@npm:3.0.2" dependencies: - "@metamask/base-controller": "npm:^9.0.1" - "@metamask/keyring-controller": "npm:^25.2.0" - "@metamask/messenger": "npm:^1.1.1" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/keyring-controller": "npm:^27.0.0" + "@metamask/messenger": "npm:^1.2.0" "@metamask/utils": "npm:^11.9.0" - checksum: 10/39805fa41088f3e816d4ef650a81f724e4b52528ade882fd3580436acccdc2da4072658abe00d33fb27d597e88f97d104e140718d7e67b132ff1b9bba0887fa4 + checksum: 10/d6c11c8edea96b72f0411c347cb5aa7b05b123bd688d6e68297aacb1db9a37ef4d2f680cfa6e59b44d66046df95b5fed6d454122b2559085eedea8f1a63e36d8 languageName: node linkType: hard @@ -8338,14 +8383,14 @@ __metadata: languageName: node linkType: hard -"@metamask/delegation-core@npm:^2.0.0": - version: 2.0.0 - resolution: "@metamask/delegation-core@npm:2.0.0" +"@metamask/delegation-core@npm:^2.2.1": + version: 2.2.1 + resolution: "@metamask/delegation-core@npm:2.2.1" dependencies: "@metamask/abi-utils": "npm:^3.0.0" "@metamask/utils": "npm:^11.4.0" "@noble/hashes": "npm:^1.8.0" - checksum: 10/b473160e4cb4a6d463c6015de6e90d057034d2e8f2905068e1f44f93c8247618c5d84a155e86dfaa125dacb040951643517b9a76961bf8d215c194dc4d1cc0ad + checksum: 10/8cc532e4a5fdc83eddb36fada6283d51629272fcce8b7c7c2d3b4addde5aff7c741365a3bcd701364460fbbcbbe94fccc0460ee3ef9af284a82f84ca708f0e06 languageName: node linkType: hard @@ -8356,7 +8401,7 @@ __metadata: languageName: node linkType: hard -"@metamask/delegation-deployments@npm:^1.0.0, @metamask/delegation-deployments@npm:^1.3.0": +"@metamask/delegation-deployments@npm:^1.0.0, @metamask/delegation-deployments@npm:^1.4.0": version: 1.4.0 resolution: "@metamask/delegation-deployments@npm:1.4.0" checksum: 10/e5e7b83e27daec5b1b61482647d43d4b685954818ff02687e2dbe8169a4dfe199cc8d2ed444242b60425ac2e7d2cf2a5ca29f3e69936abe6a8391f578ec693bb @@ -9167,21 +9212,21 @@ __metadata: languageName: node linkType: hard -"@metamask/money-account-upgrade-controller@npm:^2.0.0": - version: 2.0.1 - resolution: "@metamask/money-account-upgrade-controller@npm:2.0.1" +"@metamask/money-account-upgrade-controller@npm:^2.0.5": + version: 2.0.5 + resolution: "@metamask/money-account-upgrade-controller@npm:2.0.5" dependencies: - "@metamask/authenticated-user-storage": "npm:^1.0.1" + "@metamask/authenticated-user-storage": "npm:^2.0.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/chomp-api-service": "npm:^3.1.0" - "@metamask/delegation-controller": "npm:^3.0.0" - "@metamask/delegation-core": "npm:^2.0.0" - "@metamask/delegation-deployments": "npm:^1.3.0" - "@metamask/keyring-controller": "npm:^25.5.0" + "@metamask/delegation-controller": "npm:^3.0.2" + "@metamask/delegation-core": "npm:^2.2.1" + "@metamask/delegation-deployments": "npm:^1.4.0" + "@metamask/keyring-controller": "npm:^27.0.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/network-controller": "npm:^32.0.0" - "@metamask/utils": "npm:^11.9.0" - checksum: 10/5a903ccde41188f9e61a0200f79b8bae540c0ca31d8b7e4089708c876eaba46accdd7b2b5e110e7228d724b35e8f279b2fc5e257f3dc9d5d0f2b764416f968fe + "@metamask/utils": "npm:^11.11.0" + checksum: 10/ce7c34035abe401f310832fe15cd8cd99c3740d72ead914b170667cd65c2612820c6936eaaf8b9bf1bf2407aed92ff7cc2bff7bebfc12ce40683e68344b6c606 languageName: node linkType: hard @@ -9426,13 +9471,13 @@ __metadata: languageName: node linkType: hard -"@metamask/perps-controller@npm:^8.0.0": - version: 8.0.0 - resolution: "@metamask/perps-controller@npm:8.0.0" +"@metamask/perps-controller@npm:^8.1.0": + version: 8.1.0 + resolution: "@metamask/perps-controller@npm:8.1.0" dependencies: "@metamask/abi-utils": "npm:^2.0.3" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^12.1.0" + "@metamask/controller-utils": "npm:^12.2.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/utils": "npm:^11.9.0" "@myx-trade/sdk": "npm:^0.1.265" @@ -9443,7 +9488,7 @@ __metadata: dependenciesMeta: "@myx-trade/sdk": optional: true - checksum: 10/7b06134fbb3a945a44a5349a29a120094069d66f011af9989ae560099e1bd12a4932aa1c5cb66a5a5c40b41f96738d7e633d3332e482b3e01d9b7f52be6363aa + checksum: 10/adb4501e0d5fcb8837ea87bdea43bc5c0444838d218d0cdd3d3153fb3dc25307906e5078f7ac38f2799569bb7d82c64c3d352b78d77829da4068275c5dec6849 languageName: node linkType: hard @@ -35205,7 +35250,7 @@ __metadata: "@metamask/app-metadata-controller": "npm:^2.0.0" "@metamask/approval-controller": "npm:^9.0.0" "@metamask/assets-controller": "npm:^8.3.1" - "@metamask/assets-controllers": "npm:^108.4.0" + "@metamask/assets-controllers": "npm:^109.0.0" "@metamask/authenticated-user-storage": "npm:^2.0.0" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.1" @@ -35268,7 +35313,7 @@ __metadata: "@metamask/mobile-wallet-protocol-wallet-client": "npm:^0.3.0" "@metamask/money-account-balance-service": "npm:^1.0.0" "@metamask/money-account-controller": "npm:^0.2.0" - "@metamask/money-account-upgrade-controller": "npm:^2.0.0" + "@metamask/money-account-upgrade-controller": "npm:^2.0.5" "@metamask/multichain-account-service": "npm:^10.0.2" "@metamask/multichain-api-client": "npm:^0.10.1" "@metamask/multichain-api-middleware": "npm:^3.1.1" @@ -35280,7 +35325,7 @@ __metadata: "@metamask/notification-services-controller": "npm:24.1.1" "@metamask/object-multiplex": "npm:^1.1.0" "@metamask/permission-controller": "npm:^13.1.1" - "@metamask/perps-controller": "npm:^8.0.0" + "@metamask/perps-controller": "npm:^8.1.0" "@metamask/phishing-controller": "npm:^17.2.0" "@metamask/post-message-stream": "npm:^10.0.0" "@metamask/preferences-controller": "npm:^23.0.0"