diff --git a/.github/guidelines/E2E_DECISION_TREE.md b/.github/guidelines/E2E_DECISION_TREE.md index 53a9aebb4d7e..a9f6ce17e8d9 100644 --- a/.github/guidelines/E2E_DECISION_TREE.md +++ b/.github/guidelines/E2E_DECISION_TREE.md @@ -33,7 +33,7 @@ To save infra resources while waiting for static analysis findings and potential - E2E tests are skipped and merge is blocked while the label is present, **unless** all changes are ignorable-only. - If E2E tests are needed, they should pass to be able to merge. -## AI test selection +## Smart AI E2E test selection Runs only when all of the following are true: @@ -53,3 +53,10 @@ Flakiness detection is applied to modified E2E test files in PRs: - Modified E2E test files run twice - It applies to existing test files as well as new test files added in the PR - It can be disabled by adding the label `skip-e2e-flakiness-detection`. Useful when making large refactors or when changes don't pose flakiness risk. + +## Release branches + +PRs to release branches (cherry-picked from main) are exempt from the following: + +- Label `pr-not-ready-for-e2e` is not applied +- Smart AI E2E selection is skipped - all E2E suites are run (if changes are not ignorable-only, e.g. only docs) diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 85de7d34ce57..f39ecff8995c 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -207,16 +207,6 @@ jobs: SEGMENT_REGULATIONS_ENDPOINT_FLASK: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_FLASK }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - FLASK_IOS_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_IOS_GOOGLE_CLIENT_ID_PROD }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - FLASK_IOS_GOOGLE_REDIRECT_URI_PROD: ${{ secrets.FLASK_IOS_GOOGLE_REDIRECT_URI_PROD }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - FLASK_ANDROID_APPLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_APPLE_CLIENT_ID_PROD }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} - FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} @@ -258,16 +248,6 @@ jobs: SEGMENT_REGULATIONS_ENDPOINT_FLASK: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_FLASK }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - FLASK_IOS_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_IOS_GOOGLE_CLIENT_ID_PROD }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - FLASK_IOS_GOOGLE_REDIRECT_URI_PROD: ${{ secrets.FLASK_IOS_GOOGLE_REDIRECT_URI_PROD }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - FLASK_ANDROID_APPLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_APPLE_CLIENT_ID_PROD }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} - FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index 8c233861ff70..aa2296a5406c 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -60,11 +60,6 @@ jobs: SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} @@ -194,11 +189,6 @@ jobs: MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} @@ -232,11 +222,6 @@ jobs: SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c4d5f10d9d13..4d48f758bed0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,8 +68,6 @@ on: - flask-test - flask-e2e - flask-dev - - qa-prod - - qa-dev platform: required: true type: choice @@ -125,6 +123,7 @@ jobs: uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' + cache: 'yarn' - run: yarn install --immutable - run: node scripts/validate-build-config.js diff --git a/.github/workflows/run-e2e-api-specs.yml b/.github/workflows/run-e2e-api-specs.yml index 94c5edfbb81b..f90fa6ad24bc 100644 --- a/.github/workflows/run-e2e-api-specs.yml +++ b/.github/workflows/run-e2e-api-specs.yml @@ -23,8 +23,6 @@ jobs: SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SOLANA_E2E_TEST_SRP: ${{ secrets.MM_SOLANA_E2E_TEST_SRP }} diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml index cf7e218be2ae..1cc5a8949497 100644 --- a/.github/workflows/run-e2e-workflow.yml +++ b/.github/workflows/run-e2e-workflow.yml @@ -84,11 +84,6 @@ jobs: SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/run-performance-e2e.yml b/.github/workflows/run-performance-e2e.yml index a8aef5ffc6bd..114bff17c871 100644 --- a/.github/workflows/run-performance-e2e.yml +++ b/.github/workflows/run-performance-e2e.yml @@ -142,6 +142,7 @@ jobs: needs: [determine-branch-name] outputs: android_matrix: ${{ steps.read-matrix.outputs.android_matrix }} + android_mm_connect_matrix: ${{ steps.read-matrix.outputs.android_mm_connect_matrix }} ios_matrix: ${{ steps.read-matrix.outputs.ios_matrix }} steps: - name: Checkout code @@ -165,18 +166,23 @@ jobs: fi ANDROID_MATRIX=$(jq ".android_devices | $FILTER" "$FILE") + ANDROID_MM_CONNECT_MATRIX=$(jq '[.android_devices[] | select(.name | contains("Samsung"))]' "$FILE") IOS_MATRIX=$(jq ".ios_devices | $FILTER" "$FILE") { echo "android_matrix<> "$GITHUB_OUTPUT" echo "Selected: $(echo "$ANDROID_MATRIX" | jq length) Android, $(echo "$IOS_MATRIX" | jq length) iOS" + echo "Selected for Android MM-Connect: $(echo "$ANDROID_MM_CONNECT_MATRIX" | jq length)" set-build-names: name: Set Unified BrowserStack Build Names @@ -333,7 +339,7 @@ jobs: name: Fetch RN Playground APK and Upload to BrowserStack runs-on: ubuntu-latest needs: [wait-for-onboarding-completion] - if: always() && !cancelled() + if: always() && !cancelled() && (inputs.build_variant || 'rc') == 'rc' outputs: browserstack-playground-url: ${{ steps.upload-playground.outputs.browserstack-url }} steps: @@ -376,13 +382,13 @@ jobs: set-build-names, determine-branch-name, ] - if: always() && !cancelled() && (needs.trigger-android-dual-versions.result == 'skipped' || needs.trigger-android-dual-versions.result == 'success') && (inputs.browserstack_app_url_android_imported_wallet != '' || needs.trigger-android-dual-versions.outputs.with-srp-browserstack-url != '') + if: always() && !cancelled() && (inputs.build_variant || 'rc') == 'rc' && (needs.trigger-android-dual-versions.result == 'skipped' || needs.trigger-android-dual-versions.result == 'success') && (inputs.browserstack_app_url_android_imported_wallet != '' || needs.trigger-android-dual-versions.outputs.with-srp-browserstack-url != '') with: platform: android build_type: mm-connect sentry_target: ${{ inputs.sentry_target || 'test' }} build_variant: ${{ inputs.build_variant || 'rc' }} - device_matrix: ${{ needs.read-device-matrix.outputs.android_matrix }} + device_matrix: ${{ needs.read-device-matrix.outputs.android_mm_connect_matrix }} browserstack_app_url: ${{ needs.trigger-android-dual-versions.outputs.with-srp-browserstack-url || inputs.browserstack_app_url_android_imported_wallet }} app_version: ${{ needs.trigger-android-dual-versions.outputs.with-srp-version || 'Manual-Input' }} branch_name: ${{ needs.determine-branch-name.outputs.branch_name }} diff --git a/.github/workflows/update-e2e-fixtures.yml b/.github/workflows/update-e2e-fixtures.yml index 63f5db437017..a9c652ff3d62 100644 --- a/.github/workflows/update-e2e-fixtures.yml +++ b/.github/workflows/update-e2e-fixtures.yml @@ -199,11 +199,6 @@ jobs: SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} @@ -255,7 +250,7 @@ jobs: run: | IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' \ yarn detox test -c ios.sim.main.ci --headless \ - tests/regression/fixtures/fixture-validation.spec.ts + tests/smoke/fixtures/fixture-validation.spec.ts env: PREBUILT_IOS_APP_PATH: artifacts/main-qa-MetaMask.app diff --git a/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx b/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx index 98067b7d4081..b61e870b882f 100644 --- a/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx +++ b/app/components/UI/AssetOverview/Price/Price.advanced.test.tsx @@ -11,6 +11,15 @@ import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEvent jest.mock('../../../hooks/useAnalytics/useAnalytics'); +const mockTrace = jest.fn(); +const mockEndTrace = jest.fn(); + +jest.mock('../../../../util/trace', () => ({ + ...jest.requireActual('../../../../util/trace'), + trace: (...args: unknown[]) => mockTrace(...args), + endTrace: (...args: unknown[]) => mockEndTrace(...args), +})); + const mockSetIsChartBeingTouched = jest.fn(); jest.mock('../PriceChart/PriceChart.context', () => ({ usePriceChart: () => ({ @@ -830,4 +839,203 @@ describe('PriceAdvanced', () => { expect(mockSetIsChartBeingTouched).toHaveBeenCalledWith(false); }); }); + + describe('performance tracing', () => { + beforeEach(() => { + mockTrace.mockClear(); + mockEndTrace.mockClear(); + }); + + it('starts initial visibility trace when component mounts with advanced chart', () => { + render(); + + expect(mockTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringContaining('Advanced Chart Initial Visible'), + op: expect.stringContaining('token_overview.advanced_chart'), + }), + ); + }); + + it('ends trace when onSkeletonHidden is called with matching series key', () => { + const { getByTestId } = render(); + const advancedChart = getByTestId('mock-advanced-chart'); + + mockEndTrace.mockClear(); + + act(() => { + advancedChart.props.onSkeletonHidden?.(); + }); + + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringContaining('Advanced Chart Initial Visible'), + }), + ); + }); + + it('ends trace with error data when onError is called', () => { + const { getByTestId } = render(); + const advancedChart = getByTestId('mock-advanced-chart'); + + mockEndTrace.mockClear(); + + act(() => { + advancedChart.props.onError?.('WebView failed to load'); + }); + + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringContaining('Advanced Chart Initial Visible'), + data: expect.objectContaining({ + errorMessage: 'WebView failed to load', + }), + }), + ); + }); + + it('starts time range visibility trace when time range changes', () => { + const { getByTestId } = render(); + + mockTrace.mockClear(); + + act(() => { + fireEvent.press(getByTestId('select-1W')); + }); + + expect(mockTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringContaining('Time Range Visible'), + op: expect.stringContaining('time_range'), + }), + ); + }); + + it('supersedes previous trace when series key changes before skeleton hidden', () => { + const { getByTestId } = render(); + + mockEndTrace.mockClear(); + + act(() => { + fireEvent.press(getByTestId('select-1W')); + }); + + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + superseded: true, + }), + }), + ); + }); + + it('ends trace with fallbackToLegacy when switching to legacy chart', () => { + const { rerender } = render(); + + mockEndTrace.mockClear(); + + mockUseOHLCVChart.mockReturnValueOnce({ + ohlcvData: [], + isLoading: false, + error: undefined, + hasMore: false, + nextCursor: null, + hasEmptyData: true, + }); + + rerender(); + + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + fallbackToLegacy: true, + }), + }), + ); + }); + + it('includes assetId in trace data when available', () => { + mockTrace.mockClear(); + + render(); + + expect(mockTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + assetId: expect.any(String), + }), + }), + ); + }); + + it('does not start trace when falling back to legacy chart immediately', () => { + mockTrace.mockClear(); + + mockUseOHLCVChart.mockReturnValueOnce({ + ohlcvData: [], + isLoading: false, + error: undefined, + hasMore: false, + nextCursor: null, + hasEmptyData: true, + }); + + render(); + + expect(mockTrace).not.toHaveBeenCalled(); + }); + + it('ends trace with unmounted flag when component unmounts with open trace', () => { + const { unmount } = render(); + + mockEndTrace.mockClear(); + + unmount(); + + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringContaining('Advanced Chart Initial Visible'), + data: expect.objectContaining({ + unmounted: true, + }), + }), + ); + }); + + it('does not end trace on unmount when trace was already completed', () => { + const { getByTestId, unmount } = render(); + const advancedChart = getByTestId('mock-advanced-chart'); + + act(() => { + advancedChart.props.onSkeletonHidden?.(); + }); + + mockEndTrace.mockClear(); + + unmount(); + + expect(mockEndTrace).not.toHaveBeenCalled(); + }); + + it('truncates error message to 200 characters', () => { + const { getByTestId } = render(); + const advancedChart = getByTestId('mock-advanced-chart'); + + mockEndTrace.mockClear(); + + const longError = 'A'.repeat(300); + + act(() => { + advancedChart.props.onError?.(longError); + }); + + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + errorMessage: 'A'.repeat(200), + }), + }), + ); + }); + }); }); diff --git a/app/components/UI/AssetOverview/Price/Price.advanced.tsx b/app/components/UI/AssetOverview/Price/Price.advanced.tsx index 3a06ac9fdfae..0477302d54ec 100644 --- a/app/components/UI/AssetOverview/Price/Price.advanced.tsx +++ b/app/components/UI/AssetOverview/Price/Price.advanced.tsx @@ -56,6 +56,12 @@ import type { TokenPrice, } from '../../../../components/hooks/useTokenHistoricalPrices'; import PriceLegacy from './Price.legacy'; +import { + endTrace, + trace, + TraceName, + TraceOperation, +} from '../../../../util/trace'; const EMPTY_INDICATORS: IndicatorType[] = []; @@ -67,6 +73,36 @@ const TIME_RANGE_LABELS: Record = { '1Y': 'asset_overview.chart_time_period.1y', }; +/** Maps {@link ohlcvSeriesKey} transitions to Sentry trace name/op (dashboards filter by name or op). */ +function getAdvancedChartVisibilityTraceRequest( + previousSeriesKey: string | null, + nextSeriesKey: string, +): { name: TraceName; op: TraceOperation } { + if (previousSeriesKey === null) { + return { + name: TraceName.TokenOverviewAdvancedChartInitialVisible, + op: TraceOperation.TokenOverviewAdvancedChart, + }; + } + const prev = previousSeriesKey.split('|'); + const next = nextSeriesKey.split('|'); + if (prev.length >= 4 && next.length >= 4) { + const sameAsset = prev[0] === next[0]; + const sameCurrency = prev[prev.length - 1] === next[next.length - 1]; + const rangeChanged = prev[1] !== next[1] || prev[2] !== next[2]; + if (sameAsset && sameCurrency && rangeChanged) { + return { + name: TraceName.TokenOverviewAdvancedChartTimeRangeVisible, + op: TraceOperation.TokenOverviewAdvancedChartTimeRange, + }; + } + } + return { + name: TraceName.TokenOverviewAdvancedChartInitialVisible, + op: TraceOperation.TokenOverviewAdvancedChart, + }; +} + export interface PriceAdvancedProps { asset: TokenI; currentPrice: number; @@ -206,6 +242,43 @@ const PriceAdvanced = ({ [assetId, config.timePeriod, config.interval, currentCurrency], ); + const assetIdRef = useRef(assetId); + assetIdRef.current = assetId; + + const visibilityTraceStartedRef = useRef(null); + /** Matches pending manual trace so {@link endTrace} uses the same `TraceName` as {@link trace}. */ + const activeVisibilityTraceRef = useRef<{ + seriesKey: string; + traceName: TraceName; + } | null>(null); + + const handleAdvancedChartSkeletonHidden = useCallback(() => { + const open = activeVisibilityTraceRef.current; + if (!open) { + return; + } + endTrace({ + name: open.traceName, + id: open.seriesKey, + }); + activeVisibilityTraceRef.current = null; + }, []); + + const handleAdvancedChartError = useCallback((error: string) => { + const open = activeVisibilityTraceRef.current; + if (!open) { + return; + } + endTrace({ + name: open.traceName, + id: open.seriesKey, + data: { + errorMessage: error.slice(0, 200), + }, + }); + activeVisibilityTraceRef.current = null; + }, []); + const { ohlcvData, isLoading: chartLoading, @@ -294,6 +367,85 @@ const PriceAdvanced = ({ !chartLoading && (ohlcvData.length < CHART_DATA_THRESHOLD || hasEmptyData || chartError); + const shouldFallbackToLegacyRef = useRef(shouldFallbackToLegacy); + shouldFallbackToLegacyRef.current = shouldFallbackToLegacy; + + useEffect(() => { + if (!shouldFallbackToLegacy) { + return; + } + const pendingId = visibilityTraceStartedRef.current; + if (pendingId === null) { + return; + } + const open = activeVisibilityTraceRef.current; + if (open?.seriesKey === pendingId) { + endTrace({ + name: open.traceName, + id: pendingId, + data: { fallbackToLegacy: true }, + }); + activeVisibilityTraceRef.current = null; + } + visibilityTraceStartedRef.current = null; + }, [shouldFallbackToLegacy]); + + useEffect(() => { + if (shouldFallbackToLegacyRef.current) { + return; + } + if (visibilityTraceStartedRef.current === ohlcvSeriesKey) { + return; + } + + const previousSeriesId = visibilityTraceStartedRef.current; + if (previousSeriesId !== null && previousSeriesId !== ohlcvSeriesKey) { + const supersededOpen = activeVisibilityTraceRef.current; + if (supersededOpen?.seriesKey === previousSeriesId) { + endTrace({ + name: supersededOpen.traceName, + id: previousSeriesId, + data: { superseded: true }, + }); + activeVisibilityTraceRef.current = null; + } + } + const { name: visibilityTraceName, op: visibilityTraceOp } = + getAdvancedChartVisibilityTraceRequest(previousSeriesId, ohlcvSeriesKey); + + visibilityTraceStartedRef.current = ohlcvSeriesKey; + activeVisibilityTraceRef.current = { + seriesKey: ohlcvSeriesKey, + traceName: visibilityTraceName, + }; + + const currentAssetId = assetIdRef.current; + trace({ + name: visibilityTraceName, + op: visibilityTraceOp, + id: ohlcvSeriesKey, + ...(currentAssetId.length > 0 + ? { data: { assetId: currentAssetId } } + : {}), + }); + }, [ohlcvSeriesKey]); + + useEffect( + () => () => { + const open = activeVisibilityTraceRef.current; + if (open) { + endTrace({ + name: open.traceName, + id: open.seriesKey, + data: { unmounted: true }, + }); + activeVisibilityTraceRef.current = null; + visibilityTraceStartedRef.current = null; + } + }, + [], + ); + if (shouldFallbackToLegacy) { return ( diff --git a/app/components/UI/Card/Views/Cashback/Cashback.test.tsx b/app/components/UI/Card/Views/Cashback/Cashback.test.tsx index ee0c465ba45a..eda6842fcd9a 100644 --- a/app/components/UI/Card/Views/Cashback/Cashback.test.tsx +++ b/app/components/UI/Card/Views/Cashback/Cashback.test.tsx @@ -60,7 +60,7 @@ jest.mock('../../../../../util/theme', () => { jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => { const translations: Record = { - 'card.cashback_screen.available_cashback': 'Available cashback', + 'card.cashback_screen.available_cashback': 'Available mUSD', 'card.cashback_screen.network_fee': 'Network fee', 'card.cashback_screen.expected_to_receive': 'Expected to receive', 'card.cashback_screen.withdraw': 'Withdraw', @@ -221,7 +221,7 @@ describe('Cashback Component', () => { render(); expect(screen.getByTestId(CashbackSelectors.CONTAINER)).toBeOnTheScreen(); - expect(screen.queryByText('Available cashback')).toBeOnTheScreen(); + expect(screen.queryByText('Available mUSD')).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx b/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx index eb9ca1ee9f33..2865ae3a8256 100644 --- a/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx @@ -85,6 +85,7 @@ const AdvancedChart = forwardRef( enableDrawingTools = false, disabledFeatures = DEFAULT_DISABLED_FEATURES, onChartReady, + onSkeletonHidden, onError, onCrosshairMove, onChartInteracted, @@ -123,6 +124,7 @@ const AdvancedChart = forwardRef( /** When non-null, `ohlcvData` is still the previous series' array; skip sync until the hook replaces it. */ const ohlcvSeriesStaleSnapshotRef = useRef(null); const tradingViewOpenInterceptRef = useRef(0); + const skeletonHiddenReportedRef = useRef(false); const htmlContent = useMemo( () => @@ -136,6 +138,7 @@ const AdvancedChart = forwardRef( // Reset all chart state when the WebView reloads due to htmlContent changes useEffect(() => { + skeletonHiddenReportedRef.current = false; setChartReadyCount(0); setWebViewLoaded(false); activeIndicatorsRef.current.clear(); @@ -180,6 +183,7 @@ const AdvancedChart = forwardRef( if (ohlcvSeriesKey === undefined) { return; } + skeletonHiddenReportedRef.current = false; setChartReadyCount(0); setWebViewLoaded(false); setLayoutSettling(false); @@ -565,6 +569,31 @@ const AdvancedChart = forwardRef( }); }, [lineChrome, chartReadyCount, postMessage]); + const showSkeleton = isLoading || !isChartReady || layoutSettling; + + useEffect(() => { + if (webViewError) { + return; + } + if (!onSkeletonHidden) { + return; + } + if (isLoading || !isChartReady || layoutSettling) { + return; + } + if (skeletonHiddenReportedRef.current) { + return; + } + skeletonHiddenReportedRef.current = true; + onSkeletonHidden(); + }, [ + isLoading, + isChartReady, + layoutSettling, + webViewError, + onSkeletonHidden, + ]); + // ---- Render ---- if (webViewError) { @@ -601,7 +630,7 @@ const AdvancedChart = forwardRef( androidLayerType="hardware" mixedContentMode="always" /> - {(isLoading || !isChartReady || layoutSettling) && ( + {showSkeleton && ( void; + /** + * Fires once when the native skeleton overlay is removed (chart ready, layout settled, + * and parent `isLoading` false). Resets when `ohlcvSeriesKey` or chart HTML reloads. + */ + onSkeletonHidden?: () => void; /** Callback when an error occurs */ onError?: (error: string) => void; /** Crosshair OHLC data callback (for overlay legend) */ diff --git a/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx b/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx index 4849719b07c6..21ede7c67a84 100644 --- a/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx +++ b/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx @@ -373,6 +373,144 @@ describe('AdvancedChart', () => { expect(onChartReady).toHaveBeenCalledTimes(1); }); + it('calls onSkeletonHidden once when skeleton overlay is removed', () => { + const onSkeletonHidden = jest.fn(); + const { getByTestId, queryByTestId, rerender } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onLoadEnd(); + }); + + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + expect(getByTestId('advanced-chart-skeleton')).toBeOnTheScreen(); + expect(onSkeletonHidden).not.toHaveBeenCalled(); + + rerender( + , + ); + + expect(queryByTestId('advanced-chart-skeleton')).not.toBeOnTheScreen(); + expect(onSkeletonHidden).toHaveBeenCalledTimes(1); + + rerender( + , + ); + + expect(onSkeletonHidden).toHaveBeenCalledTimes(1); + }); + + it('calls onSkeletonHidden after CHART_LAYOUT_SETTLED when series key changes', () => { + const altBars: OHLCVBar[] = [ + { time: 2000000, open: 20, high: 22, low: 19, close: 21, volume: 400 }, + { time: 2000300, open: 21, high: 23, low: 20, close: 22, volume: 500 }, + ]; + const onSkeletonHidden = jest.fn(); + + const { getByTestId, queryByTestId, rerender } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onLoadEnd(); + }); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + expect(onSkeletonHidden).toHaveBeenCalledTimes(1); + expect(queryByTestId('advanced-chart-skeleton')).not.toBeOnTheScreen(); + + onSkeletonHidden.mockClear(); + rerender( + , + ); + + expect(getByTestId('advanced-chart-skeleton')).toBeOnTheScreen(); + + const webViewAfter = getByTestId('mock-webview'); + act(() => { + webViewAfter.props.onLoadEnd(); + }); + act(() => { + webViewAfter.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + act(() => { + webViewAfter.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_LAYOUT_SETTLED', payload: {} }), + }, + }); + }); + + expect(queryByTestId('advanced-chart-skeleton')).not.toBeOnTheScreen(); + expect(onSkeletonHidden).toHaveBeenCalledTimes(1); + }); + + it('does not call onSkeletonHidden when WebView error UI is shown', () => { + const onSkeletonHidden = jest.fn(); + const { getByTestId, queryByTestId } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ + type: 'ERROR', + payload: { message: 'chart init failed' }, + }), + }, + }); + }); + + expect(queryByTestId('advanced-chart-skeleton')).not.toBeOnTheScreen(); + expect(queryByTestId('mock-webview')).not.toBeOnTheScreen(); + expect(onSkeletonHidden).not.toHaveBeenCalled(); + }); + it('calls onError when chart reports an error', () => { const onError = jest.fn(); const { getByTestId } = render( diff --git a/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.tsx b/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.tsx index 623f96eb47b0..b328f249d3c0 100644 --- a/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.tsx +++ b/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.tsx @@ -1,10 +1,13 @@ import React, { useCallback, useMemo } from 'react'; import { View, Switch, InteractionManager } from 'react-native'; -import Text, { +import { + Text, TextColor, TextVariant, -} from '../../../../component-library/components/Texts/Text'; + Icon, + IconName, +} from '@metamask/design-system-react-native'; import { useTheme } from '../../../../util/theme'; import styles from './BackupAndSyncFeaturesToggles.styles'; import { useBackupAndSync } from '../../../../util/identity/hooks/useBackupAndSync'; @@ -16,9 +19,6 @@ import { selectIsBackupAndSyncUpdateLoading, } from '../../../../selectors/identity'; import { BACKUPANDSYNC_FEATURES } from '@metamask/profile-sync-controller/user-storage'; -import Icon, { - IconName, -} from '../../../../component-library/components/Icons/Icon'; import { strings } from '../../../../../locales/i18n'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; @@ -126,10 +126,10 @@ const BackupAndSyncFeaturesToggles = () => { return ( - + {strings('backupAndSync.manageWhatYouSync.title')} - + {strings('backupAndSync.manageWhatYouSync.description')} diff --git a/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.tsx b/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.tsx index 5a6f11559759..ad287b913993 100644 --- a/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.tsx +++ b/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.tsx @@ -3,10 +3,11 @@ import React, { useCallback, useEffect } from 'react'; import { View, Switch, Linking, InteractionManager } from 'react-native'; // import { useNavigation } from '@react-navigation/native'; -import Text, { +import { + Text, TextVariant, TextColor, -} from '../../../../component-library/components/Texts/Text'; +} from '@metamask/design-system-react-native'; import { useTheme } from '../../../../util/theme'; // import { strings } from '../../../../../locales/i18n'; import styles from './BackupAndSyncToggle.styles'; @@ -150,7 +151,7 @@ const BackupAndSyncToggle = ({ return ( - + {strings('backupAndSync.title')} - + {strings('backupAndSync.enable.description')} - + {strings('backupAndSync.privacyLink')} diff --git a/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/ConfirmTurnOnBackupAndSyncModal.tsx b/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/ConfirmTurnOnBackupAndSyncModal.tsx index 79a22efcaaa4..a917aa0a4c32 100644 --- a/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/ConfirmTurnOnBackupAndSyncModal.tsx +++ b/app/components/UI/Identity/ConfirmTurnOnBackupAndSyncModal/ConfirmTurnOnBackupAndSyncModal.tsx @@ -1,15 +1,13 @@ import React, { useRef } from 'react'; -import BottomSheet, { - BottomSheetRef, -} from '../../../../component-library/components/BottomSheets/BottomSheet'; -import { strings } from '../../../../../locales/i18n'; - import { + BottomSheet, + type BottomSheetRef, IconColor, IconName, IconSize, -} from '../../../../component-library/components/Icons/Icon'; +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../locales/i18n'; import ModalContent from '../../Notification/Modal'; import { toggleBasicFunctionality } from '../../../../actions/settings'; import { useParams } from '../../../../util/navigation/navUtils'; @@ -45,7 +43,7 @@ const ConfirmTurnOnBackupAndSyncModal = () => { const turnContent = { icon: { name: IconName.Check, - color: IconColor.Success, + color: IconColor.SuccessDefault, }, bottomSheetTitle: strings('backupAndSync.enable.title'), bottomSheetMessage: strings('backupAndSync.enable.confirmation'), diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.styles.ts b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.styles.ts index e7239008a51a..f2168f26c5a3 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.styles.ts +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.styles.ts @@ -7,8 +7,11 @@ const styleSheet = (params: { theme: Theme }) => flex: 1, backgroundColor: params.theme.colors.background.default, }, - scrollContent: { - paddingBottom: 0, + footerOverlay: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, }, }); diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx index 17dd6468c1da..927b5e656d61 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import { fireEvent } from '@testing-library/react-native'; +import { act, fireEvent } from '@testing-library/react-native'; +import { Linking } from 'react-native'; +import type { ReactTestInstance } from 'react-test-renderer'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import MoneyHomeView from './MoneyHomeView'; import { MoneyHomeViewTestIds } from './MoneyHomeView.testIds'; @@ -23,10 +25,13 @@ import { strings } from '../../../../../../locales/i18n'; import MOCK_MONEY_TRANSACTIONS from '../../constants/mockActivityData'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import { selectIsCardholder } from '../../../../../selectors/cardController'; +import { getDetectedGeolocation } from '../../../../../reducers/fiatOrders'; import { moneyFormatFiat } from '../../utils/moneyFormatFiat'; +import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; const mockGoBack = jest.fn(); const mockNavigate = jest.fn(); +const mockInitiateCustomConversion = jest.fn(); const mockMoneyFormatFiat = moneyFormatFiat as jest.MockedFunction< typeof moneyFormatFiat >; @@ -71,9 +76,16 @@ jest.mock('../../hooks/useMoneyAccountBalance', () => ({ })); jest.mock('../../../Earn/hooks/useMusdConversion', () => ({ - useMusdConversion: () => ({ - initiateCustomConversion: jest.fn(), - }), + useMusdConversion: jest.fn(), +})); + +jest.mock('../../../../../core/NavigationService', () => ({ + __esModule: true, + default: { + navigation: { + navigate: jest.fn(), + }, + }, })); jest.mock('../../utils/moneyFormatFiat', () => ({ @@ -85,12 +97,20 @@ jest.mock('../../../../../selectors/cardController', () => ({ selectIsCardholder: jest.fn(), })); +jest.mock('../../../../../reducers/fiatOrders', () => ({ + ...jest.requireActual('../../../../../reducers/fiatOrders'), + getDetectedGeolocation: jest.fn(), +})); + const mockSelectIsCardholder = jest.mocked(selectIsCardholder); +const mockGetDetectedGeolocation = jest.mocked(getDetectedGeolocation); const mockUseMoneyAccountTransactions = jest.mocked( useMoneyAccountTransactions, ); +const mockUseMusdConversion = jest.mocked(useMusdConversion); + const mockUseMoneyAccountBalance = jest.mocked(useMoneyAccountBalance); jest.mock( @@ -112,13 +132,22 @@ jest.mock('../../../../../component-library/components/Badges/Badge', () => ({ })); jest.mock('../../components/MoneyActivityItem/MoneyActivityItem', () => { - const { View, Text } = jest.requireActual('react-native'); + const { TouchableOpacity, Text } = jest.requireActual('react-native'); return { __esModule: true, - default: ({ tx }: { tx: { id: string } }) => ( - + default: ({ + tx, + onPress, + }: { + tx: { id: string }; + onPress?: () => void; + }) => ( + {tx.id} - + ), }; }); @@ -127,13 +156,23 @@ jest.mock('@react-native-masked-view/masked-view', () => 'MaskedView'); jest.mock('../../../../UI/AssetOverview/Balance/Balance', () => ({ NetworkBadgeSource: jest.fn(() => null), })); +jest.mock('../../../../../util/Logger', () => ({ + __esModule: true, + default: { error: jest.fn() }, +})); describe('MoneyHomeView', () => { beforeEach(() => { jest.clearAllMocks(); global.alert = jest.fn(); + mockInitiateCustomConversion.mockResolvedValue(undefined); + mockUseMusdConversion.mockReturnValue({ + initiateCustomConversion: mockInitiateCustomConversion, + } as unknown as ReturnType); + mockSelectIsCardholder.mockReturnValue(false); + mockGetDetectedGeolocation.mockReturnValue('US'); mockUseMoneyAccountBalance.mockReturnValue({ totalFiatFormatted: '$3.00', @@ -331,22 +370,68 @@ describe('MoneyHomeView', () => { expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { screen: Routes.MONEY.MODALS.EARNINGS_INFO_SHEET, - params: { apy: 5 }, }); }); - describe('projected earnings', () => { - it('passes the formatted projected earnings to MoneyEarnings', () => { + it('navigates to Card root when Get now row is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT); + }); + + it('navigates to potential earnings screen when View potential earnings is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyPotentialEarningsTestIds.VIEW_ALL_BUTTON)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.POTENTIAL_EARNINGS); + }); + + it('opens the MUSD learn more URL when learn more is pressed in empty state', () => { + const mockOpenURL = jest + .spyOn(Linking, 'openURL') + .mockResolvedValue(undefined); + + mockUseMoneyAccountTransactions.mockReturnValue({ + allTransactions: [], + deposits: [], + transfers: [], + submittedTransactions: [], + moneyAddress: '0x0000000000000000000000000000000000000001', + }); + + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyWhatYouGetTestIds.LEARN_MORE_BUTTON)); + + expect(mockOpenURL).toHaveBeenCalledTimes(1); + mockOpenURL.mockRestore(); + }); + + describe('monthly and yearly earnings', () => { + it('passes the formatted monthly earnings to MoneyEarnings', () => { mockMoneyFormatFiat.mockReturnValue('$0.12'); const { getByTestId } = renderWithProvider(); - expect( - getByTestId(MoneyEarningsTestIds.PROJECTED_VALUE), - ).toHaveTextContent('$0.12'); + expect(getByTestId(MoneyEarningsTestIds.MONTHLY_VALUE)).toHaveTextContent( + '$0.12', + ); + }); + + it('passes the formatted yearly earnings to MoneyEarnings', () => { + mockMoneyFormatFiat.mockReturnValue('$0.12'); + + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyEarningsTestIds.YEARLY_VALUE)).toHaveTextContent( + '$0.12', + ); }); - it('displays the zero-formatted value for projected earnings when totalFiatRaw is absent', () => { + it('displays the zero-formatted value for monthly earnings when totalFiatRaw is absent', () => { mockMoneyFormatFiat.mockReturnValue('$0.00'); mockUseMoneyAccountBalance.mockReturnValue({ totalFiatFormatted: undefined, @@ -365,9 +450,9 @@ describe('MoneyHomeView', () => { const { getByTestId } = renderWithProvider(); - expect( - getByTestId(MoneyEarningsTestIds.PROJECTED_VALUE), - ).toHaveTextContent('$0.00'); + expect(getByTestId(MoneyEarningsTestIds.MONTHLY_VALUE)).toHaveTextContent( + '$0.00', + ); }); }); @@ -591,5 +676,455 @@ describe('MoneyHomeView', () => { screen: Routes.MONEY.MODALS.ADD_MONEY_SHEET, }); }); + + it('navigates to Asset details when the mUSD token row is pressed', () => { + const NavigationService = jest.requireMock( + '../../../../../core/NavigationService', + ).default; + + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyMusdTokenRowTestIds.CONTAINER)); + + expect(NavigationService.navigation.navigate).toHaveBeenCalledWith( + 'Asset', + expect.objectContaining({ source: expect.any(String) }), + ); + }); + + it('navigates to HowItWorks when its section header is pressed', () => { + const { getByText } = renderWithProvider(); + + fireEvent.press(getByText(strings('money.how_it_works.title'))); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.HOW_IT_WORKS); + }); + + it('opens the Learn more URL when Learn more is pressed', () => { + const { Linking } = jest.requireMock('react-native'); + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyWhatYouGetTestIds.LEARN_MORE_BUTTON)); + + expect(Linking.openURL).toHaveBeenCalledWith( + expect.stringContaining('http'), + ); + }); + }); + + describe('filled state navigation handlers', () => { + it('navigates to Potential Earnings when View all is pressed on potential earnings section', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press( + getByTestId(MoneyPotentialEarningsTestIds.VIEW_ALL_BUTTON), + ); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MONEY.POTENTIAL_EARNINGS, + ); + }); + + it('initiates a custom conversion when a token Convert button is pressed', async () => { + const { getByText } = renderWithProvider(); + + fireEvent.press(getByText(strings('money.potential_earnings.convert'))); + + expect(mockInitiateCustomConversion).toHaveBeenCalledWith( + expect.objectContaining({ + preferredPaymentToken: expect.objectContaining({ + address: mockConversionTokens[0].address, + }), + navigationStack: Routes.MONEY.ROOT, + }), + ); + }); + + it('logs an error when initiateCustomConversion rejects', async () => { + mockInitiateCustomConversion.mockRejectedValueOnce( + new Error('network failure'), + ); + const Logger = jest.requireMock('../../../../../util/Logger'); + + const { getByText } = renderWithProvider(); + + fireEvent.press(getByText(strings('money.potential_earnings.convert'))); + + await Promise.resolve(); + + expect(Logger.default.error).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + message: expect.stringContaining('MoneyHomeView'), + }), + ); + }); + + it('triggers the under-construction alert when an activity item is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId('money-activity-item-padded-0')); + + expect(global.alert).toHaveBeenCalled(); + }); + }); + + describe('card upsell mode — Get Now handler', () => { + it('navigates to Card root when the Get Now card row is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW)); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT); + }); + }); + + describe('Metal card geolocation gating', () => { + it('renders the Metal card row when geolocation is US', () => { + mockGetDetectedGeolocation.mockReturnValue('US'); + + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).toBeOnTheScreen(); + expect( + getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW), + ).toBeOnTheScreen(); + }); + + it('renders the Metal card row when geolocation is a US sub-region (e.g. US-CA)', () => { + mockGetDetectedGeolocation.mockReturnValue('us-ca'); + + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).toBeOnTheScreen(); + }); + + it('hides the Metal card row when geolocation is GB', () => { + mockGetDetectedGeolocation.mockReturnValue('GB'); + + const { queryByTestId, getByTestId } = renderWithProvider( + , + ); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).not.toBeOnTheScreen(); + expect( + getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW), + ).toBeOnTheScreen(); + }); + + it('hides the Metal card row when geolocation is undefined (loading/unknown - fail closed)', () => { + mockGetDetectedGeolocation.mockReturnValue(undefined); + + const { queryByTestId, getByTestId } = renderWithProvider( + , + ); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).not.toBeOnTheScreen(); + expect( + getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW), + ).toBeOnTheScreen(); + }); + }); + + describe('Get now navigation', () => { + it('navigates to the card sign-up flow when the virtual card Get now button is pressed', () => { + mockGetDetectedGeolocation.mockReturnValue('GB'); + + const { getByText } = renderWithProvider(); + + fireEvent.press(getByText(strings('money.metamask_card.get_now'))); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT); + }); + + it('navigates to the card sign-up flow when the metal card Get now button is pressed', () => { + mockGetDetectedGeolocation.mockReturnValue('US'); + + const { getAllByText } = renderWithProvider(); + const buttons = getAllByText(strings('money.metamask_card.get_now')); + + fireEvent.press(buttons[1]); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT); + }); + }); + + describe('Add money footer peek-and-hide', () => { + it('mounts the footer in its hidden initial position', () => { + const { getByTestId } = renderWithProvider(); + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('handleScrollViewLayout updates scroll view height and calls updateStepperVisibility', () => { + const { getByTestId } = renderWithProvider(); + const scrollView = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW); + + act(() => { + fireEvent(scrollView, 'layout', { + nativeEvent: { layout: { height: 700, width: 390, x: 0, y: 0 } }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('handleScrollViewLayout is a no-op when height is unchanged', () => { + const { getByTestId } = renderWithProvider(); + const scrollView = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW); + + act(() => { + fireEvent(scrollView, 'layout', { + nativeEvent: { layout: { height: 600, width: 390, x: 0, y: 0 } }, + }); + }); + + act(() => { + fireEvent(scrollView, 'layout', { + nativeEvent: { layout: { height: 600, width: 390, x: 0, y: 0 } }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('handleScroll records the current scroll offset and calls updateStepperVisibility', () => { + const { getByTestId } = renderWithProvider(); + const scrollView = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW); + + act(() => { + fireEvent.scroll(scrollView, { + nativeEvent: { + contentOffset: { y: 300, x: 0 }, + contentSize: { height: 1200, width: 390 }, + layoutMeasurement: { height: 700, width: 390 }, + }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('handleStepperLayout stores new layout and triggers visibility update', () => { + const { UNSAFE_getAllByType, getByTestId } = renderWithProvider( + , + ); + + const Box = jest.requireActual( + '@metamask/design-system-react-native', + ).Box; + const stepperBox = UNSAFE_getAllByType(Box).find( + (b: { props: { onLayout?: unknown } }) => b.props.onLayout, + ); + + act(() => { + stepperBox?.props.onLayout({ + nativeEvent: { layout: { y: 200, height: 120, x: 0, width: 390 } }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('handleStepperLayout is a no-op when layout dimensions are unchanged', () => { + const { UNSAFE_getAllByType, getByTestId } = renderWithProvider( + , + ); + + const Box = jest.requireActual( + '@metamask/design-system-react-native', + ).Box; + const stepperBox = UNSAFE_getAllByType(Box).find( + (b: { props: { onLayout?: unknown } }) => b.props.onLayout, + ); + + act(() => { + stepperBox?.props.onLayout({ + nativeEvent: { layout: { y: 200, height: 120, x: 0, width: 390 } }, + }); + }); + + act(() => { + stepperBox?.props.onLayout({ + nativeEvent: { layout: { y: 200, height: 120, x: 0, width: 390 } }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('footer peek-in: scrolling past stepper bottom triggers animateFooter(true)', () => { + const { UNSAFE_getAllByType, getByTestId } = renderWithProvider( + , + ); + + const scrollView = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW); + + act(() => { + fireEvent(scrollView, 'layout', { + nativeEvent: { layout: { height: 700, width: 390, x: 0, y: 0 } }, + }); + }); + + const Box = jest.requireActual( + '@metamask/design-system-react-native', + ).Box; + const stepperBox = UNSAFE_getAllByType(Box).find( + (b: { props: { onLayout?: unknown } }) => b.props.onLayout, + ); + + act(() => { + stepperBox?.props.onLayout({ + nativeEvent: { layout: { y: 100, height: 200, x: 0, width: 390 } }, + }); + }); + + act(() => { + fireEvent.scroll(scrollView, { + nativeEvent: { + contentOffset: { y: 500, x: 0 }, + contentSize: { height: 2000, width: 390 }, + layoutMeasurement: { height: 700, width: 390 }, + }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('footer hide: scrolling back above stepper bottom triggers animateFooter(false)', () => { + const { UNSAFE_getAllByType, getByTestId } = renderWithProvider( + , + ); + + const scrollView = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW); + + act(() => { + fireEvent(scrollView, 'layout', { + nativeEvent: { layout: { height: 700, width: 390, x: 0, y: 0 } }, + }); + }); + + const Box = jest.requireActual( + '@metamask/design-system-react-native', + ).Box; + const stepperBox = UNSAFE_getAllByType(Box).find( + (b: { props: { onLayout?: unknown } }) => b.props.onLayout, + ); + + act(() => { + stepperBox?.props.onLayout({ + nativeEvent: { layout: { y: 100, height: 200, x: 0, width: 390 } }, + }); + }); + + act(() => { + fireEvent.scroll(scrollView, { + nativeEvent: { + contentOffset: { y: 500, x: 0 }, + contentSize: { height: 2000, width: 390 }, + layoutMeasurement: { height: 700, width: 390 }, + }, + }); + }); + + act(() => { + fireEvent.scroll(scrollView, { + nativeEvent: { + contentOffset: { y: 50, x: 0 }, + contentSize: { height: 2000, width: 390 }, + layoutMeasurement: { height: 700, width: 390 }, + }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('updateStepperVisibility does not animate when visibility is unchanged', () => { + const { getByTestId } = renderWithProvider(); + const scrollView = getByTestId(MoneyHomeViewTestIds.SCROLL_VIEW); + + act(() => { + fireEvent.scroll(scrollView, { + nativeEvent: { + contentOffset: { y: 0, x: 0 }, + contentSize: { height: 2000, width: 390 }, + layoutMeasurement: { height: 700, width: 390 }, + }, + }); + }); + + act(() => { + fireEvent.scroll(scrollView, { + nativeEvent: { + contentOffset: { y: 10, x: 0 }, + contentSize: { height: 2000, width: 390 }, + layoutMeasurement: { height: 700, width: 390 }, + }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('handleFooterLayout updates footer height on first measurement', () => { + const { getByTestId } = renderWithProvider(); + + const footerEl = getByTestId(MoneyFooterTestIds.CONTAINER); + let footerAnimatedView: ReactTestInstance | null = null; + let cursor: ReactTestInstance | null = footerEl.parent ?? null; + while (cursor) { + if (typeof cursor.props?.onLayout === 'function') { + footerAnimatedView = cursor; + break; + } + cursor = cursor.parent ?? null; + } + + act(() => { + footerAnimatedView?.props.onLayout?.({ + nativeEvent: { layout: { height: 80, width: 390, x: 0, y: 0 } }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); + + it('handleFooterLayout is a no-op when footer height is unchanged', () => { + const { getByTestId } = renderWithProvider(); + + const footerEl = getByTestId(MoneyFooterTestIds.CONTAINER); + let footerAnimatedView: ReactTestInstance | null = null; + let cursor: ReactTestInstance | null = footerEl.parent ?? null; + while (cursor) { + if (typeof cursor.props?.onLayout === 'function') { + footerAnimatedView = cursor; + break; + } + cursor = cursor.parent ?? null; + } + + act(() => { + footerAnimatedView?.props.onLayout?.({ + nativeEvent: { layout: { height: 80, width: 390, x: 0, y: 0 } }, + }); + }); + + act(() => { + footerAnimatedView?.props.onLayout?.({ + nativeEvent: { layout: { height: 80, width: 390, x: 0, y: 0 } }, + }); + }); + + expect(getByTestId(MoneyFooterTestIds.CONTAINER)).toBeOnTheScreen(); + }); }); }); diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx index a6875f3d2695..dc97b58b7526 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx @@ -1,5 +1,17 @@ -import React, { useCallback, useMemo } from 'react'; -import { ScrollView, Linking } from 'react-native'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { + LayoutChangeEvent, + Linking, + NativeScrollEvent, + NativeSyntheticEvent, + ScrollView, +} from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; @@ -23,6 +35,7 @@ import MoneyFooter from '../../components/MoneyFooter'; import Routes from '../../../../../constants/navigation/Routes'; import { MoneyHomeViewTestIds } from './MoneyHomeView.testIds'; import styleSheet from './MoneyHomeView.styles'; +import { computeStepperVisibility } from './utils/computeStepperVisibility'; import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; import { useMoneyAccountTransactions } from '../../hooks/useMoneyAccountTransactions'; @@ -36,12 +49,18 @@ import { TokenDetailsSource } from '../../../TokenDetails/constants/constants'; import AppConstants from '../../../../../core/AppConstants'; import NavigationService from '../../../../../core/NavigationService'; import { selectIsCardholder } from '../../../../../selectors/cardController'; +import { getDetectedGeolocation } from '../../../../../reducers/fiatOrders'; import Logger from '../../../../../util/Logger'; import { AssetType } from '../../../../Views/confirmations/types/token'; import { Hex } from '@metamask/utils'; const Divider = () => ; +// Slide distance for the footer peek-in/out animation. Large enough to fully +// clear any realistic footer height (button + safe-area insets). +const FOOTER_HIDDEN_OFFSET = 240; +const FOOTER_ANIMATION_DURATION_MS = 300; + type MoneyHomeState = 'empty' | 'milestone' | 'filled'; const getMoneyHomeState = (transactionCount: number): MoneyHomeState => { @@ -73,6 +92,8 @@ const MoneyHomeView = () => { const { allTransactions, moneyAddress } = useMoneyAccountTransactions(); const isCardholder = useSelector(selectIsCardholder); + const geolocation = useSelector(getDetectedGeolocation); + const isUS = geolocation?.toUpperCase().split('-')[0] === 'US'; const homeState = getMoneyHomeState(allTransactions.length); const isMilestone = homeState === 'milestone' || homeState === 'filled'; @@ -83,11 +104,28 @@ const MoneyHomeView = () => { [currentCurrency], ); - const projectedEarnings = useMemo(() => { + const monthlyEarnings = useMemo(() => { if (!totalFiatRaw || !apyPercent) return formattedZero; const balance = new BigNumber(totalFiatRaw); if (balance.isZero() || balance.isNaN()) return formattedZero; - const earnings = calculateProjectedEarnings(balance.toNumber(), apyPercent); + const earnings = calculateProjectedEarnings( + balance.toNumber(), + apyPercent, + 1 / 12, + ); + if (!Number.isFinite(earnings)) return formattedZero; + return moneyFormatFiat(new BigNumber(earnings), currentCurrency); + }, [totalFiatRaw, apyPercent, currentCurrency, formattedZero]); + + const yearlyEarnings = useMemo(() => { + if (!totalFiatRaw || !apyPercent) return formattedZero; + const balance = new BigNumber(totalFiatRaw); + if (balance.isZero() || balance.isNaN()) return formattedZero; + const earnings = calculateProjectedEarnings( + balance.toNumber(), + apyPercent, + 1, + ); if (!Number.isFinite(earnings)) return formattedZero; return moneyFormatFiat(new BigNumber(earnings), currentCurrency); }, [totalFiatRaw, apyPercent, currentCurrency, formattedZero]); @@ -138,9 +176,8 @@ const MoneyHomeView = () => { const handleEarningsInfoPress = useCallback(() => { navigation.navigate(Routes.MONEY.MODALS.ROOT, { screen: Routes.MONEY.MODALS.EARNINGS_INFO_SHEET, - params: { apy: apyPercent }, }); - }, [navigation, apyPercent]); + }, [navigation]); const handleMusdRowPress = useCallback(() => { NavigationService.navigation.navigate('Asset', { @@ -192,6 +229,97 @@ const MoneyHomeView = () => { showMoneyActivityUnderConstructionAlert(); }, []); + // Stepper layout, scroll offset, and scroll view height are read on every + // scroll event (~60fps with scrollEventThrottle={16}). Storing them as state + // would re-render MoneyHomeView on every frame during scrolling. + const stepperLayoutRef = useRef<{ y: number; height: number } | null>(null); + const scrollOffsetYRef = useRef(0); + const scrollViewHeightRef = useRef(0); + // ScrollView reserves matching bottom padding so the absolutely positioned + // footer overlay never hides scroll content -- state, not a ref, so the + // padding update triggers a re-render. + const [footerHeight, setFooterHeight] = useState(0); + + const footerTranslateY = useSharedValue(FOOTER_HIDDEN_OFFSET); + const footerAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: footerTranslateY.value }], + })); + const scrollContentStyle = useMemo( + () => ({ paddingBottom: footerHeight }), + [footerHeight], + ); + + // Default to "visible" until layouts settle so the footer stays hidden on + // initial paint and we avoid a flash of "Add money". + const isStepperVisibleRef = useRef(true); + + const getStepperVisible = useCallback( + () => + computeStepperVisibility({ + stepperLayout: stepperLayoutRef.current, + scrollViewHeight: scrollViewHeightRef.current, + scrollOffsetY: scrollOffsetYRef.current, + }), + [], + ); + + const animateFooter = useCallback( + (visible: boolean) => { + footerTranslateY.value = withTiming(visible ? 0 : FOOTER_HIDDEN_OFFSET, { + duration: FOOTER_ANIMATION_DURATION_MS, + easing: visible ? Easing.out(Easing.cubic) : Easing.in(Easing.cubic), + }); + }, + [footerTranslateY], + ); + + const updateStepperVisibility = useCallback(() => { + const next = getStepperVisible(); + if (next === isStepperVisibleRef.current) return; + isStepperVisibleRef.current = next; + animateFooter(!next); + }, [getStepperVisible, animateFooter]); + + const handleStepperLayout = useCallback( + (event: LayoutChangeEvent) => { + const { y, height } = event.nativeEvent.layout; + const prev = stepperLayoutRef.current; + if (prev && prev.y === y && prev.height === height) { + return; + } + stepperLayoutRef.current = { y, height }; + updateStepperVisibility(); + }, + [updateStepperVisibility], + ); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + // Update the ref unconditionally on every scroll frame (cheap) but only + // commit a state change when the visibility boolean actually flips. + scrollOffsetYRef.current = event.nativeEvent.contentOffset.y; + updateStepperVisibility(); + }, + [updateStepperVisibility], + ); + + const handleScrollViewLayout = useCallback( + (event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout; + if (scrollViewHeightRef.current === height) { + return; + } + scrollViewHeightRef.current = height; + updateStepperVisibility(); + }, + [updateStepperVisibility], + ); + + const handleFooterLayout = useCallback((event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout; + setFooterHeight((prev) => (prev === height ? prev : height)); + }, []); + const handleOnboardingCtaPress = useCallback(() => { if (isCardholderWithMilestone) { handleLinkCardPress(); @@ -224,8 +352,11 @@ const MoneyHomeView = () => { /> { onTransferPress={handleTransferPress} onCardPress={handleCardPress} /> - + + + @@ -282,7 +415,6 @@ const MoneyHomeView = () => { { onHeaderPress={handleHeaderPress} onLinkPress={handleLinkCardPress} apy={apyPercent} + showMetalCard={isUS} /> {isMilestone && ( @@ -315,7 +448,12 @@ const MoneyHomeView = () => { /> )} - + + + ); }; diff --git a/app/components/UI/Money/Views/MoneyHomeView/utils/computeStepperVisibility.test.ts b/app/components/UI/Money/Views/MoneyHomeView/utils/computeStepperVisibility.test.ts new file mode 100644 index 000000000000..7f1c2a90ede0 --- /dev/null +++ b/app/components/UI/Money/Views/MoneyHomeView/utils/computeStepperVisibility.test.ts @@ -0,0 +1,65 @@ +import { computeStepperVisibility } from './computeStepperVisibility'; + +describe('computeStepperVisibility', () => { + it('returns true when layout is null (still measuring)', () => { + expect( + computeStepperVisibility({ + stepperLayout: null, + scrollViewHeight: 600, + scrollOffsetY: 0, + }), + ).toBe(true); + }); + + it('returns true when stepper height is 0 (unmeasured)', () => { + expect( + computeStepperVisibility({ + stepperLayout: { y: 0, height: 0 }, + scrollViewHeight: 600, + scrollOffsetY: 0, + }), + ).toBe(true); + }); + + it('returns true when scrollViewHeight is 0 (scrollview not laid out)', () => { + expect( + computeStepperVisibility({ + stepperLayout: { y: 100, height: 300 }, + scrollViewHeight: 0, + scrollOffsetY: 0, + }), + ).toBe(true); + }); + + it('returns false when user has scrolled past the stepper bottom', () => { + // Stepper occupies y=[100, 400]. Scrolling to 500 puts the user past it. + expect( + computeStepperVisibility({ + stepperLayout: { y: 100, height: 300 }, + scrollViewHeight: 600, + scrollOffsetY: 500, + }), + ).toBe(false); + }); + + it('returns true when user is exactly at the stepper bottom (boundary, inclusive)', () => { + // stepperBottom = 100 + 300 = 400; offset === 400 stays "visible". + expect( + computeStepperVisibility({ + stepperLayout: { y: 100, height: 300 }, + scrollViewHeight: 600, + scrollOffsetY: 400, + }), + ).toBe(true); + }); + + it('returns true when stepper is fully on screen and user has not scrolled', () => { + expect( + computeStepperVisibility({ + stepperLayout: { y: 100, height: 300 }, + scrollViewHeight: 600, + scrollOffsetY: 0, + }), + ).toBe(true); + }); +}); diff --git a/app/components/UI/Money/Views/MoneyHomeView/utils/computeStepperVisibility.ts b/app/components/UI/Money/Views/MoneyHomeView/utils/computeStepperVisibility.ts new file mode 100644 index 000000000000..218972af07db --- /dev/null +++ b/app/components/UI/Money/Views/MoneyHomeView/utils/computeStepperVisibility.ts @@ -0,0 +1,36 @@ +export interface StepperLayout { + y: number; + height: number; +} + +export interface ComputeStepperVisibilityArgs { + stepperLayout: StepperLayout | null; + scrollViewHeight: number; + scrollOffsetY: number; +} + +/** + * Returns true when the onboarding stepper should be considered "visible" + * for footer peek-and-hide purposes. + * + * Layout / measurement still pending (any of: layout null, height === 0, + * scrollViewHeight === 0): return true so the footer stays hidden until the + * stepper's onLayout reports a real height. Avoids a flash of "Add money" + * before measurements settle. + * + * User has scrolled past the stepper's bottom edge: return false (the footer + * should peek in). + * + * Otherwise (stepper still on screen or below the fold): return true. + */ +export const computeStepperVisibility = ({ + stepperLayout, + scrollViewHeight, + scrollOffsetY, +}: ComputeStepperVisibilityArgs): boolean => { + if (!stepperLayout || stepperLayout.height === 0 || scrollViewHeight === 0) { + return true; + } + const stepperBottom = stepperLayout.y + stepperLayout.height; + return scrollOffsetY <= stepperBottom; +}; diff --git a/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.test.tsx b/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.test.tsx index fa0501d1a91a..ee847929b0da 100644 --- a/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.test.tsx +++ b/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.test.tsx @@ -5,80 +5,103 @@ import { MoneyEarningsTestIds } from './MoneyEarnings.testIds'; import { strings } from '../../../../../../locales/i18n'; const ZERO_VALUE = '$0.00'; +const MONTHLY_VALUE = '$1.23'; +const YEARLY_VALUE = '$14.76'; describe('MoneyEarnings', () => { it('renders the section title', () => { const { getByText } = render( , ); expect(getByText(strings('money.earnings.title'))).toBeOnTheScreen(); }); + it('renders the estimated monthly and yearly labels', () => { + const { getByText } = render( + , + ); + + expect( + getByText(strings('money.earnings.estimated_monthly')), + ).toBeOnTheScreen(); + expect( + getByText(strings('money.earnings.estimated_yearly')), + ).toBeOnTheScreen(); + }); + it('renders the provided zero values when no real earnings exist', () => { const { getByTestId } = render( , ); - expect(getByTestId(MoneyEarningsTestIds.LIFETIME_VALUE)).toHaveTextContent( + expect(getByTestId(MoneyEarningsTestIds.MONTHLY_VALUE)).toHaveTextContent( ZERO_VALUE, ); - expect(getByTestId(MoneyEarningsTestIds.PROJECTED_VALUE)).toHaveTextContent( + expect(getByTestId(MoneyEarningsTestIds.YEARLY_VALUE)).toHaveTextContent( ZERO_VALUE, ); }); - it('renders the provided lifetime and projected earnings values', () => { + it('renders the provided monthly and yearly earnings values', () => { const { getByTestId } = render( - , + , ); - expect(getByTestId(MoneyEarningsTestIds.LIFETIME_VALUE)).toHaveTextContent( - '$12.34', + expect(getByTestId(MoneyEarningsTestIds.MONTHLY_VALUE)).toHaveTextContent( + MONTHLY_VALUE, ); - expect(getByTestId(MoneyEarningsTestIds.PROJECTED_VALUE)).toHaveTextContent( - '$56.78', + expect(getByTestId(MoneyEarningsTestIds.YEARLY_VALUE)).toHaveTextContent( + YEARLY_VALUE, ); }); it('renders skeletons instead of values when loading', () => { const { getByTestId, queryByTestId } = render( , ); expect( - getByTestId(MoneyEarningsTestIds.LIFETIME_SKELETON), + getByTestId(MoneyEarningsTestIds.MONTHLY_SKELETON), ).toBeOnTheScreen(); + expect(getByTestId(MoneyEarningsTestIds.YEARLY_SKELETON)).toBeOnTheScreen(); expect( - getByTestId(MoneyEarningsTestIds.PROJECTED_SKELETON), - ).toBeOnTheScreen(); - expect( - queryByTestId(MoneyEarningsTestIds.LIFETIME_VALUE), + queryByTestId(MoneyEarningsTestIds.MONTHLY_VALUE), ).not.toBeOnTheScreen(); expect( - queryByTestId(MoneyEarningsTestIds.PROJECTED_VALUE), + queryByTestId(MoneyEarningsTestIds.YEARLY_VALUE), ).not.toBeOnTheScreen(); }); - it('renders lifetime earnings in success color when value starts with +', () => { + it('renders value text in default color regardless of sign', () => { const { getByTestId } = render( , ); - const lifetimeValue = getByTestId(MoneyEarningsTestIds.LIFETIME_VALUE); - expect(lifetimeValue).toHaveTextContent('+$2.84'); + expect(getByTestId(MoneyEarningsTestIds.MONTHLY_VALUE)).toHaveTextContent( + MONTHLY_VALUE, + ); + expect(getByTestId(MoneyEarningsTestIds.YEARLY_VALUE)).toHaveTextContent( + YEARLY_VALUE, + ); }); }); diff --git a/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.testIds.ts b/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.testIds.ts index e5ee62e7d9f5..b2082b2a838c 100644 --- a/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.testIds.ts +++ b/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.testIds.ts @@ -1,8 +1,9 @@ export const MoneyEarningsTestIds = { CONTAINER: 'money-earnings-container', - LIFETIME: 'money-earnings-lifetime', - LIFETIME_VALUE: 'money-earnings-lifetime-value', - LIFETIME_SKELETON: 'money-earnings-lifetime-skeleton', - PROJECTED_VALUE: 'money-earnings-projected-value', - PROJECTED_SKELETON: 'money-earnings-projected-skeleton', + MONTHLY: 'money-earnings-monthly', + MONTHLY_VALUE: 'money-earnings-monthly-value', + MONTHLY_SKELETON: 'money-earnings-monthly-skeleton', + YEARLY: 'money-earnings-yearly', + YEARLY_VALUE: 'money-earnings-yearly-value', + YEARLY_SKELETON: 'money-earnings-yearly-skeleton', } as const; diff --git a/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.tsx b/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.tsx index 6754fa15ee00..2ceef8618c74 100644 --- a/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.tsx +++ b/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.tsx @@ -4,10 +4,6 @@ import { BoxAlignItems, BoxFlexDirection, FontWeight, - Icon, - IconColor, - IconName, - IconSize, Skeleton, Text, TextColor, @@ -19,14 +15,15 @@ import { MoneyEarningsTestIds } from './MoneyEarnings.testIds'; interface MoneyEarningsProps { /** - * Cumulative yield earned to date, formatted in the user's selected currency. + * Estimated monthly earnings based on current balance and APY, formatted in + * the user's selected currency. */ - lifetimeEarnings: string; + monthlyEarnings: string; /** - * Forward-looking earnings based on current balance and APY, formatted in + * Estimated yearly earnings based on current balance and APY, formatted in * the user's selected currency. */ - projectedEarnings: string; + yearlyEarnings: string; /** * Render skeletons in place of the two earnings values while data is being * fetched. @@ -42,16 +39,13 @@ interface MoneyEarningsProps { const ValueText = ({ children, testID, - color, }: { children: string; testID: string; - color?: TextColor; }) => ( {children} @@ -59,8 +53,8 @@ const ValueText = ({ ); const MoneyEarnings = ({ - lifetimeEarnings, - projectedEarnings, + monthlyEarnings, + yearlyEarnings, isLoading = false, onInfoPress, }: MoneyEarningsProps) => ( @@ -71,62 +65,47 @@ const MoneyEarnings = ({ infoAccessibilityLabel={strings('money.earnings.info_label')} /> - - - - {strings('money.earnings.lifetime')} + + + + {strings('money.earnings.estimated_monthly')} {isLoading ? ( ) : ( - - {lifetimeEarnings} + + {monthlyEarnings} )} - - - - {strings('money.earnings.projected')} - - + + + {strings('money.earnings.estimated_yearly')} + {isLoading ? ( ) : ( - - {projectedEarnings} + + {yearlyEarnings} )} diff --git a/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.test.tsx b/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.test.tsx index 912256fed27a..727eb9817d7a 100644 --- a/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.test.tsx +++ b/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.test.tsx @@ -4,7 +4,6 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider'; import MoneyEarningsInfoSheet from './MoneyEarningsInfoSheet'; import { MoneyEarningsInfoSheetTestIds } from './MoneyEarningsInfoSheet.testIds'; import { strings } from '../../../../../../locales/i18n'; -import { useParams } from '../../../../../util/navigation/navUtils'; const mockOnCloseBottomSheet = jest.fn((cb?: () => void) => cb?.()); const mockGoBack = jest.fn(); @@ -19,10 +18,6 @@ jest.mock('@react-navigation/native', () => { }; }); -jest.mock('../../../../../util/navigation/navUtils', () => ({ - useParams: jest.fn(), -})); - jest.mock('@metamask/design-system-react-native', () => { const actual = jest.requireActual('@metamask/design-system-react-native'); const ReactActual = jest.requireActual('react'); @@ -66,14 +61,9 @@ jest.mock('@metamask/design-system-react-native', () => { }; }); -const mockUseParams = useParams as jest.MockedFunction; - -const DEFAULT_APY = 4; - describe('MoneyEarningsInfoSheet', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseParams.mockReturnValue({ apy: DEFAULT_APY }); }); it('renders the container', () => { @@ -92,52 +82,10 @@ describe('MoneyEarningsInfoSheet', () => { ).toBeOnTheScreen(); }); - it('renders the lifetime section heading', () => { - const { getByText } = renderWithProvider(); - - expect( - getByText(strings('money.earnings_tooltip.lifetime_heading')), - ).toBeOnTheScreen(); - }); - - it('renders the lifetime section body', () => { - const { getByText } = renderWithProvider(); - - expect( - getByText(strings('money.earnings_tooltip.lifetime_body'), { - exact: false, - }), - ).toBeOnTheScreen(); - }); - - it('renders the projected section heading', () => { + it('renders the body paragraph', () => { const { getByText } = renderWithProvider(); - expect( - getByText(strings('money.earnings_tooltip.projected_heading')), - ).toBeOnTheScreen(); - }); - - it('renders the projected section body', () => { - const { getByText } = renderWithProvider(); - - expect( - getByText(strings('money.earnings_tooltip.projected_body'), { - exact: false, - }), - ).toBeOnTheScreen(); - }); - - it('renders the disclaimer with the apy percentage interpolated', () => { - const { getByText } = renderWithProvider(); - - expect( - getByText( - strings('money.earnings_tooltip.disclaimer', { - percentage: DEFAULT_APY, - }), - ), - ).toBeOnTheScreen(); + expect(getByText(strings('money.earnings_tooltip.body'))).toBeOnTheScreen(); }); it('renders the Got It footer button', () => { diff --git a/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.tsx b/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.tsx index 4edee4a74751..b6ea317d2797 100644 --- a/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.tsx +++ b/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.tsx @@ -7,26 +7,18 @@ import { BottomSheetHeader, ButtonSize, type BottomSheetRef, - FontWeight, Text, - TextColor, TextVariant, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import { useStyles } from '../../../../../component-library/hooks'; -import { useParams } from '../../../../../util/navigation/navUtils'; import styleSheet from './MoneyEarningsInfoSheet.styles'; import { MoneyEarningsInfoSheetTestIds } from './MoneyEarningsInfoSheet.testIds'; -interface MoneyEarningsInfoSheetParams { - apy: number; -} - const MoneyEarningsInfoSheet = () => { const sheetRef = useRef(null); const navigation = useNavigation(); const { styles } = useStyles(styleSheet, {}); - const { apy } = useParams(); const handleGoBack = useCallback(() => { navigation.goBack(); @@ -54,21 +46,7 @@ const MoneyEarningsInfoSheet = () => { - - {strings('money.earnings_tooltip.lifetime_heading')} - - {'\n'} - {strings('money.earnings_tooltip.lifetime_body')} - - - - {strings('money.earnings_tooltip.projected_heading')} - - {'\n'} - {strings('money.earnings_tooltip.projected_body')} - - - {strings('money.earnings_tooltip.disclaimer', { percentage: apy })} + {strings('money.earnings_tooltip.body')} { ).toBeOnTheScreen(); }); - it('renders metal card row', () => { + it('renders metal card row when showMetalCard is true', () => { const { getByText, getByTestId } = render( - , + , ); expect( @@ -48,14 +48,36 @@ describe('MoneyMetaMaskCard', () => { ).toBeOnTheScreen(); }); + it('hides metal card row by default (showMetalCard not provided)', () => { + const { queryByTestId, queryByText } = render( + , + ); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).not.toBeOnTheScreen(); + expect( + queryByText(strings('money.metamask_card.metal_card')), + ).not.toBeOnTheScreen(); + }); + + it('hides metal card row when showMetalCard is false', () => { + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).not.toBeOnTheScreen(); + }); + it('calls onGetNowPress when virtual card Get now is pressed', () => { const mockGetNow = jest.fn(); - const { getAllByText } = render( + const { getByText } = render( , ); - const getNowButtons = getAllByText(strings('money.metamask_card.get_now')); - fireEvent.press(getNowButtons[0]); + fireEvent.press(getByText(strings('money.metamask_card.get_now'))); expect(mockGetNow).toHaveBeenCalledTimes(1); expect(mockGetNow.mock.calls[0]).toEqual([]); @@ -64,7 +86,7 @@ describe('MoneyMetaMaskCard', () => { it('calls onGetNowPress when metal card Get now is pressed', () => { const mockGetNow = jest.fn(); const { getAllByText } = render( - , + , ); const getNowButtons = getAllByText(strings('money.metamask_card.get_now')); @@ -174,9 +196,9 @@ describe('MoneyMetaMaskCard', () => { }); describe('upsell mode (default)', () => { - it('renders virtual and metal card rows', () => { + it('renders virtual and metal card rows when showMetalCard is true', () => { const { getByTestId } = render( - , + , ); expect( @@ -187,6 +209,19 @@ describe('MoneyMetaMaskCard', () => { ).toBeOnTheScreen(); }); + it('renders only the virtual card row when showMetalCard is false', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect( + getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW), + ).toBeOnTheScreen(); + expect( + queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).not.toBeOnTheScreen(); + }); + it('does not render link mode elements', () => { const { queryByTestId } = render( , diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx index 997526d78441..aa1951595982 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx @@ -13,6 +13,8 @@ import { IconColor, IconName, IconSize, + Tag, + TagSeverity, Text, TextColor, TextVariant, @@ -34,6 +36,12 @@ interface MoneyMetaMaskCardProps { onLinkPress?: () => void; /** Current APY value displayed in the link mode bullet. */ apy?: number; + /** + * Whether to render the Metal card row in upsell mode. Defaults to `false` + * because the Metal card is currently only available to US users; the parent + * is expected to pass the geolocation-derived flag. + */ + showMetalCard?: boolean; } const CardRow = ({ @@ -66,15 +74,11 @@ const CardRow = ({ {cardName} - + {strings('money.metamask_card.cashback', { percentage: cashbackPercentage, })} - + - ) : ( - <> - {visibleTokens.map((token) => ( - - ))} - - - - - - )} + ); }; diff --git a/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.test.tsx b/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.test.tsx index f9a9af60cbaa..a89016af8162 100644 --- a/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.test.tsx +++ b/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.test.tsx @@ -27,7 +27,7 @@ describe('MoneyWhatYouGet', () => { expect(container).toHaveTextContent(/Auto-earn/); expect(container).toHaveTextContent(/dollar-backed stablecoin/); expect(container).toHaveTextContent(/Get full liquidity/); - expect(container).toHaveTextContent(/1-3% cashback/); + expect(container).toHaveTextContent(/1-3% mUSD back/); expect(container).toHaveTextContent( /Transfer money to any of your wallets/, ); diff --git a/app/components/UI/Notification/Modal/index.tsx b/app/components/UI/Notification/Modal/index.tsx index 9a37c8f3cae5..4f4077167e82 100644 --- a/app/components/UI/Notification/Modal/index.tsx +++ b/app/components/UI/Notification/Modal/index.tsx @@ -1,18 +1,16 @@ import React from 'react'; import { View } from 'react-native'; import Checkbox from '../../../../component-library/components/Checkbox/Checkbox'; -import Icon, { - IconColor, - IconName, - IconSize, -} from '../../../../component-library/components/Icons/Icon'; -import Text, { - TextVariant, -} from '../../../../component-library/components/Texts/Text'; import { Button, ButtonVariant, ButtonSize, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextVariant, } from '@metamask/design-system-react-native'; import createStyles from './styles'; import { useTheme } from '../../../../util/theme'; @@ -60,10 +58,10 @@ const ModalContent = ({ size={iconSize} style={styles.icon} /> - + {title} - + {message} diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx index 124110bfe2f5..0e5d68c95bd1 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx @@ -4,6 +4,25 @@ import PerpsHomeView from './PerpsHomeView'; import { PERPS_EVENT_VALUE } from '@metamask/perps-controller'; import { selectPerpsFeedbackEnabledFlag } from '../../selectors/featureFlags'; import { mockTheme } from '../../../../../util/theme'; +import { useDiscoveryScrollManager } from '../../../Predict/hooks/useDiscoveryScrollManager'; + +// Mock useDiscoveryScrollManager +const mockPerpsOnTabEnter = jest.fn(); +const mockPerpsScrollHandler = jest.fn(); +jest.mock('../../../Predict/hooks/useDiscoveryScrollManager', () => ({ + useDiscoveryScrollManager: jest.fn(() => ({ + scrollHandler: mockPerpsScrollHandler, + onTabEnter: mockPerpsOnTabEnter, + headerHidden: false, + })), +})); + +// Mock react-native-reanimated +jest.mock('react-native-reanimated', () => { + const Reanimated = jest.requireActual('react-native-reanimated/mock'); + Reanimated.default.ScrollView = jest.requireActual('react-native').ScrollView; + return Reanimated; +}); // Mock navigation const mockNavigate = jest.fn(); @@ -877,4 +896,77 @@ describe('PerpsHomeView', () => { }); }); }); + + describe('hideHeader prop', () => { + it('renders the header by default', () => { + const { getByTestId } = render(); + expect(getByTestId('back-button')).toBeTruthy(); + expect(getByTestId('perps-home-search-toggle')).toBeTruthy(); + }); + + it('hides the header when hideHeader is true', () => { + const { queryByTestId } = render(); + expect(queryByTestId('back-button')).toBeNull(); + expect(queryByTestId('perps-home-search-toggle')).toBeNull(); + }); + + it('still renders content when hideHeader is true', () => { + const { UNSAFE_getByType } = render(); + expect( + UNSAFE_getByType('PerpsMarketBalanceActions' as never), + ).toBeTruthy(); + }); + }); + + describe('tabEnterCallbackRef prop', () => { + it('populates tabEnterCallbackRef.current with onTabEnter after mount', () => { + const ref = { current: null } as React.MutableRefObject< + (() => void) | null + >; + render(); + expect(ref.current).toBe(mockPerpsOnTabEnter); + }); + + it('updates tabEnterCallbackRef.current when onTabEnter changes', () => { + const ref = { current: null } as React.MutableRefObject< + (() => void) | null + >; + const newOnTabEnter = jest.fn(); + (useDiscoveryScrollManager as jest.Mock).mockReturnValueOnce({ + scrollHandler: mockPerpsScrollHandler, + onTabEnter: newOnTabEnter, + headerHidden: false, + }); + render(); + expect(ref.current).toBe(newOnTabEnter); + }); + + it('does not throw when tabEnterCallbackRef is not provided', () => { + expect(() => render()).not.toThrow(); + }); + }); + + describe('useDiscoveryScrollManager integration', () => { + it('passes walletHeaderHeight to useDiscoveryScrollManager', () => { + render(); + expect(useDiscoveryScrollManager).toHaveBeenCalledWith( + expect.objectContaining({ walletHeaderHeight: 56 }), + ); + }); + + it('passes onHeaderHiddenChange to useDiscoveryScrollManager', () => { + const onHeaderHiddenChange = jest.fn(); + render(); + expect(useDiscoveryScrollManager).toHaveBeenCalledWith( + expect.objectContaining({ onHeaderHiddenChange }), + ); + }); + + it('uses default walletHeaderHeight of 0 when not provided', () => { + render(); + expect(useDiscoveryScrollManager).toHaveBeenCalledWith( + expect.objectContaining({ walletHeaderHeight: 0 }), + ); + }); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx index 2baecf8cf13f..1a00e887ada0 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx @@ -5,7 +5,7 @@ import React, { useEffect, useMemo, } from 'react'; -import { View, ScrollView, Modal } from 'react-native'; +import { View, Modal, NativeScrollEvent } from 'react-native'; import { useSelector } from 'react-redux'; import { SafeAreaView, @@ -57,6 +57,8 @@ import PerpsHomeHeader from '../../components/PerpsHomeHeader'; import type { PerpsNavigationParamList } from '../../types/navigation'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import Reanimated, { SharedValue } from 'react-native-reanimated'; +import { useDiscoveryScrollManager } from '../../../Predict/hooks/useDiscoveryScrollManager'; import styleSheet from './PerpsHomeView.styles'; import { TraceName } from '../../../../../util/trace'; import { @@ -72,7 +74,23 @@ import PerpsNavigationCard, { NavigationItem, } from '../../components/PerpsNavigationCard/PerpsNavigationCard'; -const PerpsHomeView = () => { +interface PerpsHomeViewProps { + hideHeader?: boolean; + walletHeaderTranslateY?: SharedValue; + walletHeaderHeight?: number; + /** Ref populated with this tab's onTabEnter so the parent can call it on tab switch. */ + tabEnterCallbackRef?: React.MutableRefObject<(() => void) | null>; + /** Forwarded to useDiscoveryScrollManager to sync icon animations with header hide/show. */ + onHeaderHiddenChange?: (hidden: boolean) => void; +} + +const PerpsHomeView = ({ + hideHeader = false, + walletHeaderTranslateY, + walletHeaderHeight = 0, + tabEnterCallbackRef, + onHeaderHiddenChange, +}: PerpsHomeViewProps) => { const { styles } = useStyles(styleSheet, {}); const insets = useSafeAreaInsets(); const navigation = useNavigation(); @@ -124,6 +142,38 @@ const PerpsHomeView = () => { const { handleSectionLayout, handleScroll, resetTracking } = usePerpsHomeSectionTracking(); + // Bridge analytics handler into the Reanimated worklet via onScrollEvent + const handleScrollEvent = useCallback( + (scrollY: number, viewportHeight: number) => { + handleScroll({ + nativeEvent: { + contentOffset: { x: 0, y: scrollY }, + layoutMeasurement: { width: 0, height: viewportHeight }, + } as NativeScrollEvent, + }); + }, + [handleScroll], + ); + + const { scrollHandler: perpsScrollHandler, onTabEnter: perpsOnTabEnter } = + useDiscoveryScrollManager({ + walletHeaderHeight, + walletHeaderTranslateY, + onScrollEvent: handleScrollEvent, + onHeaderHiddenChange, + }); + + // Expose onTabEnter to the parent so it can restore this tab's header state on switch. + useEffect(() => { + if (tabEnterCallbackRef) { + tabEnterCallbackRef.current = perpsOnTabEnter; + return () => { + tabEnterCallbackRef.current = null; + }; + } + return undefined; + }, [tabEnterCallbackRef, perpsOnTabEnter]); + // Get balance state directly from Redux const { account: perpsAccount } = usePerpsLiveAccount({ throttleMs: 1000 }); const totalBalance = perpsAccount?.totalBalance || '0'; @@ -417,20 +467,25 @@ const PerpsHomeView = () => { const handleBackPress = perpsNavigation.navigateToWallet; return ( - + {/* Header */} - + {!hideHeader && ( + + )} {/* Main Content - ScrollView with all carousels */} - { {/* Bottom spacing for tab bar */} - + {/* Close All Positions Bottom Sheet */} {showCloseAllSheet && ( diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx index 1fef385dea23..7dcc0b4c5902 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx @@ -80,6 +80,7 @@ const PredictGameDetailsContent: React.FC = ({ marketId: market.id, childMarketIds: market.childMarketIds, claimable: false, + livePriceUpdates: true, }); const { data: claimablePositions = [] } = usePredictPositions({ marketId: market.id, diff --git a/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx b/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx index 5753c9818eca..9a2c259a40e1 100644 --- a/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx +++ b/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx @@ -42,7 +42,7 @@ const PredictHomePositions = forwardRef< refetch, isLoading: isActiveLoading, error: activeError, - } = usePredictPositions({ claimable: false }); + } = usePredictPositions({ claimable: false, livePriceUpdates: true }); const { data: claimablePositions = [], diff --git a/app/components/UI/Predict/components/PredictMarket/PredictMarket.tsx b/app/components/UI/Predict/components/PredictMarket/PredictMarket.tsx index f14340b28396..b81fc7405556 100644 --- a/app/components/UI/Predict/components/PredictMarket/PredictMarket.tsx +++ b/app/components/UI/Predict/components/PredictMarket/PredictMarket.tsx @@ -12,6 +12,10 @@ interface PredictMarketProps { testID?: string; entryPoint?: PredictEntryPoint; isCarousel?: boolean; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; + /** Called when the user taps a buy button (before betslip opens). */ + onBuyButtonPress?: (marketId: string) => void; } const PredictMarket: React.FC = ({ @@ -19,6 +23,8 @@ const PredictMarket: React.FC = ({ testID, entryPoint: propEntryPoint, isCarousel = false, + onCardPress, + onBuyButtonPress, }) => { const contextEntryPoint = usePredictEntryPoint(); const entryPoint = @@ -32,6 +38,8 @@ const PredictMarket: React.FC = ({ testID={testID} entryPoint={entryPoint} isCarousel={isCarousel} + onCardPress={onCardPress} + onBuyButtonPress={onBuyButtonPress} /> ); } @@ -43,6 +51,8 @@ const PredictMarket: React.FC = ({ testID={testID} entryPoint={entryPoint} isCarousel={isCarousel} + onCardPress={onCardPress} + onBuyButtonPress={onBuyButtonPress} /> ); } @@ -53,6 +63,8 @@ const PredictMarket: React.FC = ({ testID={testID} entryPoint={entryPoint} isCarousel={isCarousel} + onCardPress={onCardPress} + onBuyButtonPress={onBuyButtonPress} /> ); }; diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx index 6e29aa633679..3dab46dbe3b8 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx @@ -50,6 +50,10 @@ interface PredictMarketMultipleProps { testID?: string; entryPoint?: PredictEntryPoint; isCarousel?: boolean; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; + /** Called when the user taps a buy button (before betslip opens). */ + onBuyButtonPress?: (marketId: string) => void; } const PredictMarketMultiple: React.FC = ({ @@ -57,6 +61,8 @@ const PredictMarketMultiple: React.FC = ({ testID, entryPoint: propEntryPoint, isCarousel = false, + onCardPress, + onBuyButtonPress, }) => { const contextEntryPoint = usePredictEntryPoint(); const baseEntryPoint = @@ -137,6 +143,7 @@ const PredictMarketMultiple: React.FC = ({ outcome: PredictOutcome, outcomeToken: PredictOutcomeToken, ) => { + onBuyButtonPress?.(market.id); executeGuardedAction( () => { openBuySheet({ @@ -161,6 +168,7 @@ const PredictMarketMultiple: React.FC = ({ { + onCardPress?.(); navigation.navigate(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_DETAILS, params: { diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx index b831b609ccfe..cd52f0264ffa 100644 --- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx +++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx @@ -128,6 +128,10 @@ interface PredictMarketSingleProps { testID?: string; entryPoint?: PredictEntryPoint; isCarousel?: boolean; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; + /** Called when the user taps a buy button (before betslip opens). */ + onBuyButtonPress?: (marketId: string) => void; } const PredictMarketSingle: React.FC = ({ @@ -135,6 +139,8 @@ const PredictMarketSingle: React.FC = ({ testID, entryPoint: propEntryPoint, isCarousel = false, + onCardPress, + onBuyButtonPress, }) => { const contextEntryPoint = usePredictEntryPoint(); const baseEntryPoint = @@ -185,6 +191,7 @@ const PredictMarketSingle: React.FC = ({ const yesPercentage = getYesPercentage(); const handleBuy = (token: PredictOutcomeToken) => { + onBuyButtonPress?.(market.id); executeGuardedAction( () => { openBuySheet({ @@ -204,6 +211,7 @@ const PredictMarketSingle: React.FC = ({ { + onCardPress?.(); navigation.navigate(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_DETAILS, params: { diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx index b25adb66bb4c..18700a3f9faf 100644 --- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx +++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx @@ -27,6 +27,10 @@ interface PredictMarketSportCardProps { entryPoint?: PredictEntryPoint; onDismiss?: () => void; isCarousel?: boolean; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; + /** Called when the user taps a buy button (before betslip opens). */ + onBuyButtonPress?: (marketId: string) => void; } const PredictMarketSportCard: React.FC = ({ @@ -35,6 +39,8 @@ const PredictMarketSportCard: React.FC = ({ entryPoint: propEntryPoint, onDismiss, isCarousel, + onCardPress, + onBuyButtonPress, }) => { const tw = useTailwind(); const contextEntryPoint = usePredictEntryPoint(); @@ -57,6 +63,7 @@ const PredictMarketSportCard: React.FC = ({ style={tw.style(isCarousel ? '' : 'my-[8px]')} testID={testID} onPress={() => { + onCardPress?.(); navigation.navigate(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_DETAILS, params: { @@ -102,6 +109,7 @@ const PredictMarketSportCard: React.FC = ({ entryPoint={resolvedEntryPoint} testID={testID ? `${testID}-footer` : undefined} isCarousel={isCarousel} + onBuyButtonPress={onBuyButtonPress} /> diff --git a/app/components/UI/Predict/components/PredictPicks/PredictPicks.test.tsx b/app/components/UI/Predict/components/PredictPicks/PredictPicks.test.tsx index c673a6d1b986..005c6b2e7677 100644 --- a/app/components/UI/Predict/components/PredictPicks/PredictPicks.test.tsx +++ b/app/components/UI/Predict/components/PredictPicks/PredictPicks.test.tsx @@ -45,13 +45,6 @@ jest.mock('../../hooks/usePredictCashOut', () => ({ usePredictCashOut: () => ({ onCashOut: mockOnCashOut }), })); -jest.mock('../../hooks/usePredictLivePositions', () => ({ - usePredictLivePositions: jest.fn((positions: unknown[]) => ({ - livePositions: positions ?? [], - isConnected: false, - lastUpdateTime: null, - })), -})); jest.mock('../../utils/format'); const mockUseSelector = useSelector as jest.MockedFunction; diff --git a/app/components/UI/Predict/components/PredictPicks/PredictPicks.tsx b/app/components/UI/Predict/components/PredictPicks/PredictPicks.tsx index 0d560fdda2d9..47568ad316a5 100644 --- a/app/components/UI/Predict/components/PredictPicks/PredictPicks.tsx +++ b/app/components/UI/Predict/components/PredictPicks/PredictPicks.tsx @@ -1,7 +1,6 @@ import { Box } from '@metamask/design-system-react-native'; import React from 'react'; import { useSelector } from 'react-redux'; -import { usePredictLivePositions } from '../../hooks/usePredictLivePositions'; import { usePredictCashOut } from '../../hooks/usePredictCashOut'; import { PredictMarket, @@ -29,7 +28,6 @@ const PredictPicks: React.FC = ({ claimablePositions, testID = PREDICT_PICKS_TEST_ID, }) => { - const { livePositions } = usePredictLivePositions(positions); const { onCashOut } = usePredictCashOut({ market, callerName: 'PredictPicks', @@ -43,7 +41,7 @@ const PredictPicks: React.FC = ({ if (usePositionDetail) { return ( - {livePositions.map((position) => ( + {positions.map((position) => ( = ({ return ( - {livePositions.map((position) => ( + {positions.map((position) => ( ({ - usePredictLivePositions: jest.fn((positions: unknown[]) => ({ - livePositions: positions ?? [], - isConnected: false, - lastUpdateTime: null, - })), -})); jest.mock('../../utils/format'); const mockUsePredictPositions = usePredictPositions as jest.Mock; @@ -327,12 +320,12 @@ describe('PredictPicksForCard', () => { expect(mockUsePredictPositions).toHaveBeenCalledWith({ marketId: 'specific-market-456', - refetchInterval: 10000, enabled: true, + livePriceUpdates: true, }); }); - it('passes refetchInterval of 10000ms to hook when no positions prop', () => { + it('enables livePriceUpdates when no positions prop', () => { mockUsePredictPositions.mockReturnValue({ data: [], isLoading: false, @@ -345,7 +338,7 @@ describe('PredictPicksForCard', () => { expect(mockUsePredictPositions).toHaveBeenCalledWith( expect.objectContaining({ - refetchInterval: 10000, + livePriceUpdates: true, }), ); }); @@ -362,8 +355,8 @@ describe('PredictPicksForCard', () => { expect(mockUsePredictPositions).toHaveBeenCalledWith({ marketId: 'market-1', - refetchInterval: undefined, enabled: false, + livePriceUpdates: false, }); }); }); diff --git a/app/components/UI/Predict/components/PredictPicks/PredictPicksForCard.tsx b/app/components/UI/Predict/components/PredictPicks/PredictPicksForCard.tsx index f2049d1248a3..92e5ac875e39 100644 --- a/app/components/UI/Predict/components/PredictPicks/PredictPicksForCard.tsx +++ b/app/components/UI/Predict/components/PredictPicks/PredictPicksForCard.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { Box } from '@metamask/design-system-react-native'; import { usePredictPositions } from '../../hooks/usePredictPositions'; -import { usePredictLivePositions } from '../../hooks/usePredictLivePositions'; import type { PredictPosition } from '../../types'; import PredictPicksForCardItem from './PredictPicksForCardItem'; import { @@ -33,14 +32,13 @@ const PredictPicksForCard: React.FC = ({ }) => { const { data: fetchedPositions = [] } = usePredictPositions({ marketId, - refetchInterval: positionsProp ? undefined : 10000, enabled: !positionsProp, + livePriceUpdates: !positionsProp, }); const basePositions = positionsProp ?? fetchedPositions; - const { livePositions } = usePredictLivePositions(basePositions); - if (livePositions.length === 0) { + if (basePositions.length === 0) { return null; } @@ -52,7 +50,7 @@ const PredictPicksForCard: React.FC = ({ twClassName="h-px bg-border-muted my-2" /> )} - {livePositions.map((position) => ( + {basePositions.map((position) => ( ({ })); const mockRefetchClaimablePositions = jest.fn(); -jest.mock('../../hooks/usePredictPositions', () => ({ - usePredictPositions: () => ({ - data: [{ id: 'position-1' }], - isLoading: false, - error: null, - refetch: mockRefetchClaimablePositions, - }), -})); +let mockActivePositions: PredictPosition[] = []; +let mockClaimablePositions: PredictPosition[] = []; +jest.mock('../../hooks/usePredictPositions'); const mockClaim = jest.fn(); jest.mock('../../hooks/usePredictClaim', () => ({ @@ -127,36 +123,13 @@ jest.mock('../../../../../../locales/i18n', () => ({ }), })); -function createTestState( - _availableBalance?: number, - claimableAmount?: number, - privacyMode = false, -) { +function createTestState(_availableBalance?: number, privacyMode = false) { const testAddress = '0x1234567890123456789012345678901234567890'; const testAccountId = 'test-account-id'; - const claimablePositions = claimableAmount - ? ([ - { - id: 'position-1', - status: PredictPositionStatus.WON, - cashPnl: claimableAmount, - currentValue: claimableAmount, - marketId: 'market-1', - title: 'Test Market', - outcome: 'Yes', - }, - ] as unknown as PredictPosition[]) - : []; - return { engine: { backgroundState: { - PredictController: { - claimablePositions: { - [testAddress]: claimablePositions, - }, - }, AccountsController: { internalAccounts: { selectedAccount: testAccountId, @@ -185,11 +158,16 @@ describe('MarketsWonCard', () => { const mockUseUnrealizedPnL = useUnrealizedPnL as jest.MockedFunction< typeof useUnrealizedPnL >; + const mockUsePredictPositions = usePredictPositions as jest.MockedFunction< + typeof usePredictPositions + >; beforeEach(() => { jest.clearAllMocks(); mockBalanceResult.data = 100.5; mockBalanceResult.isLoading = false; + mockActivePositions = [{ id: 'position-1' } as PredictPosition]; + mockClaimablePositions = []; mockUseUnrealizedPnL.mockReturnValue({ data: { @@ -201,13 +179,35 @@ describe('MarketsWonCard', () => { isFetching: false, error: null, } as unknown as ReturnType); + mockUsePredictPositions.mockImplementation( + ({ claimable }: { claimable?: boolean } = {}) => + ({ + data: claimable ? mockClaimablePositions : mockActivePositions, + isLoading: false, + error: null, + refetch: mockRefetchClaimablePositions, + }) as unknown as ReturnType, + ); }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); describe('rendering', () => { + it('does not enable live updates for active position count query', () => { + const state = createTestState(100.5); + + renderWithProvider(, { state }); + + const activePositionsCall = mockUsePredictPositions.mock.calls.find( + ([options]) => options?.claimable === false, + ); + + expect(activePositionsCall?.[0]).toMatchObject({ claimable: false }); + expect(activePositionsCall?.[0]?.livePriceUpdates).toBeUndefined(); + }); + it('displays available balance and unrealized P&L', () => { const state = createTestState(100.5); @@ -230,7 +230,18 @@ describe('MarketsWonCard', () => { }); it('hides monetary values when privacy mode is enabled', () => { - const state = createTestState(100.5, 24.66, true); + mockClaimablePositions = [ + { + id: 'position-1', + status: PredictPositionStatus.WON, + cashPnl: 24.66, + currentValue: 24.66, + marketId: 'market-1', + title: 'Test Market', + outcome: 'Yes', + } as PredictPosition, + ]; + const state = createTestState(100.5, true); renderWithProvider(, { state }); @@ -238,7 +249,7 @@ describe('MarketsWonCard', () => { expect(screen.queryByText('+$8.63 (+3.9%)')).toBeNull(); expect(screen.queryByText('Claim $24.66')).toBeNull(); expect(screen.getByText('••••••••••••')).toBeOnTheScreen(); - expect(screen.getByText('•••••••••')).toBeOnTheScreen(); + expect(screen.getAllByText('•••••••••').length).toBeGreaterThan(0); }); }); diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx index 6bca1d097b85..6d37d864d302 100644 --- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx +++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx @@ -46,8 +46,7 @@ import { usePredictDeposit } from '../../hooks/usePredictDeposit'; import { useUnrealizedPnL } from '../../hooks/useUnrealizedPnL'; import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; import { usePredictPositions } from '../../hooks/usePredictPositions'; -import { selectPredictWonPositions } from '../../selectors/predictController'; -import { PredictPosition } from '../../types'; +import { PredictPosition, PredictPositionStatus } from '../../types'; import { PredictNavigationParamList } from '../../types/navigation'; import { formatPercentage, @@ -95,12 +94,20 @@ const PredictPositionsHeader = forwardRef< const evmAccount = getEvmAccountFromSelectedAccountGroup(); const selectedAddress = evmAccount?.address ?? '0x0'; const { isDepositPending } = usePredictDeposit(); - const wonPositions = useSelector( - selectPredictWonPositions({ address: selectedAddress }), - ); - - const { data: activePositions } = usePredictPositions({ claimable: false }); + const { data: activePositions } = usePredictPositions({ + claimable: false, + }); + const { data: claimablePositions = [] } = usePredictPositions({ + claimable: true, + }); const hasPositions = (activePositions?.length ?? 0) > 0; + const wonPositions = useMemo( + () => + claimablePositions.filter( + (position) => position.status === PredictPositionStatus.WON, + ), + [claimablePositions], + ); const { data: pnlData, diff --git a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx index 02d2100f1cae..bd700a62a77e 100644 --- a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx +++ b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.test.tsx @@ -306,7 +306,7 @@ describe('PredictSportCardFooter', () => { expect(mockUsePredictPositions).toHaveBeenCalledWith({ marketId: 'specific-market-123', claimable: false, - refetchInterval: 10000, + livePriceUpdates: true, }); }); diff --git a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx index 0f3b6af467aa..f963826c67b4 100644 --- a/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx +++ b/app/components/UI/Predict/components/PredictSportCardFooter/PredictSportCardFooter.tsx @@ -27,6 +27,8 @@ interface PredictSportCardFooterProps { testID?: string; entryPoint?: PredictEntryPoint; isCarousel?: boolean; + /** Called when the user taps a buy button (before betslip opens). */ + onBuyButtonPress?: (marketId: string) => void; } const PredictSportCardFooter: React.FC = ({ @@ -34,6 +36,7 @@ const PredictSportCardFooter: React.FC = ({ testID, entryPoint: propEntryPoint, isCarousel, + onBuyButtonPress, }) => { const tw = useTailwind(); const navigation = @@ -53,7 +56,7 @@ const PredictSportCardFooter: React.FC = ({ const { data: positions = [], isLoading } = usePredictPositions({ marketId: market.id, claimable: false, - refetchInterval: 10000, + livePriceUpdates: true, }); const { data: claimablePositions = [] } = usePredictPositions({ @@ -82,6 +85,7 @@ const PredictSportCardFooter: React.FC = ({ ), ) ?? market.outcomes?.[0]; + onBuyButtonPress?.(market.id); executeGuardedAction( () => { openBuySheet({ @@ -96,7 +100,13 @@ const PredictSportCardFooter: React.FC = ({ }, ); }, - [executeGuardedAction, resolvedEntryPoint, openBuySheet, market], + [ + executeGuardedAction, + resolvedEntryPoint, + openBuySheet, + market, + onBuyButtonPress, + ], ); const handleClaimPress = useCallback(async () => { diff --git a/app/components/UI/Predict/hooks/index.ts b/app/components/UI/Predict/hooks/index.ts index 1515de44b185..e22c2dda5323 100644 --- a/app/components/UI/Predict/hooks/index.ts +++ b/app/components/UI/Predict/hooks/index.ts @@ -13,12 +13,6 @@ export { type UseLiveMarketPricesResult, } from './useLiveMarketPrices'; -export { - usePredictLivePositions, - type UseLivePositionsOptions, - type UseLivePositionsResult, -} from './usePredictLivePositions'; - export { usePredictTabs, type FeedTab, diff --git a/app/components/UI/Predict/hooks/useDiscoveryScrollManager.test.ts b/app/components/UI/Predict/hooks/useDiscoveryScrollManager.test.ts new file mode 100644 index 000000000000..01b49ce53646 --- /dev/null +++ b/app/components/UI/Predict/hooks/useDiscoveryScrollManager.test.ts @@ -0,0 +1,558 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { SharedValue } from 'react-native-reanimated'; +import { + useDiscoveryScrollManager, + ANIMATION_DURATION, + SCROLL_THRESHOLD, +} from './useDiscoveryScrollManager'; + +const mockWithTiming = jest.fn((toValue: unknown) => toValue); +const mockWithDelay = jest.fn( + (_delay: unknown, animation: unknown) => animation, +); +const mockRunOnJS = jest.fn( + (fn: (...args: unknown[]) => void) => + (...args: unknown[]) => + fn(...args), +); + +jest.mock('react-native-reanimated', () => { + const { useRef } = jest.requireActual('react'); + return { + // useRef keeps the SharedValue object alive across re-renders, matching + // the real implementation and preventing isHeaderHidden from resetting to + // its initial value whenever a state update triggers a re-render. + useSharedValue: jest.fn((initialValue: unknown) => { + const ref = useRef({ value: initialValue }); + return ref.current; + }), + useAnimatedScrollHandler: jest.fn( + (config: { onScroll: (event: ScrollEvent) => void }) => config, + ), + withTiming: mockWithTiming, + withDelay: mockWithDelay, + Easing: { + out: jest.fn((easing: unknown) => easing), + cubic: jest.fn(), + }, + runOnJS: mockRunOnJS, + }; +}); + +interface ScrollEvent { + contentOffset: { x?: number; y: number }; + contentSize: { width?: number; height: number }; + layoutMeasurement: { width?: number; height: number }; +} + +interface ScrollHandler { + onScroll?: (event: ScrollEvent) => void; +} + +/** Build a realistic scroll event. contentSize defaults to a tall page. */ +const makeScrollEvent = ( + y: number, + opts: { contentHeight?: number; viewportHeight?: number } = {}, +): ScrollEvent => ({ + contentOffset: { x: 0, y }, + contentSize: { width: 390, height: opts.contentHeight ?? 2000 }, + layoutMeasurement: { width: 390, height: opts.viewportHeight ?? 800 }, +}); + +describe('useDiscoveryScrollManager', () => { + const createSharedValue = (initial: number) => + ({ value: initial }) as unknown as SharedValue; + + const createDefaultProps = (overrides = {}) => ({ + walletHeaderHeight: 56, + walletHeaderTranslateY: createSharedValue(0), + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + // The global Reanimated.setUpTests() in testSetup.js may override the + // module-level jest.mock factory. Patch all used functions at runtime so + // worklet-called and JS-thread functions resolve to our controllable mocks. + const reanimated = jest.requireMock('react-native-reanimated'); + reanimated.withTiming = mockWithTiming; + reanimated.withDelay = mockWithDelay; + reanimated.runOnJS = mockRunOnJS; + mockWithTiming.mockImplementation((toValue: unknown) => toValue); + mockWithDelay.mockImplementation( + (_delay: unknown, animation: unknown) => animation, + ); + }); + + // ─── exports ─────────────────────────────────────────────────────────────── + + describe('exports', () => { + it('exports ANIMATION_DURATION as 300', () => { + expect(ANIMATION_DURATION).toBe(300); + }); + + it('exports SCROLL_THRESHOLD as 100', () => { + expect(SCROLL_THRESHOLD).toBe(100); + }); + }); + + describe('initialization', () => { + it('returns required properties', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + expect(typeof result.current.headerHidden).toBe('boolean'); + expect(typeof result.current.onTabEnter).toBe('function'); + expect(result.current.scrollHandler).toBeDefined(); + }); + + it('initializes headerHidden as false', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + expect(result.current.headerHidden).toBe(false); + }); + + it('works without optional walletHeaderTranslateY', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager({ walletHeaderHeight: 56 }), + ); + + expect(result.current.headerHidden).toBe(false); + expect(result.current.scrollHandler).toBeDefined(); + }); + + it('works without any optional props', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager({ walletHeaderHeight: 56 }), + ); + + expect(result.current.headerHidden).toBe(false); + }); + }); + + describe('walletHeaderHeight sync', () => { + it('updates sharedHeaderHeight when walletHeaderHeight prop changes', () => { + const props = createDefaultProps({ walletHeaderHeight: 56 }); + + const { rerender } = renderHook( + (p: typeof props) => useDiscoveryScrollManager(p), + { initialProps: props }, + ); + + expect(() => + rerender({ ...props, walletHeaderHeight: 80 }), + ).not.toThrow(); + }); + }); + + describe('scrollHandler', () => { + it('processes a scroll event without throwing', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + expect(() => { + handler.onScroll?.(makeScrollEvent(0)); + }).not.toThrow(); + }); + + it('does not hide header when accumulated scroll is below threshold', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(50)); // 50px < SCROLL_THRESHOLD (100) + }); + + expect(result.current.headerHidden).toBe(false); + }); + + it('hides header after scrolling down past the threshold', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(101)); // > 100px threshold + }); + + expect(result.current.headerHidden).toBe(true); + }); + + it('shows header again after scrolling up past threshold when hidden', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + + expect(result.current.headerHidden).toBe(true); + + act(() => { + handler.onScroll?.(makeScrollEvent(200)); + handler.onScroll?.(makeScrollEvent(50)); // -150px upward (> threshold) + }); + + expect(result.current.headerHidden).toBe(false); + }); + + it('shows header immediately when scrolled back to top', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + expect(result.current.headerHidden).toBe(true); + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); // atTop forces show + }); + expect(result.current.headerHidden).toBe(false); + }); + + it('resets accumulated delta when scroll direction reverses', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + // Scroll down 80px (below threshold) + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(80)); + + // Reverse — accumulated delta resets, only 20px down so far + handler.onScroll?.(makeScrollEvent(60)); + handler.onScroll?.(makeScrollEvent(80)); // 20px down — still below threshold + }); + + expect(result.current.headerHidden).toBe(false); + }); + + it('ignores zero-delta events', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(50)); + handler.onScroll?.(makeScrollEvent(50)); // delta = 0, should be ignored + }); + + expect(result.current.headerHidden).toBe(false); + }); + + it('ignores bounce events past the bottom edge', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + expect(result.current.headerHidden).toBe(true); + + act(() => { + // Bounce: scroll past bottom (contentHeight=2000, viewport=800 → max=1200) + handler.onScroll?.( + makeScrollEvent(1250, { contentHeight: 2000, viewportHeight: 800 }), + ); + // Snapback upward should NOT re-show header (atBottom guard) + handler.onScroll?.( + makeScrollEvent(1200, { contentHeight: 2000, viewportHeight: 800 }), + ); + }); + + expect(result.current.headerHidden).toBe(true); + }); + + it('invokes onPortfolioScroll on every scroll event', () => { + const onPortfolioScroll = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps({ onPortfolioScroll })), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + act(() => { + handler.onScroll?.(makeScrollEvent(10)); + handler.onScroll?.(makeScrollEvent(20)); + }); + + expect(onPortfolioScroll).toHaveBeenCalledTimes(2); + }); + + it('invokes onScrollEvent with scrollY and viewportHeight on every event', () => { + const onScrollEvent = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps({ onScrollEvent })), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + act(() => { + handler.onScroll?.(makeScrollEvent(42, { viewportHeight: 800 })); + }); + + expect(onScrollEvent).toHaveBeenCalledWith(42, 800); + }); + + it('invokes both onPortfolioScroll and onScrollEvent in the same scroll event', () => { + const onPortfolioScroll = jest.fn(); + const onScrollEvent = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager( + createDefaultProps({ onPortfolioScroll, onScrollEvent }), + ), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + act(() => { + handler.onScroll?.(makeScrollEvent(10, { viewportHeight: 750 })); + }); + + expect(onPortfolioScroll).toHaveBeenCalledTimes(1); + expect(onScrollEvent).toHaveBeenCalledWith(10, 750); + }); + + it('calls onHeaderHiddenChange(true) when header hides', () => { + const onHeaderHiddenChange = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps({ onHeaderHiddenChange })), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + + expect(onHeaderHiddenChange).toHaveBeenCalledWith(true); + }); + + it('calls onHeaderHiddenChange(false) when header shows after scrolling up', () => { + const onHeaderHiddenChange = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps({ onHeaderHiddenChange })), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + onHeaderHiddenChange.mockClear(); + + act(() => { + handler.onScroll?.(makeScrollEvent(200)); + handler.onScroll?.(makeScrollEvent(50)); + }); + + expect(onHeaderHiddenChange).toHaveBeenCalledWith(false); + }); + + it('calls onHeaderHiddenChange(false) when scrolled back to top', () => { + const onHeaderHiddenChange = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps({ onHeaderHiddenChange })), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + onHeaderHiddenChange.mockClear(); + + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + }); + + expect(onHeaderHiddenChange).toHaveBeenCalledWith(false); + }); + }); + + describe('onTabEnter', () => { + it('does not throw when header is visible', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + expect(() => { + act(() => { + result.current.onTabEnter(); + }); + }).not.toThrow(); + + expect(result.current.headerHidden).toBe(false); + }); + + it('does not throw after scrolling past threshold', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + + expect(() => { + act(() => { + result.current.onTabEnter(); + }); + }).not.toThrow(); + }); + + it('does not throw on re-entry after header was hidden by scroll', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + expect(result.current.headerHidden).toBe(true); + + expect(() => { + act(() => { + result.current.onTabEnter(); + }); + }).not.toThrow(); + }); + + it('shows header when re-entering a tab that was at the top', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + act(() => { + result.current.onTabEnter(); + }); + + expect(result.current.headerHidden).toBe(false); + }); + + it('calls onHeaderHiddenChange(true) when entering a tab with hidden header', () => { + const onHeaderHiddenChange = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps({ onHeaderHiddenChange })), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + onHeaderHiddenChange.mockClear(); + + act(() => { + result.current.onTabEnter(); + }); + + expect(onHeaderHiddenChange).toHaveBeenCalledWith(true); + }); + + it('calls onHeaderHiddenChange(false) when entering a tab with visible header', () => { + const onHeaderHiddenChange = jest.fn(); + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps({ onHeaderHiddenChange })), + ); + + act(() => { + result.current.onTabEnter(); + }); + + expect(onHeaderHiddenChange).toHaveBeenCalledWith(false); + }); + + it('skips settling scroll events after a tab switch', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + result.current.onTabEnter(); + }); + + // Fire 5 settling events (tabSwitchEventsToSkip = 5) — none should hide header + act(() => { + for (let i = 0; i < 5; i++) { + handler.onScroll?.(makeScrollEvent(500)); + } + }); + + expect(result.current.headerHidden).toBe(false); + }); + + it('processes scroll normally after the settling window expires', () => { + const { result } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + const handler = result.current.scrollHandler as unknown as ScrollHandler; + + act(() => { + result.current.onTabEnter(); + }); + + // Burn through all 5 skip slots + act(() => { + for (let i = 0; i < 5; i++) { + handler.onScroll?.(makeScrollEvent(0)); + } + }); + + // Now normal scroll should work + act(() => { + handler.onScroll?.(makeScrollEvent(0)); + handler.onScroll?.(makeScrollEvent(200)); + }); + + expect(result.current.headerHidden).toBe(true); + }); + }); + + describe('cleanup', () => { + it('unmounts without errors', () => { + const { unmount } = renderHook(() => + useDiscoveryScrollManager(createDefaultProps()), + ); + + expect(() => unmount()).not.toThrow(); + }); + }); +}); diff --git a/app/components/UI/Predict/hooks/useDiscoveryScrollManager.ts b/app/components/UI/Predict/hooks/useDiscoveryScrollManager.ts new file mode 100644 index 000000000000..c6cccf357494 --- /dev/null +++ b/app/components/UI/Predict/hooks/useDiscoveryScrollManager.ts @@ -0,0 +1,201 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { + useSharedValue, + useAnimatedScrollHandler, + SharedValue, + withTiming, + withDelay, + Easing, + runOnJS, +} from 'react-native-reanimated'; + +export const ANIMATION_DURATION = 300; +export const SCROLL_THRESHOLD = 100; + +const hideAnimationConfig = { + duration: ANIMATION_DURATION, + easing: Easing.out(Easing.cubic), +}; + +const showAnimationConfig = { + duration: 250, + easing: Easing.out(Easing.cubic), +}; + +interface UseDiscoveryScrollManagerParams { + walletHeaderHeight: number; + walletHeaderTranslateY?: SharedValue; + onPortfolioScroll?: () => void; + /** + * Called from the scroll worklet (via runOnJS) with the current scroll Y and + * viewport height. Use this to forward events to JS-thread scroll handlers + * (e.g. analytics section tracking) without needing a separate ScrollView. + */ + onScrollEvent?: (scrollY: number, viewportHeight: number) => void; + /** + * Called via runOnJS at the exact moment the hide/show decision is made — + * same worklet frame as the withTiming call. Use this to sync sibling + * animations (e.g. icon collapse) without position-based polling. + */ + onHeaderHiddenChange?: (hidden: boolean) => void; +} + +interface UseDiscoveryScrollManagerReturn { + headerHidden: boolean; + scrollHandler: ReturnType; + onTabEnter: () => void; +} + +export const useDiscoveryScrollManager = ({ + walletHeaderHeight, + walletHeaderTranslateY: externalTranslateY, + onPortfolioScroll, + onScrollEvent, + onHeaderHiddenChange, +}: UseDiscoveryScrollManagerParams): UseDiscoveryScrollManagerReturn => { + const fallbackTranslateY = useSharedValue(0); + const walletHeaderTranslateY = externalTranslateY ?? fallbackTranslateY; + const isHeaderHidden = useSharedValue(0); + const sharedHeaderHeight = useSharedValue(walletHeaderHeight); + const lastScrollY = useSharedValue(0); + const accumulatedDelta = useSharedValue(0); + const lastDirection = useSharedValue(0); + const tabSwitchEventsToSkip = useSharedValue(0); + + const [headerHidden, setHeaderHidden] = useState(false); + + // Keep shared height in sync as it's measured after first render + useEffect(() => { + sharedHeaderHeight.value = walletHeaderHeight; + }, [walletHeaderHeight, sharedHeaderHeight]); + + const onPortfolioScrollRef = useRef<(() => void) | undefined>(undefined); + onPortfolioScrollRef.current = onPortfolioScroll; + + const onScrollEventRef = useRef< + ((scrollY: number, viewportHeight: number) => void) | undefined + >(undefined); + onScrollEventRef.current = onScrollEvent; + + const callScrollCallbacks = useCallback( + (scrollY: number, viewportHeight: number) => { + onPortfolioScrollRef.current?.(); + onScrollEventRef.current?.(scrollY, viewportHeight); + }, + [], + ); + + const onHeaderHiddenChangeRef = useRef< + ((hidden: boolean) => void) | undefined + >(undefined); + onHeaderHiddenChangeRef.current = onHeaderHiddenChange; + const callHeaderHiddenChange = useCallback((hidden: boolean) => { + onHeaderHiddenChangeRef.current?.(hidden); + }, []); + + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + 'worklet'; + + const currentY = event.contentOffset.y; + runOnJS(callScrollCallbacks)(currentY, event.layoutMeasurement.height); + + // Skip settling events after a tab switch. Multiple events can fire + // when views remain mounted (opacity approach) and settle back into view. + if (tabSwitchEventsToSkip.value > 0) { + tabSwitchEventsToSkip.value -= 1; + lastScrollY.value = currentY; + accumulatedDelta.value = 0; + lastDirection.value = 0; + return; + } + + const delta = currentY - lastScrollY.value; + lastScrollY.value = currentY; + + const atTop = currentY <= 0; + const maxScrollY = + event.contentSize.height - event.layoutMeasurement.height; + const atBottom = maxScrollY > 0 && currentY >= maxScrollY; + + // Ignore bounce events past the bottom edge — the snapback registers as + // an upward scroll and would falsely trigger the show-header logic. + if (atBottom) { + accumulatedDelta.value = 0; + return; + } + + const currentDirection = delta > 0 ? 1 : delta < 0 ? -1 : 0; + if (currentDirection === 0) return; + + // Reset accumulated delta when direction reverses + if (currentDirection !== lastDirection.value) { + lastDirection.value = currentDirection; + accumulatedDelta.value = 0; + } + + accumulatedDelta.value += Math.abs(delta); + + // Always show header when at the top of the list + if (atTop && isHeaderHidden.value === 1) { + isHeaderHidden.value = 0; + walletHeaderTranslateY.value = withTiming(0, showAnimationConfig); + accumulatedDelta.value = 0; + lastDirection.value = 0; + runOnJS(setHeaderHidden)(false); + runOnJS(callHeaderHiddenChange)(false); + return; + } + + if (accumulatedDelta.value < SCROLL_THRESHOLD) return; + + // Scroll down — hide header + if (currentDirection === 1 && isHeaderHidden.value === 0) { + isHeaderHidden.value = 1; + walletHeaderTranslateY.value = withTiming( + -sharedHeaderHeight.value, + hideAnimationConfig, + ); + accumulatedDelta.value = 0; + runOnJS(setHeaderHidden)(true); + runOnJS(callHeaderHiddenChange)(true); + } + + // Scroll up — show header + if (currentDirection === -1 && isHeaderHidden.value === 1) { + isHeaderHidden.value = 0; + walletHeaderTranslateY.value = withTiming(0, showAnimationConfig); + accumulatedDelta.value = 0; + runOnJS(setHeaderHidden)(false); + runOnJS(callHeaderHiddenChange)(false); + } + }, + }); + + // Restore this tab's header state when the user enters it. + // If this tab was previously scrolled with the header hidden, keep it hidden. + // If this tab is at the top (or hasn't been visited), show the header. + const onTabEnter = useCallback(() => { + tabSwitchEventsToSkip.value = 5; + if (isHeaderHidden.value === 1) { + walletHeaderTranslateY.value = withDelay( + 100, + withTiming(-sharedHeaderHeight.value, hideAnimationConfig), + ); + setHeaderHidden(true); + callHeaderHiddenChange(true); + } else { + walletHeaderTranslateY.value = withTiming(0, showAnimationConfig); + setHeaderHidden(false); + callHeaderHiddenChange(false); + } + }, [ + tabSwitchEventsToSkip, + isHeaderHidden, + walletHeaderTranslateY, + sharedHeaderHeight, + callHeaderHiddenChange, + ]); + + return { headerHidden, scrollHandler, onTabEnter }; +}; diff --git a/app/components/UI/Predict/hooks/useFeedScrollManager.ts b/app/components/UI/Predict/hooks/useFeedScrollManager.ts index 5537e9a87dd0..e30627127f8d 100644 --- a/app/components/UI/Predict/hooks/useFeedScrollManager.ts +++ b/app/components/UI/Predict/hooks/useFeedScrollManager.ts @@ -17,6 +17,7 @@ export interface UseFeedScrollManagerParams { headerRef: React.RefObject; tabBarRef: React.RefObject; setActiveIndex: (index: number) => void; + onHeaderHiddenChange?: (hidden: boolean) => void; } export interface UseFeedScrollManagerReturn { @@ -69,6 +70,7 @@ export const useFeedScrollManager = ({ headerRef, tabBarRef, setActiveIndex, + onHeaderHiddenChange, }: UseFeedScrollManagerParams): UseFeedScrollManagerReturn => { const isHeaderHidden = useSharedValue(0); const headerTranslateY = useSharedValue(0); @@ -186,6 +188,7 @@ export const useFeedScrollManager = ({ accumulatedDelta.value = 0; lastDirection.value = 0; runOnJS(setHeaderHidden)(false); + if (onHeaderHiddenChange) runOnJS(onHeaderHiddenChange)(false); return; } @@ -202,6 +205,7 @@ export const useFeedScrollManager = ({ ); accumulatedDelta.value = 0; runOnJS(setHeaderHidden)(true); + if (onHeaderHiddenChange) runOnJS(onHeaderHiddenChange)(true); } // Scrolling up -> show header @@ -210,6 +214,7 @@ export const useFeedScrollManager = ({ headerTranslateY.value = withTiming(0, animationConfig); accumulatedDelta.value = 0; runOnJS(setHeaderHidden)(false); + if (onHeaderHiddenChange) runOnJS(onHeaderHiddenChange)(false); } }, }); diff --git a/app/components/UI/Predict/hooks/usePredictLivePositions.test.ts b/app/components/UI/Predict/hooks/usePredictLivePositions.test.ts index b091e2f8e79b..91ec1f57b188 100644 --- a/app/components/UI/Predict/hooks/usePredictLivePositions.test.ts +++ b/app/components/UI/Predict/hooks/usePredictLivePositions.test.ts @@ -1,10 +1,19 @@ -import { renderHook } from '@testing-library/react-native'; +import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react-native'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { usePredictLivePositions } from './usePredictLivePositions'; import { useLiveMarketPrices } from './useLiveMarketPrices'; import { PredictPosition, PredictPositionStatus, PriceUpdate } from '../types'; +import { predictQueries } from '../queries'; import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants'; jest.mock('./useLiveMarketPrices'); +const mockUseIsFocused = jest.fn(() => true); +jest.mock('@react-navigation/native', () => ({ + useIsFocused: () => mockUseIsFocused(), +})); + +const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; const createMockPosition = ( overrides: Partial = {}, @@ -42,11 +51,52 @@ const createMockPriceUpdate = ( ...overrides, }); +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, cacheTime: Infinity } }, + }); + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + return { Wrapper, queryClient }; +}; + +const renderLivePositionsHook = ( + positions: PredictPosition[], + options?: Parameters[1], + cachedPositions?: PredictPosition[], +) => { + const { Wrapper, queryClient } = createWrapper(); + if (cachedPositions) { + queryClient.setQueryData( + predictQueries.positions.keys.byAddress(MOCK_ADDRESS), + cachedPositions, + ); + } + const renderResult = renderHook( + () => usePredictLivePositions(positions, options), + { + wrapper: Wrapper, + }, + ); + + return { + ...renderResult, + queryClient, + }; +}; + describe('usePredictLivePositions', () => { const mockUseLiveMarketPrices = useLiveMarketPrices as jest.Mock; + const getCachedPositions = (queryClient: QueryClient) => + queryClient.getQueryData( + predictQueries.positions.keys.byAddress(MOCK_ADDRESS), + ); + beforeEach(() => { jest.clearAllMocks(); + mockUseIsFocused.mockReturnValue(true); mockUseLiveMarketPrices.mockReturnValue({ prices: new Map(), isConnected: false, @@ -61,7 +111,7 @@ describe('usePredictLivePositions', () => { createMockPosition({ id: 'position-2', outcomeTokenId: 'token-2' }), ]; - renderHook(() => usePredictLivePositions(positions)); + renderLivePositionsHook(positions); expect(mockUseLiveMarketPrices).toHaveBeenCalledWith( ['token-1', 'token-2'], @@ -70,7 +120,7 @@ describe('usePredictLivePositions', () => { }); it('disables subscription when positions array is empty', () => { - renderHook(() => usePredictLivePositions([])); + renderLivePositionsHook([]); expect(mockUseLiveMarketPrices).toHaveBeenCalledWith([], { enabled: false, @@ -80,7 +130,7 @@ describe('usePredictLivePositions', () => { it('disables subscription when enabled option is false', () => { const positions = [createMockPosition()]; - renderHook(() => usePredictLivePositions(positions, { enabled: false })); + renderLivePositionsHook(positions, { enabled: false }); expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(['token-1'], { enabled: false, @@ -90,16 +140,37 @@ describe('usePredictLivePositions', () => { it('passes enabled true when positions exist and enabled is not specified', () => { const positions = [createMockPosition()]; - renderHook(() => usePredictLivePositions(positions)); + renderLivePositionsHook(positions); expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(['token-1'], { enabled: true, }); }); + + it('skips claimable positions when building live subscriptions', () => { + const positions = [createMockPosition({ claimable: true })]; + + renderLivePositionsHook(positions); + + expect(mockUseLiveMarketPrices).toHaveBeenCalledWith([], { + enabled: false, + }); + }); + + it('disables subscription when the screen is not focused', () => { + mockUseIsFocused.mockReturnValue(false); + const positions = [createMockPosition()]; + + renderLivePositionsHook(positions); + + expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(['token-1'], { + enabled: false, + }); + }); }); describe('live position calculation', () => { - it('returns original positions when no price updates are available', () => { + it('preserves cached positions when no price updates are available', async () => { const positions = [createMockPosition({ currentValue: 100, cashPnl: 0 })]; mockUseLiveMarketPrices.mockReturnValue({ prices: new Map(), @@ -107,13 +178,18 @@ describe('usePredictLivePositions', () => { lastUpdateTime: null, }); - const { result } = renderHook(() => usePredictLivePositions(positions)); + const { queryClient } = renderLivePositionsHook( + positions, + { cacheAddress: MOCK_ADDRESS }, + positions, + ); - expect(result.current.livePositions[0].currentValue).toBe(100); - expect(result.current.livePositions[0].cashPnl).toBe(0); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toBe(positions); + }); }); - it('calculates currentValue as size multiplied by bestBid', () => { + it('calculates currentValue as size multiplied by bestBid', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', size: 200, @@ -129,12 +205,19 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].currentValue).toBe(120); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].currentValue).toBe(120); + }); }); - it('calculates cashPnl as currentValue minus initialValue', () => { + it('calculates cashPnl as currentValue minus initialValue', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', size: 200, @@ -150,12 +233,19 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].cashPnl).toBe(20); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].cashPnl).toBe(20); + }); }); - it('calculates percentPnl correctly for positive gains', () => { + it('calculates percentPnl correctly for positive gains', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', size: 200, @@ -171,12 +261,19 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].percentPnl).toBe(20); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].percentPnl).toBe(20); + }); }); - it('calculates percentPnl correctly for negative losses', () => { + it('calculates percentPnl correctly for negative losses', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', size: 200, @@ -192,14 +289,21 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].currentValue).toBe(80); - expect(result.current.livePositions[0].cashPnl).toBe(-20); - expect(result.current.livePositions[0].percentPnl).toBe(-20); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].currentValue).toBe(80); + expect(cached?.[0].cashPnl).toBe(-20); + expect(cached?.[0].percentPnl).toBe(-20); + }); }); - it('returns zero percentPnl when initialValue is zero', () => { + it('returns zero percentPnl when initialValue is zero', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', size: 200, @@ -215,12 +319,19 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].percentPnl).toBe(0); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].percentPnl).toBe(0); + }); }); - it('updates price field with bestBid value', () => { + it('updates price field with bestBid value', async () => { const position = createMockPosition({ outcomeTokenId: 'token-1', price: 0.5, @@ -235,14 +346,21 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); + const { queryClient } = renderLivePositionsHook( + [position], + { cacheAddress: MOCK_ADDRESS }, + [position], + ); - expect(result.current.livePositions[0].price).toBe(0.65); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].price).toBe(0.65); + }); }); }); describe('multiple positions', () => { - it('updates only positions with matching price updates', () => { + it('updates only positions with matching price updates', async () => { const positions = [ createMockPosition({ id: 'position-1', @@ -269,15 +387,22 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions(positions)); + const { queryClient } = renderLivePositionsHook( + positions, + { cacheAddress: MOCK_ADDRESS }, + positions, + ); - expect(result.current.livePositions[0].currentValue).toBe(70); - expect(result.current.livePositions[0].cashPnl).toBe(20); - expect(result.current.livePositions[1].currentValue).toBe(100); - expect(result.current.livePositions[1].cashPnl).toBe(0); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].currentValue).toBe(70); + expect(cached?.[0].cashPnl).toBe(20); + expect(cached?.[1].currentValue).toBe(100); + expect(cached?.[1].cashPnl).toBe(0); + }); }); - it('updates all positions when all have price updates', () => { + it('updates all positions when all have price updates', async () => { const positions = [ createMockPosition({ id: 'position-1', @@ -308,122 +433,265 @@ describe('usePredictLivePositions', () => { lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions(positions)); + const { queryClient } = renderLivePositionsHook( + positions, + { cacheAddress: MOCK_ADDRESS }, + positions, + ); - expect(result.current.livePositions[0].currentValue).toBe(60); - expect(result.current.livePositions[1].currentValue).toBe(160); + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].currentValue).toBe(60); + expect(cached?.[1].currentValue).toBe(160); + }); }); }); - describe('connection status', () => { - it('returns isConnected from useLiveMarketPrices', () => { + describe('cache synchronization', () => { + it('syncs live values into the address cache for passed active positions', async () => { + const activePosition = createMockPosition({ + id: 'active-position', + currentValue: 100, + cashPnl: 0, + percentPnl: 0, + }); + const untouchedPosition = createMockPosition({ + id: 'untouched-position', + outcomeTokenId: 'token-2', + currentValue: 55, + cashPnl: 5, + percentPnl: 10, + }); + const priceUpdate = createMockPriceUpdate({ + tokenId: activePosition.outcomeTokenId, + bestBid: 0.6, + }); + mockUseLiveMarketPrices.mockReturnValue({ - prices: new Map(), + prices: new Map([[activePosition.outcomeTokenId, priceUpdate]]), isConnected: true, - lastUpdateTime: null, + lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([])); + const { queryClient } = renderLivePositionsHook( + [activePosition], + { + cacheAddress: MOCK_ADDRESS, + }, + [activePosition, untouchedPosition], + ); - expect(result.current.isConnected).toBe(true); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toEqual([ + expect.objectContaining({ + id: activePosition.id, + currentValue: 120, + cashPnl: 20, + percentPnl: 20, + price: 0.6, + }), + untouchedPosition, + ]); + }); }); - it('returns false for isConnected when disconnected', () => { - mockUseLiveMarketPrices.mockReturnValue({ - prices: new Map(), - isConnected: false, - lastUpdateTime: null, + it('ignores claimable positions when syncing cache', async () => { + const claimablePosition = createMockPosition({ + id: 'claimable-position', + claimable: true, + currentValue: 80, + cashPnl: 30, + percentPnl: 60, }); + const { queryClient } = renderLivePositionsHook( + [claimablePosition], + { + cacheAddress: MOCK_ADDRESS, + }, + [claimablePosition], + ); - const { result } = renderHook(() => usePredictLivePositions([])); - - expect(result.current.isConnected).toBe(false); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toEqual([claimablePosition]); + }); }); - it('returns lastUpdateTime from useLiveMarketPrices', () => { - const timestamp = 1704067200000; + it('does not rewrite cache when live values are unchanged', async () => { + const livePosition = createMockPosition({ + currentValue: 120, + cashPnl: 20, + percentPnl: 20, + price: 0.6, + }); + const priceUpdate = createMockPriceUpdate({ + tokenId: livePosition.outcomeTokenId, + bestBid: 0.6, + }); mockUseLiveMarketPrices.mockReturnValue({ - prices: new Map(), + prices: new Map([[livePosition.outcomeTokenId, priceUpdate]]), isConnected: true, - lastUpdateTime: timestamp, + lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([])); + const cachedPositions = [livePosition]; + const { queryClient } = renderLivePositionsHook( + [livePosition], + { + cacheAddress: MOCK_ADDRESS, + }, + cachedPositions, + ); - expect(result.current.lastUpdateTime).toBe(timestamp); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toBe(cachedPositions); + }); }); - it('returns null lastUpdateTime when no updates received', () => { + it('disables cache sync when enabled is false', async () => { + const activePosition = createMockPosition({ + currentValue: 100, + cashPnl: 0, + percentPnl: 0, + }); + const priceUpdate = createMockPriceUpdate({ + tokenId: activePosition.outcomeTokenId, + bestBid: 0.6, + }); mockUseLiveMarketPrices.mockReturnValue({ - prices: new Map(), + prices: new Map([[activePosition.outcomeTokenId, priceUpdate]]), isConnected: true, - lastUpdateTime: null, + lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([])); + const cachedPositions = [activePosition]; + const { queryClient } = renderLivePositionsHook( + [activePosition], + { + enabled: false, + cacheAddress: MOCK_ADDRESS, + }, + cachedPositions, + ); - expect(result.current.lastUpdateTime).toBeNull(); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toBe(cachedPositions); + }); }); - }); - describe('empty state', () => { - it('returns empty array for empty positions input', () => { - const { result } = renderHook(() => usePredictLivePositions([])); + it('disables cache sync when the screen is not focused', async () => { + mockUseIsFocused.mockReturnValue(false); + const activePosition = createMockPosition({ + currentValue: 100, + cashPnl: 0, + percentPnl: 0, + }); + const priceUpdate = createMockPriceUpdate({ + tokenId: activePosition.outcomeTokenId, + bestBid: 0.6, + }); + mockUseLiveMarketPrices.mockReturnValue({ + prices: new Map([[activePosition.outcomeTokenId, priceUpdate]]), + isConnected: true, + lastUpdateTime: Date.now(), + }); - expect(result.current.livePositions).toEqual([]); + const cachedPositions = [activePosition]; + const { queryClient } = renderLivePositionsHook( + [activePosition], + { + cacheAddress: MOCK_ADDRESS, + }, + cachedPositions, + ); + + await waitFor(() => { + expect(getCachedPositions(queryClient)).toBe(cachedPositions); + }); }); - it('preserves position order in output', () => { - const positions = [ - createMockPosition({ id: 'first', outcomeTokenId: 'token-1' }), - createMockPosition({ id: 'second', outcomeTokenId: 'token-2' }), - createMockPosition({ id: 'third', outcomeTokenId: 'token-3' }), - ]; + it('disables cache sync when cacheAddress is missing', async () => { + const activePosition = createMockPosition({ + currentValue: 100, + cashPnl: 0, + percentPnl: 0, + }); + const priceUpdate = createMockPriceUpdate({ + tokenId: activePosition.outcomeTokenId, + bestBid: 0.6, + }); + mockUseLiveMarketPrices.mockReturnValue({ + prices: new Map([[activePosition.outcomeTokenId, priceUpdate]]), + isConnected: true, + lastUpdateTime: Date.now(), + }); - const { result } = renderHook(() => usePredictLivePositions(positions)); + const cachedPositions = [activePosition]; + const { queryClient } = renderLivePositionsHook( + [activePosition], + undefined, + cachedPositions, + ); - expect(result.current.livePositions[0].id).toBe('first'); - expect(result.current.livePositions[1].id).toBe('second'); - expect(result.current.livePositions[2].id).toBe('third'); + await waitFor(() => { + expect(getCachedPositions(queryClient)).toBe(cachedPositions); + }); }); }); - describe('position data preservation', () => { - it('preserves all original position fields not related to value calculation', () => { - const position = createMockPosition({ - id: 'test-id', - providerId: 'test-provider', - marketId: 'test-market', - outcomeId: 'test-outcome', - outcome: 'Test Outcome', - title: 'Test Title', - icon: 'test-icon', - status: PredictPositionStatus.OPEN, - claimable: true, - endDate: '2025-06-15', - negRisk: true, - }); - const priceUpdate = createMockPriceUpdate({ tokenId: 'token-1' }); + describe('empty state', () => { + it('preserves position order in cache output', async () => { + const positions = [ + createMockPosition({ + id: 'first', + outcomeTokenId: 'token-1', + size: 100, + initialValue: 50, + }), + createMockPosition({ + id: 'second', + outcomeTokenId: 'token-2', + size: 200, + initialValue: 100, + }), + createMockPosition({ + id: 'third', + outcomeTokenId: 'token-3', + size: 300, + initialValue: 150, + }), + ]; + const pricesMap = new Map([ + [ + 'token-1', + createMockPriceUpdate({ tokenId: 'token-1', bestBid: 0.6 }), + ], + [ + 'token-2', + createMockPriceUpdate({ tokenId: 'token-2', bestBid: 0.7 }), + ], + [ + 'token-3', + createMockPriceUpdate({ tokenId: 'token-3', bestBid: 0.8 }), + ], + ]); mockUseLiveMarketPrices.mockReturnValue({ - prices: new Map([['token-1', priceUpdate]]), + prices: pricesMap, isConnected: true, lastUpdateTime: Date.now(), }); - const { result } = renderHook(() => usePredictLivePositions([position])); - - const livePosition = result.current.livePositions[0]; - expect(livePosition.id).toBe('test-id'); - expect(livePosition.providerId).toBe('test-provider'); - expect(livePosition.marketId).toBe('test-market'); - expect(livePosition.outcomeId).toBe('test-outcome'); - expect(livePosition.outcome).toBe('Test Outcome'); - expect(livePosition.title).toBe('Test Title'); - expect(livePosition.icon).toBe('test-icon'); - expect(livePosition.status).toBe(PredictPositionStatus.OPEN); - expect(livePosition.claimable).toBe(true); - expect(livePosition.endDate).toBe('2025-06-15'); - expect(livePosition.negRisk).toBe(true); + const { queryClient } = renderLivePositionsHook( + positions, + { cacheAddress: MOCK_ADDRESS }, + positions, + ); + + await waitFor(() => { + const cached = getCachedPositions(queryClient); + expect(cached?.[0].id).toBe('first'); + expect(cached?.[1].id).toBe('second'); + expect(cached?.[2].id).toBe('third'); + }); }); }); }); diff --git a/app/components/UI/Predict/hooks/usePredictLivePositions.ts b/app/components/UI/Predict/hooks/usePredictLivePositions.ts index 80530359d258..7878b3d39f02 100644 --- a/app/components/UI/Predict/hooks/usePredictLivePositions.ts +++ b/app/components/UI/Predict/hooks/usePredictLivePositions.ts @@ -1,64 +1,74 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useIsFocused } from '@react-navigation/native'; import { PredictPosition } from '../types'; +import { predictQueries } from '../queries'; import { useLiveMarketPrices } from './useLiveMarketPrices'; +/** + * Stable empty Map reference to avoid unnecessary useEffect cycles. + * When livePositionUpdates computes an empty Map, returning this constant + * preserves referential equality and prevents the cache-sync effect from firing. + */ +const EMPTY_POSITION_UPDATES = new Map< + string, + Pick +>(); + export interface UseLivePositionsOptions { /** * Whether to enable live price updates * @default true */ enabled?: boolean; -} - -export interface UseLivePositionsResult { - /** - * Positions with live-updated values based on current market prices - */ - livePositions: PredictPosition[]; - /** - * Whether the WebSocket connection is active - */ - isConnected: boolean; /** - * Timestamp of the last price update + * Address-scoped positions cache to sync live values into + * @internal */ - lastUpdateTime: number | null; + cacheAddress?: string; } /** - * Hook that takes positions and returns live-updated positions based on real-time market prices. - * - * Uses the bestBid price from live market data to calculate: - * - currentValue: size * bestBid (what you can sell for right now) - * - cashPnl: currentValue - initialValue (profit/loss) - * - percentPnl: ((currentValue - initialValue) / initialValue) * 100 + * Side-effect hook that subscribes to live market prices and syncs + * computed position values (currentValue, cashPnl, percentPnl, price) + * into the address-scoped positions query cache. * * @param positions - Array of positions to track (from usePredictPositions) - * @param options - Configuration options (enabled: boolean) - * @returns Live-updated positions, connection status, and last update timestamp + * @param options - Configuration options + * @internal Only consumed by usePredictPositions */ export const usePredictLivePositions = ( positions: PredictPosition[], options: UseLivePositionsOptions = {}, -): UseLivePositionsResult => { - const { enabled = true } = options; +): void => { + const { enabled = true, cacheAddress } = options; + const queryClient = useQueryClient(); + const isScreenFocused = useIsFocused(); const tokenIds = useMemo( - () => positions.map((position) => position.outcomeTokenId), + () => + positions + .filter((position) => !position.claimable) + .map((position) => position.outcomeTokenId), [positions], ); - const { prices, isConnected, lastUpdateTime } = useLiveMarketPrices( - tokenIds, - { enabled: enabled && positions.length > 0 }, - ); + const { prices } = useLiveMarketPrices(tokenIds, { + enabled: enabled && isScreenFocused && tokenIds.length > 0, + }); const livePositions = useMemo(() => { if (positions.length === 0) { return []; } - return positions.map((position) => { + let hasChanges = false; + + const nextPositions = positions.map((position) => { + if (position.claimable) { + return position; + } + const priceUpdate = prices.get(position.outcomeTokenId); if (!priceUpdate) { @@ -75,6 +85,17 @@ export const usePredictLivePositions = ( 100 : 0; + if ( + position.currentValue === liveCurrentValue && + position.cashPnl === liveCashPnl && + position.percentPnl === livePercentPnl && + position.price === bestBid + ) { + return position; + } + + hasChanges = true; + return { ...position, currentValue: liveCurrentValue, @@ -83,11 +104,90 @@ export const usePredictLivePositions = ( price: bestBid, }; }); + + return hasChanges ? nextPositions : positions; }, [positions, prices]); - return { - livePositions, - isConnected, - lastUpdateTime, - }; + const livePositionUpdates = useMemo(() => { + const updates = new Map< + string, + Pick + >(); + + livePositions.forEach((livePosition, index) => { + const originalPosition = positions[index]; + + if ( + !originalPosition || + originalPosition.id !== livePosition.id || + originalPosition === livePosition || + livePosition.claimable + ) { + return; + } + + updates.set(livePosition.id, { + currentValue: livePosition.currentValue, + cashPnl: livePosition.cashPnl, + percentPnl: livePosition.percentPnl, + price: livePosition.price, + }); + }); + + return updates.size > 0 ? updates : EMPTY_POSITION_UPDATES; + }, [livePositions, positions]); + + useEffect(() => { + if ( + !enabled || + !isScreenFocused || + !cacheAddress || + livePositionUpdates.size === 0 + ) { + return; + } + + queryClient.setQueryData( + predictQueries.positions.keys.byAddress(cacheAddress), + (cachedPositions) => { + if (!cachedPositions || cachedPositions.length === 0) { + return cachedPositions; + } + + let hasChanges = false; + + const nextPositions = cachedPositions.map((cachedPosition) => { + const livePositionUpdate = livePositionUpdates.get(cachedPosition.id); + + if (!livePositionUpdate || cachedPosition.claimable) { + return cachedPosition; + } + + if ( + cachedPosition.currentValue === livePositionUpdate.currentValue && + cachedPosition.cashPnl === livePositionUpdate.cashPnl && + cachedPosition.percentPnl === livePositionUpdate.percentPnl && + cachedPosition.price === livePositionUpdate.price + ) { + return cachedPosition; + } + + hasChanges = true; + + return { + ...cachedPosition, + ...livePositionUpdate, + }; + }); + + return hasChanges ? nextPositions : cachedPositions; + }, + ); + }, [ + cacheAddress, + enabled, + isScreenFocused, + livePositionUpdates, + queryClient, + ]); }; diff --git a/app/components/UI/Predict/hooks/usePredictPositions.test.ts b/app/components/UI/Predict/hooks/usePredictPositions.test.ts index 4c80ce6de693..5d94210e9f69 100644 --- a/app/components/UI/Predict/hooks/usePredictPositions.test.ts +++ b/app/components/UI/Predict/hooks/usePredictPositions.test.ts @@ -34,6 +34,10 @@ jest.mock('react-redux', () => ({ useSelector: (selector: () => unknown) => selector(), })); +jest.mock('./usePredictLivePositions', () => ({ + usePredictLivePositions: jest.fn(), +})); + const mockGetPositions = jest.fn< Promise, [{ address: string }] @@ -91,11 +95,16 @@ const createWrapper = () => { return { Wrapper, queryClient }; }; +const mockUsePredictLivePositions = jest.requireMock( + './usePredictLivePositions', +).usePredictLivePositions as jest.Mock; + describe('usePredictPositions', () => { beforeEach(() => { jest.clearAllMocks(); mockEnsurePolygonNetworkExists.mockResolvedValue(undefined); mockGetPositions.mockResolvedValue([]); + mockUsePredictLivePositions.mockImplementation(() => undefined); }); it('returns empty positions when query returns no positions', async () => { @@ -154,6 +163,13 @@ describe('usePredictPositions', () => { }); expect(result.current.data).toEqual([activePosition]); + expect(mockUsePredictLivePositions).toHaveBeenLastCalledWith( + [activePosition], + { + enabled: false, + cacheAddress: MOCK_ADDRESS, + }, + ); }); it('returns only claimable positions when claimable is true', async () => { @@ -176,6 +192,13 @@ describe('usePredictPositions', () => { }); expect(result.current.data).toEqual([claimablePosition]); + expect(mockUsePredictLivePositions).toHaveBeenLastCalledWith( + [claimablePosition], + { + enabled: false, + cacheAddress: MOCK_ADDRESS, + }, + ); }); it('filters positions by marketId', async () => { @@ -220,6 +243,10 @@ describe('usePredictPositions', () => { expect(mockGetPositions).not.toHaveBeenCalled(); expect(result.current.data).toBeUndefined(); expect(result.current.isFetching).toBe(false); + expect(mockUsePredictLivePositions).toHaveBeenLastCalledWith([], { + enabled: false, + cacheAddress: MOCK_ADDRESS, + }); }); it('returns query error message when query fails', async () => { @@ -371,4 +398,94 @@ describe('usePredictPositions', () => { expect(result.current.data).toEqual([parentPosition]); }); }); + + it('updates returned data through cache sync while keeping claimable rows unchanged', async () => { + const { Wrapper } = createWrapper(); + const activePosition = createPosition('active-cache-sync', { + claimable: false, + currentValue: 100, + cashPnl: 8, + percentPnl: 12, + }); + const claimablePosition = createPosition('claimable-cache-sync', { + claimable: true, + currentValue: 40, + cashPnl: 30, + percentPnl: 300, + marketId: 'market-claimable', + }); + mockGetPositions.mockResolvedValue([activePosition, claimablePosition]); + + mockUsePredictLivePositions.mockImplementation( + ( + positions: PredictPosition[], + options?: { enabled?: boolean; cacheAddress?: string }, + ) => { + const { useEffect } = jest.requireActual('react'); + const { useQueryClient } = jest.requireActual('@tanstack/react-query'); + const queryClient = useQueryClient(); + + useEffect(() => { + if (!options?.enabled || !options.cacheAddress) { + return; + } + + queryClient.setQueryData( + ['predict', 'positions', options.cacheAddress], + (cachedPositions: PredictPosition[] | undefined) => { + if (!cachedPositions) { + return cachedPositions; + } + + let hasChanges = false; + + const nextPositions = cachedPositions.map( + (position: PredictPosition) => { + if (position.id !== activePosition.id || position.claimable) { + return position; + } + + if ( + position.currentValue === 150 && + position.cashPnl === 58 && + position.percentPnl === 63 + ) { + return position; + } + + hasChanges = true; + + return { + ...position, + currentValue: 150, + cashPnl: 58, + percentPnl: 63, + }; + }, + ); + + return hasChanges ? nextPositions : cachedPositions; + }, + ); + }, [options?.cacheAddress, options?.enabled, queryClient, positions]); + }, + ); + + const { result } = renderHook( + () => usePredictPositions({ livePriceUpdates: true }), + { wrapper: Wrapper }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual([ + expect.objectContaining({ + id: activePosition.id, + currentValue: 150, + cashPnl: 58, + percentPnl: 63, + }), + claimablePosition, + ]); + }); + }); }); diff --git a/app/components/UI/Predict/hooks/usePredictPositions.ts b/app/components/UI/Predict/hooks/usePredictPositions.ts index ca1ce9fd36e8..ac3e7ca5ef8e 100644 --- a/app/components/UI/Predict/hooks/usePredictPositions.ts +++ b/app/components/UI/Predict/hooks/usePredictPositions.ts @@ -3,11 +3,13 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import type { PredictPosition } from '../types'; import { usePredictNetworkManagement } from './usePredictNetworkManagement'; +import { usePredictLivePositions } from './usePredictLivePositions'; import { getEvmAccountFromSelectedAccountGroup } from '../utils/accounts'; import { predictQueries } from '../queries'; import { selectSelectedAccountGroupId } from '../../../../selectors/multichainAccounts/accountTreeController'; const OPTIMISTIC_POLL_INTERVAL = 2_000; +const EMPTY_POSITIONS: PredictPosition[] = []; interface UsePredictPositionsOptions { enabled?: boolean; @@ -15,6 +17,7 @@ interface UsePredictPositionsOptions { claimable?: boolean; marketId?: string; childMarketIds?: string[]; + livePriceUpdates?: boolean; } function buildSelect( @@ -51,6 +54,7 @@ export function usePredictPositions(options: UsePredictPositionsOptions = {}) { claimable, marketId, childMarketIds, + livePriceUpdates = false, } = options; const { ensurePolygonNetworkExists } = usePredictNetworkManagement(); @@ -74,7 +78,7 @@ export function usePredictPositions(options: UsePredictPositionsOptions = {}) { (p: PredictPosition) => p.optimistic, ); - return useQuery({ + const query = useQuery({ ...queryOpts, enabled, refetchInterval: hasOptimistic @@ -82,4 +86,11 @@ export function usePredictPositions(options: UsePredictPositionsOptions = {}) { : (refetchInterval ?? false), select: buildSelect(claimable, marketId, childMarketIds), }); + + usePredictLivePositions(query.data ?? EMPTY_POSITIONS, { + enabled: enabled && livePriceUpdates, + cacheAddress: address, + }); + + return query; } diff --git a/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts b/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts index 9ded1b0bd719..775b51b8b021 100644 --- a/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts +++ b/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts @@ -525,6 +525,58 @@ describe('WebSocketManager', () => { }), ); }); + + it('does not unsubscribe overlapping tokens still needed by another subscription', () => { + const manager = WebSocketManager.getInstance(); + const homepageCallback = jest.fn(); + const marketDetailsCallback = jest.fn(); + + manager.subscribeToMarketPrices(['token1', 'token2'], homepageCallback); + const unsubscribeMarketDetails = manager.subscribeToMarketPrices( + ['token1'], + marketDetailsCallback, + ); + mockWebSocketInstances[0].simulateOpen(); + mockWebSocketInstances[0].send.mockClear(); + + unsubscribeMarketDetails(); + + expect(mockWebSocketInstances[0].send).not.toHaveBeenCalledWith( + JSON.stringify({ + operation: 'unsubscribe', + assets_ids: ['token1'], + }), + ); + }); + + it('only unsubscribes tokens no longer needed by remaining subscriptions', () => { + const manager = WebSocketManager.getInstance(); + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + manager.subscribeToMarketPrices(['token1', 'token2'], callback1); + const unsubscribe = manager.subscribeToMarketPrices( + ['token2', 'token3'], + callback2, + ); + mockWebSocketInstances[0].simulateOpen(); + mockWebSocketInstances[0].send.mockClear(); + + unsubscribe(); + + expect(mockWebSocketInstances[0].send).toHaveBeenCalledWith( + JSON.stringify({ + operation: 'unsubscribe', + assets_ids: ['token3'], + }), + ); + expect(mockWebSocketInstances[0].send).not.toHaveBeenCalledWith( + JSON.stringify({ + operation: 'unsubscribe', + assets_ids: ['token2', 'token3'], + }), + ); + }); }); describe('crypto price subscriptions', () => { diff --git a/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts b/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts index 32854b837a1b..935e7c2628d1 100644 --- a/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts +++ b/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts @@ -306,7 +306,14 @@ export class WebSocketManager { callbacks.delete(callback); if (callbacks.size === 0) { this.priceSubscriptions.delete(subscriptionKey); - this.sendMarketUnsubscribe(tokenIds); + const remainingTokenIds = this.getSubscribedMarketTokenIds(); + const tokenIdsToUnsubscribe = tokenIds.filter( + (tokenId) => !remainingTokenIds.has(tokenId), + ); + + if (tokenIdsToUnsubscribe.length > 0) { + this.sendMarketUnsubscribe(tokenIdsToUnsubscribe); + } } } @@ -449,12 +456,23 @@ export class WebSocketManager { ); } - private resubscribeAllMarkets(): void { - const allTokenIds = new Set(); + private getSubscribedMarketTokenIds(): Set { + const subscribedTokenIds = new Set(); + this.priceSubscriptions.forEach((_, key) => { - key.split(',').forEach((id) => allTokenIds.add(id)); + key.split(',').forEach((tokenId) => { + if (tokenId) { + subscribedTokenIds.add(tokenId); + } + }); }); + return subscribedTokenIds; + } + + private resubscribeAllMarkets(): void { + const allTokenIds = this.getSubscribedMarketTokenIds(); + if (allTokenIds.size > 0) { this.sendMarketSubscribe(Array.from(allTokenIds)); } diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx index edb94687527f..c905e01ae5b5 100644 --- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx +++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx @@ -1012,4 +1012,58 @@ describe('PredictFeed', () => { expect(queryByPlaceholderText('Search prediction markets')).toBeNull(); }); }); + + describe('hideHeader prop', () => { + it('renders header nav by default when hideHeader is not provided', () => { + const { getByTestId } = render(); + + expect( + getByTestId(PredictMarketListSelectorsIDs.BACK_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON), + ).toBeOnTheScreen(); + }); + + it('hides header nav when hideHeader is true', () => { + const { queryByTestId } = render(); + + expect( + queryByTestId(PredictMarketListSelectorsIDs.BACK_BUTTON), + ).toBeNull(); + expect(queryByTestId(PredictSearchSelectorsIDs.SEARCH_BUTTON)).toBeNull(); + }); + + it('still renders container, tabs, and pager when hideHeader is true', () => { + const { getByTestId } = render(); + + expect( + getByTestId(PredictMarketListSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + expect(getByTestId(PredictFeedSelectorsIDs.TABS)).toBeOnTheScreen(); + expect( + getByTestId(PredictFeedMockSelectorsIDs.PAGER_VIEW), + ).toBeOnTheScreen(); + }); + }); + + describe('onHeaderHiddenChange prop', () => { + it('passes onHeaderHiddenChange callback to useFeedScrollManager', () => { + const onHeaderHiddenChange = jest.fn(); + + render(); + + expect(mockUseFeedScrollManager).toHaveBeenCalledWith( + expect.objectContaining({ onHeaderHiddenChange }), + ); + }); + + it('passes undefined to useFeedScrollManager when onHeaderHiddenChange is not provided', () => { + render(); + + expect(mockUseFeedScrollManager).toHaveBeenCalledWith( + expect.objectContaining({ onHeaderHiddenChange: undefined }), + ); + }); + }); }); diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx index 6b1af5de4ec5..d8ad1825e8a7 100644 --- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx +++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx @@ -604,7 +604,15 @@ const PredictSearchOverlay: React.FC = ({ ); }; -const PredictFeed: React.FC = () => { +interface PredictFeedProps { + hideHeader?: boolean; + onHeaderHiddenChange?: (hidden: boolean) => void; +} + +const PredictFeed: React.FC = ({ + hideHeader = false, + onHeaderHiddenChange, +}) => { const { tabs, activeIndex, @@ -684,6 +692,7 @@ const PredictFeed: React.FC = () => { headerRef, tabBarRef, setActiveIndex, + onHeaderHiddenChange, }); const handleTabPress = useCallback( @@ -714,27 +723,29 @@ const PredictFeed: React.FC = () => { twClassName="flex-1" style={{ backgroundColor: colors.background.default }} > - - - + {!hideHeader && ( + + + + )} ({ })), })); +jest.mock('../../hooks/usePredictLivePositions', () => ({ + usePredictLivePositions: jest.fn(), +})); + jest.mock('../../hooks/usePredictBalance', () => ({ usePredictBalance: jest.fn(() => ({ data: 100, @@ -562,6 +566,9 @@ function setupPredictMarketDetailsTest( const { usePredictPositions } = jest.requireMock( '../../hooks/usePredictPositions', ); + const { usePredictLivePositions } = jest.requireMock( + '../../hooks/usePredictLivePositions', + ); const { usePredictEligibility } = jest.requireMock( '../../hooks/usePredictEligibility', ); @@ -624,6 +631,7 @@ function setupPredictMarketDetailsTest( ({ claimable }: { claimable?: boolean }) => claimable ? claimablePositionsHook : activePositionsHook, ); + usePredictLivePositions.mockImplementation(() => undefined); // Set up usePredictOrderPreview mock to return preview data matching position currentValue mockUsePredictOrderPreview.mockImplementation( diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index 334846bcc7d2..75999c086299 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -129,6 +129,7 @@ const PredictMarketDetails: React.FC = () => { childMarketIds: market?.childMarketIds, claimable: false, enabled: !isMarketLoading && Boolean(resolvedMarketId), + livePriceUpdates: true, }); // "claimable" positions diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index 4e6887cdcf73..437caae587bd 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -69,6 +69,7 @@ import { import TruncatedError from '../../components/TruncatedError'; import { PROVIDER_LINKS } from '../../Aggregator/types'; +import { failSession } from '../../headless/sessionRegistry'; const BAILED_ORDER_STATUSES = new Set([ RampsOrderStatus.Precreated, RampsOrderStatus.IdExpired, @@ -159,10 +160,24 @@ function BuildQuote() { useEffect(() => { if (params?.nativeFlowError) { + if ( + params.headlessSessionId && + failSession( + params.headlessSessionId, + { + code: 'AUTH_FAILED', + message: params.nativeFlowError, + }, + 'AUTH_FAILED', + ) + ) { + navigation.setParams({ nativeFlowError: undefined }); + return; + } setRampsError(params.nativeFlowError); navigation.setParams({ nativeFlowError: undefined }); } - }, [params?.nativeFlowError, navigation]); + }, [params?.headlessSessionId, params?.nativeFlowError, navigation]); const { userRegion, @@ -627,6 +642,9 @@ function BuildQuote() { assetId: selectedToken?.assetId ?? '', }); } catch (err) { + if (failSession(params?.headlessSessionId, err)) { + return; + } setRampsError((err as Error).message); } finally { setIsContinueLoading(false); @@ -642,6 +660,7 @@ function BuildQuote() { selectedPaymentMethod?.id, rampRoutingDecision, userRegion?.regionCode, + params?.headlessSessionId, trackEvent, createEventBuilder, continueWithQuote, diff --git a/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx b/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx index b1408bb9a46c..8d4f519e8250 100644 --- a/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx +++ b/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx @@ -58,6 +58,7 @@ jest.mock('../../utils/v2OrderToast', () => ({ jest.mock('../../headless/sessionRegistry', () => ({ getSession: jest.fn(), closeSession: jest.fn(), + failSession: jest.fn(), })); jest.mock('../../../../../util/Logger', () => ({ @@ -618,6 +619,8 @@ describe('Checkout', () => { .getSession as jest.Mock; const mockCloseSession = jest.requireMock('../../headless/sessionRegistry') .closeSession as jest.Mock; + const mockFailSession = jest.requireMock('../../headless/sessionRegistry') + .failSession as jest.Mock; const showV2OrderToastMock = jest.requireMock('../../utils/v2OrderToast') .showV2OrderToast as jest.Mock; @@ -641,6 +644,7 @@ describe('Checkout', () => { beforeEach(() => { mockGetSession.mockReset(); mockCloseSession.mockReset(); + mockFailSession.mockReset(); mockParentPop = jest.fn(); mockNavigation.getParent.mockReturnValue({ pop: mockParentPop }); mockGetOrderFromCallback.mockResolvedValue(mockOrder); @@ -740,6 +744,52 @@ describe('Checkout', () => { expect(mockParentPop).toHaveBeenCalled(); }); + it('surfaces callback processing failures through onError and skips the ErrorView', async () => { + mockUseParams.mockReturnValue(callbackFlowParams); + mockGetOrderFromCallback.mockRejectedValueOnce( + new Error('callback failed'), + ); + mockFailSession.mockReturnValue({ + code: 'UNKNOWN', + message: 'callback failed', + }); + + const { getByTestId, queryByText } = renderWithProvider( + , + {}, + true, + false, + ); + + await act(async () => { + fireEvent.press(getByTestId('trigger-callback-navigation')); + }); + + await waitFor(() => { + expect(mockFailSession).toHaveBeenCalledWith('hs-1', expect.any(Error)); + }); + expect(mockParentPop).toHaveBeenCalled(); + expect(showV2OrderToastMock).not.toHaveBeenCalled(); + expect(queryByText('callback failed')).toBeNull(); + }); + + it('surfaces provider WebView HTTP errors through onError when headless', async () => { + mockUseParams.mockReturnValue(callbackFlowParams); + mockFailSession.mockReturnValue({ + code: 'UNKNOWN', + message: 'fiat_on_ramp_aggregator.webview_received_error', + }); + + const { getByTestId } = renderWithProvider(, {}, true, false); + + await act(async () => { + fireEvent.press(getByTestId('trigger-http-error-main-uri')); + }); + + expect(mockFailSession).toHaveBeenCalledWith('hs-1', expect.any(Error)); + expect(mockParentPop).toHaveBeenCalled(); + }); + it('falls back to the regular reset + toast when session id is present but session is missing from registry', async () => { mockGetSession.mockReturnValue(undefined); mockUseParams.mockReturnValue(callbackFlowParams); diff --git a/app/components/UI/Ramp/Views/Checkout/Checkout.tsx b/app/components/UI/Ramp/Views/Checkout/Checkout.tsx index a11923bc9b6e..0dcc8437aa36 100644 --- a/app/components/UI/Ramp/Views/Checkout/Checkout.tsx +++ b/app/components/UI/Ramp/Views/Checkout/Checkout.tsx @@ -27,7 +27,11 @@ import { import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import useRampsUnifiedV2Enabled from '../../hooks/useRampsUnifiedV2Enabled'; import { showV2OrderToast } from '../../utils/v2OrderToast'; -import { closeSession, getSession } from '../../headless/sessionRegistry'; +import { + closeSession, + failSession, + getSession, +} from '../../headless/sessionRegistry'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './Checkout.styles'; import Device from '../../../../../util/device'; @@ -120,6 +124,18 @@ const Checkout = () => { } }, [uri, createEventBuilder, trackEvent, rampRoutingDecision]); + const failHeadlessCheckout = useCallback( + (checkoutError: unknown) => { + if (!failSession(headlessSessionId, checkoutError)) { + return false; + } + // @ts-expect-error navigation prop mismatch + navigation.getParent()?.pop(); + return true; + }, + [headlessSessionId, navigation], + ); + useEffect(() => { // For external-browser flows (e.g. PayPal), addPrecreatedOrder is called in // BuildQuote; the user never reaches Checkout. For WebView flows, @@ -234,6 +250,9 @@ const Checkout = () => { Logger.error(navError as Error, { message: 'UnifiedCheckout: error handling callback', }); + if (failHeadlessCheckout(navError)) { + return; + } setError((navError as Error)?.message); } }, @@ -248,6 +267,7 @@ const Checkout = () => { isV2Enabled, params?.cryptocurrency, headlessSessionId, + failHeadlessCheckout, ], ); @@ -344,6 +364,9 @@ const Checkout = () => { 'fiat_on_ramp_aggregator.webview_received_error', { code: nativeEvent.statusCode }, ); + if (failHeadlessCheckout(new Error(webviewHttpError))) { + return; + } setError(webviewHttpError); } else { Logger.log( diff --git a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx index 898358b451f5..67d2031d98ad 100644 --- a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx +++ b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx @@ -348,6 +348,25 @@ describe('HeadlessHost', () => { expect(screen.getByText('quote expired')).toBeOnTheScreen(); }); + it('surfaces limit failures as onError(LIMIT_EXCEEDED, ...)', async () => { + const limitError = new Error('Daily limit exceeded'); + limitError.name = 'LimitExceededError'; + mockContinueWithQuote.mockRejectedValueOnce(limitError); + const quote = buildNativeQuote(); + const session = seedSession(quote); + const callbacks = session.callbacks; + renderHost({ headlessSessionId: session.id }); + await waitFor(() => { + expect(callbacks.onError).toHaveBeenCalledWith({ + code: 'LIMIT_EXCEEDED', + message: 'Daily limit exceeded', + }); + }); + expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' }); + expect(getSession(session.id)).toBeUndefined(); + expect(screen.getByText('Daily limit exceeded')).toBeOnTheScreen(); + }); + it('does not run the continueWithQuote rejection path after unmount', async () => { let rejectDeferred: ((error: Error) => void) | undefined; mockContinueWithQuote.mockImplementation( diff --git a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx index 313c1da66850..22a843f8170c 100644 --- a/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx +++ b/app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx @@ -27,7 +27,7 @@ import Logger from '../../../../../util/Logger'; // Going through the barrel would leave the registry exports `undefined` // at evaluation time inside this module. import { - closeSession, + failSession, getSession, setStatus, } from '../../headless/sessionRegistry'; @@ -132,26 +132,17 @@ function HeadlessHost() { if (!nativeFlowError) { return; } - const liveSession = getSession(headlessSessionId); - if (!liveSession) { - return; - } - setErrorMessage(nativeFlowError); - try { - liveSession.callbacks.onError({ - code: 'AUTH_FAILED', - message: nativeFlowError, - }); - } catch (e) { - Logger.error(e as Error, 'HeadlessHost: onError callback threw'); - } - closeSession( + const headlessError = failSession( headlessSessionId, - { reason: 'unknown' }, { - terminalStatus: 'failed', + code: 'AUTH_FAILED', + message: nativeFlowError, }, + 'AUTH_FAILED', ); + if (headlessError) { + setErrorMessage(headlessError.message ?? nativeFlowError); + } }, [nativeFlowError, headlessSessionId]); // Process the session. Uses `useEffect` (not `useFocusEffect`) so that @@ -202,25 +193,11 @@ function HeadlessHost() { if (!chainId) { const message = `HeadlessHost: invalid assetId "${currentSession.params.assetId}"`; Logger.error(new Error(message)); - try { - currentSession.callbacks.onError({ - code: 'UNKNOWN', - message, - }); - } catch (e) { - Logger.error(e as Error, 'HeadlessHost: onError callback threw'); - } // closeSession alone does not trigger a re-render; without setState the // render-time `session` ref stays truthy and the loader would spin // forever. Surface the same message in UI as other error paths. setErrorMessage(message); - closeSession( - headlessSessionId, - { reason: 'unknown' }, - { - terminalStatus: 'failed', - }, - ); + failSession(headlessSessionId, { code: 'UNKNOWN', message }); return; } // Defer until walletAddress resolves — avoids calling continueWithQuote @@ -270,22 +247,8 @@ function HeadlessHost() { if (!liveSession) { return; } - setErrorMessage(message); - try { - liveSession.callbacks.onError({ - code: 'UNKNOWN', - message, - }); - } catch (e) { - Logger.error(e as Error, 'HeadlessHost: onError callback threw'); - } - closeSession( - headlessSessionId, - { reason: 'unknown' }, - { - terminalStatus: 'failed', - }, - ); + const headlessError = failSession(headlessSessionId, error); + setErrorMessage(headlessError?.message ?? message); }); return () => { cancelled = true; diff --git a/app/components/UI/Ramp/headless/PLAN.md b/app/components/UI/Ramp/headless/PLAN.md index 61a1ac708a89..b85630e161b3 100644 --- a/app/components/UI/Ramp/headless/PLAN.md +++ b/app/components/UI/Ramp/headless/PLAN.md @@ -14,7 +14,7 @@ - [x] **Phase 5 (revised)** — Quote-first headless start path — `startHeadlessBuy({ quote, redirectUrl? })` creates a session carrying the quote, navigates to Headless Host, Host calls `continueWithQuote(quote, ctx)` and re-orchestrates after auth loops - [ ] **Phase 5b (deferred)** — `startHeadlessBuy({ assetId, amount, paymentMethodId, providerId? })` "open BuildQuote / Host fetches quotes" mode — picked up after the quote-first path is stable - [x] **Phase 6** — Bypass order-processing redirect in Transak/aggregator routing when headless; fire `onOrderCreated` and end session -- [ ] **Phase 7** — Extract UI-coupled error/limit surfacing; route errors through `onError` as typed `HeadlessBuyError` +- [x] **Phase 7** — Extract UI-coupled error/limit surfacing; route errors through `onError` as typed `HeadlessBuyError` - [ ] **Phase 8** — Cancellation + `onClose` semantics (including user-dismissed detection) - [ ] **Phase 9** — Expose `getOrder` / `refreshOrder` from hook and show in playground - [ ] **Phase 10** — Playground polish — event log, input persistence, aggregator/native presets diff --git a/app/components/UI/Ramp/headless/sessionRegistry.test.ts b/app/components/UI/Ramp/headless/sessionRegistry.test.ts index 78222812c41e..74b36ea39cb9 100644 --- a/app/components/UI/Ramp/headless/sessionRegistry.test.ts +++ b/app/components/UI/Ramp/headless/sessionRegistry.test.ts @@ -3,9 +3,11 @@ import { closeSession, createSession, endSession, + failSession, getActiveSessionId, getSession, setStatus, + toHeadlessBuyError, } from './sessionRegistry'; import type { HeadlessBuyCallbacks, HeadlessBuyParams } from './types'; import type { Quote } from '../types'; @@ -185,4 +187,77 @@ describe('sessionRegistry', () => { expect(getActiveSessionId()).toBeUndefined(); }); }); + + describe('toHeadlessBuyError', () => { + it('preserves explicit headless error codes and details', () => { + expect( + toHeadlessBuyError({ + code: 'LIMIT_EXCEEDED', + message: 'Daily limit exceeded', + details: { period: 'daily' }, + }), + ).toEqual({ + code: 'LIMIT_EXCEEDED', + message: 'Daily limit exceeded', + details: { period: 'daily' }, + }); + }); + + it('maps LimitExceededError instances to LIMIT_EXCEEDED', () => { + const error = new Error('Daily limit exceeded'); + error.name = 'LimitExceededError'; + expect(toHeadlessBuyError(error)).toEqual({ + code: 'LIMIT_EXCEEDED', + message: 'Daily limit exceeded', + }); + }); + + it('falls back to UNKNOWN for regular errors', () => { + expect(toHeadlessBuyError(new Error('provider failed'))).toEqual({ + code: 'UNKNOWN', + message: 'provider failed', + }); + }); + }); + + describe('failSession', () => { + it('fires onError then onClose and removes the session', () => { + const callbacks = buildCallbacks(); + const session = createSession(baseParams, callbacks); + + const headlessError = failSession(session.id, { + code: 'QUOTE_FAILED', + message: 'Quote expired', + }); + + expect(headlessError).toEqual({ + code: 'QUOTE_FAILED', + message: 'Quote expired', + details: undefined, + }); + expect(callbacks.onError).toHaveBeenCalledWith(headlessError); + expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' }); + expect(session.status).toBe('failed'); + expect(getSession(session.id)).toBeUndefined(); + }); + + it('logs onError failures but still closes the session', () => { + const callbacks = { + ...buildCallbacks(), + onError: jest.fn(() => { + throw new Error('consumer onError boom'); + }), + }; + const session = createSession(baseParams, callbacks); + + failSession(session.id, new Error('provider failed')); + + expect(mockLoggerError).toHaveBeenCalledWith( + expect.any(Error), + 'headless sessionRegistry: onError callback threw', + ); + expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' }); + expect(getSession(session.id)).toBeUndefined(); + }); + }); }); diff --git a/app/components/UI/Ramp/headless/sessionRegistry.ts b/app/components/UI/Ramp/headless/sessionRegistry.ts index 2f7f354b79c9..586e303337f3 100644 --- a/app/components/UI/Ramp/headless/sessionRegistry.ts +++ b/app/components/UI/Ramp/headless/sessionRegistry.ts @@ -3,17 +3,86 @@ import type { CloseSessionOptions, HeadlessBuyCallbacks, HeadlessBuyCloseInfo, + HeadlessBuyError, + HeadlessBuyErrorCode, HeadlessBuyParams, HeadlessSession, HeadlessSessionStatus, } from './types'; +const HEADLESS_BUY_ERROR_CODES: ReadonlySet = new Set([ + 'NO_QUOTES', + 'LIMIT_EXCEEDED', + 'KYC_REQUIRED', + 'AUTH_FAILED', + 'QUOTE_FAILED', + 'USER_CANCELLED', + 'UNKNOWN', +]); + function isTerminalSessionStatus(status: HeadlessSessionStatus): boolean { return ( status === 'completed' || status === 'cancelled' || status === 'failed' ); } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isHeadlessBuyErrorCode(value: unknown): value is HeadlessBuyErrorCode { + return ( + typeof value === 'string' && + HEADLESS_BUY_ERROR_CODES.has(value as HeadlessBuyErrorCode) + ); +} + +function getErrorMessage(error: unknown): string | undefined { + if (error instanceof Error) { + return error.message; + } + if (isRecord(error) && typeof error.message === 'string') { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return undefined; +} + +export function toHeadlessBuyError( + error: unknown, + fallbackCode: HeadlessBuyErrorCode = 'UNKNOWN', +): HeadlessBuyError { + if (isRecord(error)) { + const explicitCode = isHeadlessBuyErrorCode(error.headlessBuyErrorCode) + ? error.headlessBuyErrorCode + : isHeadlessBuyErrorCode(error.code) + ? error.code + : undefined; + + if (explicitCode) { + return { + code: explicitCode, + message: getErrorMessage(error), + details: isRecord(error.details) ? error.details : undefined, + }; + } + } + + if (error instanceof Error && error.name === 'LimitExceededError') { + return { + code: 'LIMIT_EXCEEDED', + message: error.message, + }; + } + + return { + code: fallbackCode, + message: getErrorMessage(error), + }; +} + /** * Module-level registry that holds the live headless buy sessions. Sessions * carry non-serializable callbacks and therefore cannot live in Redux nor in @@ -158,6 +227,42 @@ export function closeSession( } } +/** + * Idempotent "fail and notify" for unrecoverable headless errors. It turns + * thrown/native errors into the public HeadlessBuyError shape, fires `onError`, + * then terminates the session through `closeSession`. + */ +export function failSession( + id: string | undefined, + error: unknown, + fallbackCode: HeadlessBuyErrorCode = 'UNKNOWN', +): HeadlessBuyError | undefined { + if (!id) { + return undefined; + } + const session = sessions.get(id); + if (!session) { + return undefined; + } + const headlessError = toHeadlessBuyError(error, fallbackCode); + try { + session.callbacks.onError(headlessError); + } catch (e) { + Logger.error( + e instanceof Error ? e : new Error(String(e)), + 'headless sessionRegistry: onError callback threw', + ); + } + closeSession( + id, + { reason: 'unknown' }, + { + terminalStatus: 'failed', + }, + ); + return headlessError; +} + /** * Test-only helper. Resets registry state between tests so they do not leak * sessions into one another. diff --git a/app/components/UI/Ramp/headless/types.ts b/app/components/UI/Ramp/headless/types.ts index 4cd3a276dcb8..c2eec400413e 100644 --- a/app/components/UI/Ramp/headless/types.ts +++ b/app/components/UI/Ramp/headless/types.ts @@ -149,15 +149,17 @@ export interface HeadlessBuyCallbacks { * the UI normally renders. Phase 3 only uses `UNKNOWN`; later phases route * limit/auth/etc. errors through it. */ +export type HeadlessBuyErrorCode = + | 'NO_QUOTES' + | 'LIMIT_EXCEEDED' + | 'KYC_REQUIRED' + | 'AUTH_FAILED' + | 'QUOTE_FAILED' + | 'USER_CANCELLED' + | 'UNKNOWN'; + export interface HeadlessBuyError { - code: - | 'NO_QUOTES' - | 'LIMIT_EXCEEDED' - | 'KYC_REQUIRED' - | 'AUTH_FAILED' - | 'QUOTE_FAILED' - | 'USER_CANCELLED' - | 'UNKNOWN'; + code: HeadlessBuyErrorCode; message?: string; details?: Record; } diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts index 65afcb54df34..d766852dce98 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.test.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.test.ts @@ -19,6 +19,7 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('../headless/sessionRegistry', () => ({ getSession: jest.fn(), closeSession: jest.fn(), + failSession: jest.fn(), })); const MOCK_WALLET_ADDRESS = '0xabcdef1234567890'; @@ -1047,11 +1048,11 @@ describe('useTransakRouting', () => { describe('navigateToKycWebview', () => { it('resets navigation stack with KycProcessing behind the webview', () => { const { result } = renderHook(() => useTransakRouting()); - const mockQuote = { id: 'quote-789' } as unknown as TransakBuyQuote; + const kycQuote = { id: 'quote-789' } as unknown as TransakBuyQuote; act(() => { result.current.navigateToKycWebview({ - quote: mockQuote, + quote: kycQuote, kycUrl: 'https://kyc.example.com', workFlowRunId: 'wf-456', amount: 30, @@ -1075,7 +1076,7 @@ describe('useTransakRouting', () => { url: 'https://kyc.example.com', providerName: 'Transak', workFlowRunId: 'wf-456', - quote: mockQuote, + quote: kycQuote, amount: 30, }), }), @@ -1389,6 +1390,8 @@ describe('useTransakRouting', () => { .getSession as jest.Mock; const mockCloseSession = jest.requireMock('../headless/sessionRegistry') .closeSession as jest.Mock; + const mockFailSession = jest.requireMock('../headless/sessionRegistry') + .failSession as jest.Mock; const mockShowV2OrderToast = jest.requireMock('../utils/v2OrderToast') .showV2OrderToast as jest.Mock; @@ -1455,6 +1458,7 @@ describe('useTransakRouting', () => { beforeEach(() => { mockGetSession.mockReset(); mockCloseSession.mockReset(); + mockFailSession.mockReset(); mockParentPop.mockReset(); mockGetOrder.mockResolvedValue(depositOrder); mockRefreshOrder.mockResolvedValue(refreshedOrder); @@ -1562,6 +1566,108 @@ describe('useTransakRouting', () => { expect(mockParentPop).toHaveBeenCalled(); }); + it('preserves LIMIT_EXCEEDED errors for the Headless Host to surface as data', async () => { + mockGetUserDetails.mockResolvedValue({ + firstName: 'John', + address: {}, + }); + mockGetKycRequirement.mockResolvedValue({ + status: 'APPROVED', + kycType: 'SIMPLE', + }); + mockGetUserLimits.mockResolvedValue({ + remaining: { '1': 50, '30': 50000, '365': 200000 }, + }); + + const { result } = renderHook(() => useTransakRouting(HEADLESS_CONFIG)); + + await expect( + act(async () => { + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); + }), + ).rejects.toMatchObject({ + name: 'LimitExceededError', + headlessBuyErrorCode: 'LIMIT_EXCEEDED', + }); + + expect(mockShowV2OrderToast).not.toHaveBeenCalled(); + expect(mockFailSession).not.toHaveBeenCalled(); + }); + + it('surfaces headless checkout processing failures through onError and skips toasts', async () => { + const handler = await runApprovedFlowHeadless(); + expect(handler).not.toBeNull(); + if (!handler) return; + + mockGetOrder.mockRejectedValue(new Error('Network error')); + mockFailSession.mockReturnValue({ + code: 'UNKNOWN', + message: 'Network error', + }); + + await act(async () => { + await handler({ + url: 'https://redirect.example.com?orderId=order-hs', + }); + }); + + expect(mockFailSession).toHaveBeenCalledWith('hs-1', expect.any(Error)); + expect(mockShowV2OrderToast).not.toHaveBeenCalled(); + expect(mockParentPop).toHaveBeenCalled(); + }); + + it('routes manual bank transfer order success through headless callbacks without showing a toast', async () => { + const onOrderCreated = jest.fn(); + mockGetSession.mockReturnValue({ + id: 'hs-1', + status: 'continued', + callbacks: { + onOrderCreated, + onError: jest.fn(), + onClose: jest.fn(), + }, + }); + mockSelectedPaymentMethod = { + id: '/payments/bank-transfer', + isManualBankTransfer: true, + }; + mockGetUserDetails.mockResolvedValue({ + firstName: 'John', + lastName: 'Doe', + mobileNumber: '+1', + dob: '1990-01-01', + address: {}, + }); + mockGetKycRequirement.mockResolvedValue({ + status: 'APPROVED', + kycType: 'SIMPLE', + }); + mockGetUserLimits.mockResolvedValue({ + remaining: { '1': 10000, '30': 50000, '365': 200000 }, + }); + mockTransakCreateOrder.mockResolvedValue(depositOrder); + mockRefreshOrder.mockResolvedValue(refreshedOrder); + + const { result } = renderHook(() => useTransakRouting(HEADLESS_CONFIG)); + + await act(async () => { + await result.current.routeAfterAuthentication( + mockQuote as never, + mockQuote.fiatAmount, + ); + }); + + expect(onOrderCreated).toHaveBeenCalledWith('order-hs'); + expect(mockCloseSession).toHaveBeenCalledWith('hs-1', { + reason: 'completed', + }); + expect(mockShowV2OrderToast).not.toHaveBeenCalled(); + expect(mockParentPop).toHaveBeenCalled(); + }); + it('falls back to the regular order-details reset + toast when session id is present but session is missing from registry', async () => { mockGetSession.mockReturnValue(undefined); diff --git a/app/components/UI/Ramp/hooks/useTransakRouting.ts b/app/components/UI/Ramp/hooks/useTransakRouting.ts index c15fc3afa7ed..5319a7e41f84 100644 --- a/app/components/UI/Ramp/hooks/useTransakRouting.ts +++ b/app/components/UI/Ramp/hooks/useTransakRouting.ts @@ -28,7 +28,11 @@ import useRampAccountAddress from './useRampAccountAddress'; import { isHttpUnauthorized } from '../utils/isHttpUnauthorized'; import { parseUserFacingError } from '../utils/parseUserFacingError'; import { useRampsOrders } from './useRampsOrders'; -import { closeSession, getSession } from '../headless/sessionRegistry'; +import { + closeSession, + failSession, + getSession, +} from '../headless/sessionRegistry'; interface RampStackParamList { /** `baseRouteParams` (e.g. `headlessSessionId`) are merged onto this route in resets — see `navigateToVerifyIdentityCallback`. */ @@ -69,9 +73,14 @@ interface RampStackParamList { } class LimitExceededError extends Error { - constructor(message: string) { + readonly headlessBuyErrorCode = 'LIMIT_EXCEEDED'; + + readonly details?: Record; + + constructor(message: string, details?: Record) { super(message); this.name = 'LimitExceededError'; + this.details = details; } } @@ -194,6 +203,11 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { period: 'daily', remaining: `${dailyLimit} ${fiatCurrency}`, }), + { + period: 'daily', + remaining: dailyLimit, + currency: fiatCurrency, + }, ); } @@ -203,6 +217,11 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { period: 'monthly', remaining: `${monthlyLimit} ${fiatCurrency}`, }), + { + period: 'monthly', + remaining: monthlyLimit, + currency: fiatCurrency, + }, ); } @@ -212,6 +231,11 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { period: 'yearly', remaining: `${yearlyLimit} ${fiatCurrency}`, }), + { + period: 'yearly', + remaining: yearlyLimit, + currency: fiatCurrency, + }, ); } } catch (error) { @@ -435,6 +459,12 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { Logger.error(error as Error, { message: 'useTransakRouting: Failed to process order after checkout', }); + if (failSession(headlessSessionId, error)) { + // @ts-expect-error `pop` exists on the parent stack navigator at + // runtime but is not surfaced on the generic `NavigationProp` + // type returned by `getParent()`. + navigation.getParent()?.pop(); + } } }, [ @@ -446,6 +476,7 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { regionIsoCode, trackEvent, headlessSessionId, + navigation, ], ); @@ -564,6 +595,13 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { paymentDetails: depositOrder.paymentDetails, }); + if (getSession(headlessSessionId)) { + navigateToOrderProcessingCallback({ + orderId: rampsOrder.providerOrderId, + }); + return true; + } + showV2OrderToast({ orderId: rampsOrder.providerOrderId, cryptocurrency: rampsOrder.cryptoCurrency?.symbol ?? '', @@ -600,6 +638,9 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { } return true; } catch (error) { + if (error instanceof LimitExceededError) { + throw error; + } throw new Error( parseUserFacingError( error, @@ -723,6 +764,8 @@ export const useTransakRouting = (config?: UseTransakRoutingConfig) => { addOrder, refreshOrder, navigateToBankDetailsCallback, + navigateToOrderProcessingCallback, + headlessSessionId, navigateToWebviewModalCallback, navigateToKycProcessingCallback, submitPurposeOfUsageForm, diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts index c218339c0013..ce6d77de59ca 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts @@ -23,6 +23,8 @@ jest.mock('@metamask/design-system-react-native', () => ({ })); jest.mock('../../../../../../locales/i18n', () => ({ + __esModule: true, + default: { locale: 'en-US' }, strings: jest.fn((key: string, params?: Record) => params ? `${key}:${JSON.stringify(params)}` : key, ), diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts index cc3c99218d03..9c665255eb33 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts @@ -4,7 +4,8 @@ import { type CampaignDto, type CampaignStatus, } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { strings } from '../../../../../../locales/i18n'; +import I18n, { strings } from '../../../../../../locales/i18n'; +import { getIntlDateTimeFormatter } from '../../../../../util/intl'; /** * Set of campaign types that have full UI support (details view, opt-in, etc.) @@ -53,32 +54,18 @@ export function getCampaignStatus(campaign: CampaignDto): CampaignStatus { return 'complete'; } -const MONTHS = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', -]; - /** - * Formats a date for display in campaign tiles. + * Formats a date for display in campaign tiles (localized month and day). * * @param date - The date to format - * @returns Formatted date string (e.g., "March 15") + * @param locale - BCP 47 locale; defaults to the app locale + * @returns Formatted date string (e.g., "March 15" in en-US) */ -function formatCampaignDate(date: Date): string { - const month = MONTHS[date.getMonth()]; - const day = date.getDate(); - - return `${month} ${day}`; +function formatCampaignDate(date: Date, locale: string = I18n.locale): string { + return getIntlDateTimeFormatter(locale, { + month: 'long', + day: 'numeric', + }).format(date); } /** diff --git a/app/components/UI/Rewards/components/Campaigns/OndoActivityRow.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoActivityRow.test.tsx index 18be482b8488..38d0b2dd162b 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoActivityRow.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoActivityRow.test.tsx @@ -104,6 +104,18 @@ describe('OndoActivityRow', () => { expect(getByText('—')).toBeDefined(); }); + it('renders rebalance entry USD without a plus sign for positive amounts', () => { + const { getByText } = render( + , + ); + + expect(getByText('Rebalance')).toBeDefined(); + // formatUsd (rebalance) has no '+'; deposit/withdraw still use mocked formatSignedUsd + expect(getByText(/^\$5,000/)).toBeDefined(); + }); + it('renders external outflow entry with shortened destAddress', () => { const { getByText } = render( = { const tokenLabel = (token: ActivityTokenDto): string => token.tokenSymbol || token.tokenName; +/** Rebalance USD is not portfolio P&L; omit the '+' used for signed inflows/outflows. */ +const formatActivityUsd = ( + usdAmount: OndoGmActivityEntryDto['usdAmount'], + entryType: ActivityEntryType, +): string => { + if (entryType !== 'REBALANCE') { + return formatSignedUsd(usdAmount); + } + if (usdAmount === null) { + return '—'; + } + const num = typeof usdAmount === 'number' ? usdAmount : parseFloat(usdAmount); + if (Number.isNaN(num)) { + return '—'; + } + return formatUsd(usdAmount); +}; + interface OndoActivityRowProps { entry: OndoGmActivityEntryDto; timeOnly?: boolean; @@ -123,7 +142,7 @@ const OndoActivityRow: React.FC = ({ {label} - {formatSignedUsd(entry.usdAmount)} + {formatActivityUsd(entry.usdAmount, entryType)} diff --git a/app/components/UI/TokenDetails/hooks/useTokenSecurityData.test.ts b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.test.ts index 34dab03dff2a..c1cab8323ace 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenSecurityData.test.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.test.ts @@ -109,6 +109,42 @@ describe('useTokenSecurityData', () => { expect(result.current.securityData).toBeNull(); }); + it('ignores prefetchedData with wrong shape and fetches instead', async () => { + const assetId = 'eip155:1/erc20:0x1234' as CaipAssetType; + mockFetchTokenAssets.mockResolvedValue([ + { + assetId, + name: 'Test Token', + symbol: 'TEST', + decimals: 18, + securityData: mockSecurityData, + }, + ]); + + // Bridge SecurityData shape: { type: "Verified" } — missing resultType + const wrongShapedData = { + type: 'Verified', + } as unknown as TokenSecurityData; + + const { result } = renderHook(() => + useTokenSecurityData({ + assetId, + prefetchedData: wrongShapedData, + }), + ); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockFetchTokenAssets).toHaveBeenCalledWith([assetId], { + includeTokenSecurityData: true, + }); + expect(result.current.securityData).toBe(mockSecurityData); + }); + it('does not fetch when assetId is null', () => { const { result } = renderHook(() => useTokenSecurityData({ assetId: null }), diff --git a/app/components/UI/TokenDetails/hooks/useTokenSecurityData.ts b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.ts index 177878838f69..9f2398b8311b 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenSecurityData.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenSecurityData.ts @@ -18,10 +18,20 @@ interface UseTokenSecurityDataResult { error: Error | null; } +const isValidTokenSecurityData = (data: unknown): data is TokenSecurityData => + data != null && + typeof data === 'object' && + typeof (data as TokenSecurityData).resultType === 'string' && + Array.isArray((data as TokenSecurityData).features); + export const useTokenSecurityData = ({ assetId, - prefetchedData, + prefetchedData: rawPrefetchedData, }: UseTokenSecurityDataOpts): UseTokenSecurityDataResult => { + const prefetchedData = isValidTokenSecurityData(rawPrefetchedData) + ? rawPrefetchedData + : undefined; + const [securityData, setSecurityData] = useState( prefetchedData ?? null, ); diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx index 1d6ba2184653..efc92ff877ae 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx @@ -72,6 +72,11 @@ interface TrendingTokenRowItemProps { * asset details screen (including network-add logic and analytics tracking). */ onPress?: (token: TrendingAsset) => void; + /** + * Called synchronously before the card's press handler fires. + * Useful for injecting analytics without overriding navigation. + */ + onCardPress?: () => void; /** * When the same token row appears in multiple Explore sections, set this to keep * `testID` (and E2E selectors) unique per instance. @@ -126,6 +131,7 @@ const TrendingTokenRowItem = ({ tokenDetailsSource = TokenDetailsSource.Trending, transactionActiveAbTests, onPress, + onCardPress, testIdInstanceKey, }: TrendingTokenRowItemProps) => { const { styles } = useStyles(styleSheet, {}); @@ -165,12 +171,13 @@ const TrendingTokenRowItem = ({ }); const handlePress = useCallback(async () => { + onCardPress?.(); if (onPress) { onPress(token); return; } await defaultOnPress(); - }, [onPress, token, defaultOnPress]); + }, [onPress, onCardPress, token, defaultOnPress]); const rowTestId = testIdInstanceKey ? `trending-token-row-item-${testIdInstanceKey}-${token.assetId}` diff --git a/app/components/Views/AccountSelector/AccountSelector.styles.ts b/app/components/Views/AccountSelector/AccountSelector.styles.ts deleted file mode 100644 index f2566f57c3e7..000000000000 --- a/app/components/Views/AccountSelector/AccountSelector.styles.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { Theme } from '../../../util/theme/models'; -import { colors as importedColors } from '../../../styles/common'; - -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - const { colors } = theme; - - return StyleSheet.create({ - accountSelectorFooterContent: { - paddingHorizontal: 16, - paddingTop: 24, - // Extra space above safe-area inset so the footer actions are not flush with the screen edge - paddingBottom: 20, - }, - backdrop: { - ...StyleSheet.absoluteFillObject, - backgroundColor: colors.overlay.default, - }, - keyboardAvoidingView: { - flex: 1, - backgroundColor: importedColors.transparent, - }, - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - addWalletModalContainer: { - flex: 1, - backgroundColor: colors.background.default, - }, - accountSelectorFooter: { - flexDirection: 'row', - }, - footerButton: { - flex: 1, - }, - footerButtonSubsequent: { - flex: 1, - marginLeft: 16, - }, - }); -}; - -export default styleSheet; diff --git a/app/components/Views/AccountSelector/AccountSelector.tsx b/app/components/Views/AccountSelector/AccountSelector.tsx index de18e3297b0d..f4c18b904928 100644 --- a/app/components/Views/AccountSelector/AccountSelector.tsx +++ b/app/components/Views/AccountSelector/AccountSelector.tsx @@ -8,24 +8,29 @@ import React, { useState, } from 'react'; import { - KeyboardAvoidingView, - Platform, ActivityIndicator, + KeyboardAvoidingView, Modal, - useWindowDimensions, - View, + Platform, } from 'react-native'; import { StackActions, useNavigation } from '@react-navigation/native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - withSpring, - runOnJS, - useDerivedValue, - interpolate, -} from 'react-native-reanimated'; +import { + useSafeAreaFrame, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Button, + ButtonSize, + ButtonVariant, + FontWeight, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; // External dependencies. import MultichainAccountSelectorList from '../../../component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList'; @@ -36,13 +41,6 @@ import { store } from '../../../store'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { strings } from '../../../../locales/i18n'; import { useAccounts } from '../../hooks/useAccounts'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../component-library/components/Buttons/Button'; -import { TextVariant } from '../../../component-library/components/Texts/Text'; -import Text from '../../../component-library/components/Texts/Text/Text'; import AddAccountActions from '../AddAccountActions'; import { AccountListBottomSheetSelectorsIDs } from './AccountListBottomSheet.testIds'; import { CommonSelectorsIDs } from '../../../util/Common.testIds'; @@ -50,12 +48,10 @@ import { selectSelectedAccountGroup } from '../../../selectors/multichainAccount import { AccountGroupObject } from '@metamask/account-tree-controller'; // Internal dependencies. -import { useStyles } from '../../../component-library/hooks'; import { AccountSelectorProps, AccountSelectorScreens, } from './AccountSelector.types'; -import styleSheet from './AccountSelector.styles'; import { useDispatch, useSelector } from 'react-redux'; import { setReloadAccounts } from '../../../actions/accounts'; import { RootState } from '../../../reducers'; @@ -67,24 +63,16 @@ import { trace, } from '../../../util/trace'; import { getTraceTags } from '../../../util/sentry/tags'; -import { ButtonProps } from '../../../component-library/components/Buttons/Button/Button.types'; import { useSyncSRPs } from '../../hooks/useSyncSRPs'; import { useAccountsOperationsLoadingStates } from '../../../util/accounts/useAccountsOperationsLoadingStates'; -import { Box } from '../../UI/Box/Box'; -import { - AlignItems, - FlexDirection, - JustifyContent, -} from '../../UI/Box/box.types'; -import { AnimationDuration } from '../../../component-library/constants/animation.constants'; import Routes from '../../../constants/navigation/Routes'; const AccountSelector = ({ route }: AccountSelectorProps) => { - const { styles } = useStyles(styleSheet, {}); + const tw = useTailwind(); const dispatch = useDispatch(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { width: screenWidth } = useWindowDimensions(); + const { y: frameY } = useSafeAreaFrame(); const { trackEvent, createEventBuilder } = useAnalytics(); const routeParams = useMemo(() => route?.params, [route?.params]); @@ -148,18 +136,14 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { } }, [navigation, shouldRedirectToAddWallet]); - // Tracing for the account list rendering: const isAccountSelector = useMemo( () => screen === AccountSelectorScreens.AccountSelector, [screen], ); - const translateX = useSharedValue(screenWidth); - - // Backdrop opacity animation - fades in as screen slides in from right - const backdropOpacity = useDerivedValue(() => - interpolate(translateX.value, [screenWidth, 0], [0, 0.5]), - ); + const handleClose = useCallback(() => { + navigation.goBack(); + }, [navigation]); useEffect(() => { if (reloadAccounts) { @@ -167,45 +151,38 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { } }, [dispatch, reloadAccounts]); + // Tracing for the account list: start at layout flush, end after paint (useEffect). useLayoutEffect(() => { - if (!isAccountSelector) return; - - const onAnimationComplete = () => { + if (!isAccountSelector) { + return undefined; + } + trace({ + name: TraceName.ShowAccountList, + op: TraceOperation.AccountUi, + tags: getTraceTags(store.getState()), + }); + return () => { endTrace({ name: TraceName.ShowAccountList, }); }; - - translateX.value = withSpring( - 0, - { - damping: 20, - stiffness: 500, - mass: 0.3, - }, - () => runOnJS(onAnimationComplete)(), - ); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAccountSelector]); - const closeModal = useCallback(() => { - const onCloseComplete = () => { - navigation.goBack(); - }; - - translateX.value = withTiming( - screenWidth, - { duration: AnimationDuration.Fast }, - () => runOnJS(onCloseComplete)(), - ); - }, [translateX, navigation, screenWidth]); + useEffect(() => { + if (!isAccountSelector) { + return; + } + endTrace({ + name: TraceName.ShowAccountList, + }); + }, [isAccountSelector]); const _onSelectMultichainAccount = useCallback( (accountGroup: AccountGroupObject) => { Engine.context.AccountTreeController.setSelectedAccountGroup( accountGroup.id, ); - closeModal(); + handleClose(); trackEvent( createEventBuilder(MetaMetricsEvents.SWITCHED_ACCOUNT) @@ -216,7 +193,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { .build(), ); }, - [accounts?.length, trackEvent, createEventBuilder, closeModal], + [accounts?.length, trackEvent, createEventBuilder, handleClose], ); const handleAddAccount = useCallback(() => { @@ -227,43 +204,6 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { setScreen(AccountSelectorScreens.AccountSelector); }, []); - // Tracing for the account list rendering: - useEffect(() => { - if (isAccountSelector) { - trace({ - name: TraceName.ShowAccountList, - op: TraceOperation.AccountUi, - tags: getTraceTags(store.getState()), - }); - // Trace ends in animation callback - } - }, [isAccountSelector]); - - const addAccountButtonProps: ButtonProps[] = useMemo( - () => [ - { - variant: ButtonVariants.Secondary, - isDisabled: isAccountSyncingInProgress, - label: ( - - {isAccountSyncingInProgress && } - {buttonLabel} - - ), - size: ButtonSize.Lg, - width: ButtonWidthTypes.Full, - onPress: handleAddAccount, - testID: AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ADD_BUTTON_ID, - }, - ], - [handleAddAccount, buttonLabel, isAccountSyncingInProgress], - ); - const renderAccountSelector = useCallback( () => ( @@ -277,24 +217,38 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { /> ) : null} {!disableAddAccountButton && ( - - {addAccountButtonProps.map((buttonProp, index) => ( - + )} ), @@ -302,11 +256,9 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { selectedAccountGroup, _onSelectMultichainAccount, disableAddAccountButton, - addAccountButtonProps, - styles.accountSelectorFooterContent, - styles.accountSelectorFooter, - styles.footerButton, - styles.footerButtonSubsequent, + handleAddAccount, + buttonLabel, + isAccountSyncingInProgress, ], ); @@ -320,14 +272,6 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { [handleBackToSelector], ); - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: translateX.value }], - })); - - const backdropStyle = useAnimatedStyle(() => ({ - opacity: backdropOpacity.value, - })); - const showAddWalletModal = screen === AccountSelectorScreens.AddAccountActions || screen === AccountSelectorScreens.MultichainAddWalletActions; @@ -338,31 +282,28 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { return ( <> - - {renderAccountSelector()} - + { onRequestClose={handleBackToSelector} > {showAddWalletModal ? ( - { {screen === AccountSelectorScreens.AddAccountActions ? renderAddAccountActions() : renderMultichainAddWalletActions()} - + ) : null} diff --git a/app/components/Views/ActivityView/index.js b/app/components/Views/ActivityView/index.js index 02f7a4abe07f..67458ec491ab 100644 --- a/app/components/Views/ActivityView/index.js +++ b/app/components/Views/ActivityView/index.js @@ -1,6 +1,6 @@ import { useFocusEffect, useNavigation } from '@react-navigation/native'; -import React, { useCallback, useMemo, useState } from 'react'; -import { StyleSheet, View } from 'react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { BackHandler, StyleSheet, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useSelector } from 'react-redux'; import { WalletViewSelectorsIDs } from '../Wallet/WalletView.testIds'; @@ -23,11 +23,13 @@ import { KnownCaipNamespace } from '@metamask/utils'; import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; import { selectChainId } from '../../../selectors/networkController'; import { selectNetworkName } from '../../../selectors/networkInfos'; +import Routes from '../../../constants/navigation/Routes'; import { useParams } from '../../../util/navigation/navUtils'; import { getNetworkImageSource } from '../../../util/networks'; import { useTheme } from '../../../util/theme'; import { TabsList } from '../../../component-library/components-temp/Tabs'; import { createNetworkManagerNavDetails } from '../../UI/NetworkManager'; +import { selectMoneyHomeScreenEnabledFlag } from '../../UI/Money/selectors/featureFlags'; import { selectPerpsEnabledFlag } from '../../UI/Perps'; import { selectPredictEnabledFlag } from '../../UI/Predict/selectors/featureFlags'; import PredictTransactionsView from '../../UI/Predict/views/PredictTransactionsView/PredictTransactionsView'; @@ -106,6 +108,10 @@ const ActivityView = () => { const currentNetworkName = getNetworkInfo(0)?.networkName; + const isMoneyHomeScreenEnabled = useSelector( + selectMoneyHomeScreenEnabledFlag, + ); + const params = useParams(); const perpsEnabledFlag = useSelector(selectPerpsEnabledFlag); const isPerpsEnabled = useMemo( @@ -123,13 +129,34 @@ const ActivityView = () => { navigation.navigate(...createNetworkManagerNavDetails({})); }; + // Prevent back button returning to confirmation screen in case that users are redirected after a successful transaction. + const handleNavigateHome = useCallback(() => { + navigation.navigate(Routes.HOME_TABS); + }, [navigation]); + const handleBackPress = useCallback(() => { - if (navigation.canGoBack()) { + if (isMoneyHomeScreenEnabled) { + handleNavigateHome(); + } else if (navigation.canGoBack()) { navigation.goBack(); } - }, [navigation]); + }, [isMoneyHomeScreenEnabled, navigation, handleNavigateHome]); + + useEffect(() => { + if (!isMoneyHomeScreenEnabled) return; + + const subscription = BackHandler.addEventListener( + 'hardwareBackPress', + () => { + handleNavigateHome(); + return true; + }, + ); + + return () => subscription.remove(); + }, [navigation, isMoneyHomeScreenEnabled, handleNavigateHome]); - const showBackButton = params.showBackButton || false; + const showBackButton = params.showBackButton || isMoneyHomeScreenEnabled; // Calculate dynamic tab indices based on which tabs are enabled // Tab order: Transactions (0), Orders (1), Perps (conditional), Predict (conditional) diff --git a/app/components/Views/ActivityView/index.test.tsx b/app/components/Views/ActivityView/index.test.tsx index c1dc00d2289d..31e0ff0d4feb 100644 --- a/app/components/Views/ActivityView/index.test.tsx +++ b/app/components/Views/ActivityView/index.test.tsx @@ -1,14 +1,21 @@ import React from 'react'; import ActivityView from '.'; +import { BackHandler } from 'react-native'; import { backgroundState } from '../../../util/test/initial-root-state'; import renderWithProvider from '../../../util/test/renderWithProvider'; import { createStackNavigator } from '@react-navigation/stack'; -import { fireEvent } from '@testing-library/react-native'; +import { cleanup, fireEvent } from '@testing-library/react-native'; // eslint-disable-next-line import-x/no-namespace import * as networkManagerUtils from '../../UI/NetworkManager'; import { useCurrentNetworkInfo } from '../../hooks/useCurrentNetworkInfo'; import { ActivitiesViewSelectorsIDs } from './ActivitiesView.testIds'; import { WalletViewSelectorsIDs } from '../Wallet/WalletView.testIds'; +import Routes from '../../../constants/navigation/Routes'; + +let mockMoneyHomeScreenEnabled = false; +jest.mock('../../UI/Money/selectors/featureFlags', () => ({ + selectMoneyHomeScreenEnabledFlag: jest.fn(() => mockMoneyHomeScreenEnabled), +})); // Mock the Perps feature flag selector - will be controlled per test let mockPerpsEnabled = false; @@ -236,6 +243,8 @@ describe('ActivityView', () => { const mockUseCurrentNetworkInfo = useCurrentNetworkInfo as jest.MockedFunction; + let backHandlerSpy: jest.SpyInstance; + const defaultNetworkInfo = { enabledNetworks: [ { chainId: '0x1', enabled: true }, @@ -266,8 +275,14 @@ describe('ActivityView', () => { beforeEach(() => { jest.clearAllMocks(); + backHandlerSpy = jest + .spyOn(BackHandler, 'addEventListener') + .mockReturnValue({ remove: jest.fn() } as unknown as ReturnType< + typeof BackHandler.addEventListener + >); mockUseCurrentNetworkInfo.mockReturnValue(defaultNetworkInfo); mockIsEvmSelected = true; + mockMoneyHomeScreenEnabled = false; mockPerpsEnabled = false; mockPredictEnabled = false; mockAreAllEvmPopularNetworksEnabled = false; @@ -275,6 +290,11 @@ describe('ActivityView', () => { mockRoute.params = {}; }); + afterEach(() => { + cleanup(); + backHandlerSpy.mockRestore(); + }); + describe('Network Manager Integration', () => { beforeEach(() => { jest.clearAllMocks(); @@ -403,6 +423,80 @@ describe('ActivityView', () => { expect(mockNavigation.goBack).not.toHaveBeenCalled(); }); + + it('displays back button when Money home screen flag is enabled without showBackButton param', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = {}; + + const { getByTestId } = renderComponent(mockInitialState); + + expect(getByTestId('activity-view-back-button')).toBeOnTheScreen(); + }); + + it('calls navigation.navigate with HOME_TABS on back button press when Money flag is enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = {}; + const { getByTestId } = renderComponent(mockInitialState); + + fireEvent.press(getByTestId('activity-view-back-button')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.HOME_TABS); + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + }); + + it('calls navigation.navigate with HOME_TABS and not goBack when both flag and showBackButton param are true', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = { showBackButton: true }; + const { getByTestId } = renderComponent(mockInitialState); + + fireEvent.press(getByTestId('activity-view-back-button')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.HOME_TABS); + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + }); + + it('registers hardwareBackPress handler when Money flag is enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = {}; + + renderComponent(mockInitialState); + + expect(BackHandler.addEventListener).toHaveBeenCalledWith( + 'hardwareBackPress', + expect.any(Function), + ); + }); + + it('navigates to HOME_TABS when hardwareBackPress fires with Money flag enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = {}; + renderComponent(mockInitialState); + const [[, handler]] = (BackHandler.addEventListener as jest.Mock).mock + .calls; + + const result = handler(); + + expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.HOME_TABS); + expect(result).toBe(true); + }); + + it('does not navigate to HOME_TABS on hardwareBackPress when Money flag is disabled', () => { + mockMoneyHomeScreenEnabled = false; + mockRoute.params = {}; + + renderComponent(mockInitialState); + + const hardwareBackPressCalls = ( + BackHandler.addEventListener as jest.Mock + ).mock.calls.filter(([event]: [string]) => event === 'hardwareBackPress'); + hardwareBackPressCalls.forEach(([, handler]: [string, () => boolean]) => + handler(), + ); + + expect(mockNavigation.navigate).not.toHaveBeenCalledWith( + Routes.HOME_TABS, + ); + }); }); describe('header and SafeAreaView', () => { @@ -463,6 +557,18 @@ describe('ActivityView', () => { queryByTestId(ActivitiesViewSelectorsIDs.HEADER_COMPACT_STANDARD), ).toBeNull(); }); + + it('renders HeaderCompactStandard when Money home screen flag is enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = {}; + + const { getByTestId, queryByTestId } = renderComponent(mockInitialState); + + expect( + getByTestId(ActivitiesViewSelectorsIDs.HEADER_COMPACT_STANDARD), + ).toBeOnTheScreen(); + expect(queryByTestId(ActivitiesViewSelectorsIDs.HEADER_ROOT)).toBeNull(); + }); }); describe('Perps tab', () => { diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx index 51afe896083c..2e72d4243953 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.test.tsx @@ -314,6 +314,41 @@ describe('PredictionsSection', () => { }); }); + it('renders the current active position values from the hook data', async () => { + mockUsePredictPositionsForHomepage.mockImplementation( + ({ + claimable = false, + }: { maxPositions?: number; claimable?: boolean } = {}) => ({ + positions: claimable + ? [] + : [ + { + ...mockActivePositions[0], + currentValue: 99, + percentPnl: 890, + }, + mockActivePositions[1], + ], + isLoading: false, + error: null, + totalClaimableValue: 0, + refetch: jest.fn(), + }), + ); + + renderWithProvider( + , + ); + + await waitFor(() => { + expect(screen.getByText('Test Position 1')).toBeOnTheScreen(); + }); + + expect(screen.getByText('$99')).toBeOnTheScreen(); + expect(screen.getByText('890%')).toBeOnTheScreen(); + expect(screen.queryByText('$12')).not.toBeOnTheScreen(); + }); + it('shows position skeletons when loading positions', () => { mockUsePredictPositionsForHomepage.mockImplementation( ({ diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.test.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.test.ts index b70836a0ed35..87a7e68db8f3 100644 --- a/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.test.ts +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.test.ts @@ -3,6 +3,7 @@ import { usePredictPositionsForHomepage } from './usePredictPositionsForHomepage import type { PredictPosition } from '../../../../../UI/Predict/types'; const mockRefetch = jest.fn().mockResolvedValue(undefined); +const mockUsePredictPositions = jest.fn(); let mockUsePredictPositionsReturn: { data: PredictPosition[] | undefined; isLoading: boolean; @@ -16,7 +17,12 @@ let mockUsePredictPositionsReturn: { }; jest.mock('../../../../../UI/Predict/hooks/usePredictPositions', () => ({ - usePredictPositions: () => mockUsePredictPositionsReturn, + usePredictPositions: ( + ...args: Parameters + ) => { + mockUsePredictPositions(...args); + return mockUsePredictPositionsReturn; + }, })); const createMockPosition = (id: string, currentValue = 12): PredictPosition => @@ -36,6 +42,7 @@ const createMockPosition = (id: string, currentValue = 12): PredictPosition => describe('usePredictPositionsForHomepage', () => { beforeEach(() => { jest.clearAllMocks(); + mockUsePredictPositions.mockClear(); mockUsePredictPositionsReturn = { data: [ createMockPosition('1'), @@ -157,6 +164,28 @@ describe('usePredictPositionsForHomepage', () => { expect(result.current.totalClaimableValue).toBe(0); }); + it('enables live updates for active positions', () => { + renderHook(() => usePredictPositionsForHomepage({ claimable: false })); + + expect(mockUsePredictPositions).toHaveBeenCalledWith( + expect.objectContaining({ + claimable: false, + livePriceUpdates: true, + }), + ); + }); + + it('disables live updates for claimable positions', () => { + renderHook(() => usePredictPositionsForHomepage({ claimable: true })); + + expect(mockUsePredictPositions).toHaveBeenCalledWith( + expect.objectContaining({ + claimable: true, + livePriceUpdates: false, + }), + ); + }); + it('treats undefined currentValue as 0 in totalClaimableValue sum', () => { mockUsePredictPositionsReturn.data = [ { diff --git a/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.ts b/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.ts index 8b47633ad2b4..2967e5343d0e 100644 --- a/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.ts +++ b/app/components/Views/Homepage/Sections/Predictions/hooks/usePredictPositionsForHomepage.ts @@ -32,6 +32,7 @@ export const usePredictPositionsForHomepage = ( const { data, isLoading, error, refetch } = usePredictPositions({ claimable, enabled, + livePriceUpdates: !claimable, }); const allPositions = useMemo(() => data ?? [], [data]); diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.styles.ts b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.styles.ts new file mode 100644 index 000000000000..7146d8bf41c5 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.styles.ts @@ -0,0 +1,21 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (_params: { theme: Theme }) => + StyleSheet.create({ + flex: { + flex: 1, + }, + gradient: { + position: 'absolute', + left: 0, + right: 0, + zIndex: 1, + pointerEvents: 'none', + }, + gradientFill: { + flex: 1, + }, + }); + +export default styleSheet; diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.test.tsx b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.test.tsx new file mode 100644 index 000000000000..5e9d672772ea --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.test.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import { InteractionManager, Text } from 'react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import type { SectionRefreshHandle } from '../../types'; +import HomepageDiscoveryTabs from './HomepageDiscoveryTabs'; + +jest.mock('react-native-reanimated', () => { + const Reanimated = jest.requireActual('react-native-reanimated/mock'); + Reanimated.default.ScrollView = jest.requireActual('react-native').ScrollView; + return Reanimated; +}); + +jest + .spyOn(InteractionManager, 'runAfterInteractions') + .mockImplementation((task) => { + if (task == null) { + return { cancel: jest.fn(), then: jest.fn(), done: jest.fn() }; + } + if (typeof task === 'function') { + task(); + } else { + void task.gen(); + } + return { cancel: jest.fn(), then: jest.fn(), done: jest.fn() }; + }); + +jest.mock('../../Homepage', () => { + const ReactLib = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ReactLib.forwardRef( + ( + _props: Record, + ref: React.ForwardedRef, + ) => { + ReactLib.useImperativeHandle(ref, () => ({ + refresh: jest.fn(async () => undefined), + })); + return ReactLib.createElement(View, { testID: 'homepage' }); + }, + ), + }; +}); + +jest.mock('../../../../UI/Perps/Views/PerpsHomeView/PerpsHomeView', () => { + const { View } = jest.requireActual('react-native'); + const ReactLib = jest.requireActual('react'); + return function MockPerpsHomeView({ + tabEnterCallbackRef, + }: { + tabEnterCallbackRef?: React.MutableRefObject<(() => void) | null>; + }) { + if (tabEnterCallbackRef) tabEnterCallbackRef.current = jest.fn(); + return ReactLib.createElement(View, { testID: 'perps-home-view' }); + }; +}); + +jest.mock('../../../../UI/Predict/views/PredictFeed', () => { + const { View } = jest.requireActual('react-native'); + const ReactLib = jest.requireActual('react'); + return function MockPredictFeed() { + return ReactLib.createElement(View, { testID: 'predict-feed' }); + }; +}); + +jest.mock('../../../../UI/Perps/providers/PerpsConnectionProvider', () => ({ + PerpsConnectionProvider: ({ children }: { children: React.ReactNode }) => + children, +})); + +jest.mock('../../../../UI/Perps/providers/PerpsStreamManager', () => ({ + PerpsStreamProvider: ({ children }: { children: React.ReactNode }) => + children, +})); + +jest.mock('../../../../UI/Predict/contexts', () => ({ + PredictPreviewSheetProvider: ({ children }: { children: React.ReactNode }) => + children, +})); + +jest.mock('../../../../UI/Predict/hooks/useDiscoveryScrollManager', () => ({ + useDiscoveryScrollManager: jest.fn(() => ({ + scrollHandler: jest.fn(), + onTabEnter: jest.fn(), + headerHidden: false, + })), +})); + +jest.mock('../../../../../util/theme', () => ({ + useTheme: () => ({ themeAppearance: 'dark', colors: {} }), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: () => ({}) }), +})); + +jest.mock('react-native-linear-gradient', () => 'LinearGradient'); + +const pressTab = async (label: string) => { + await act(async () => { + fireEvent.press(screen.getAllByText(label)[0]); + }); +}; + +const renderComponent = (props = {}) => + render(); + +describe('HomepageDiscoveryTabs', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('initial render', () => { + it('renders the Portfolio tab bar label', () => { + renderComponent(); + expect(screen.getByText('Portfolio')).toBeOnTheScreen(); + }); + + it('renders the Perpetuals tab bar label', () => { + renderComponent(); + expect(screen.getByText('Perpetuals')).toBeOnTheScreen(); + }); + + it('renders the Predictions tab bar label', () => { + renderComponent(); + expect(screen.getByText('Predictions')).toBeOnTheScreen(); + }); + + it('shows Portfolio content on initial mount', () => { + renderComponent(); + expect(screen.getByTestId('homepage')).toBeOnTheScreen(); + }); + }); + + describe('tab switching', () => { + it('shows Perpetuals content after pressing the Perpetuals tab', async () => { + renderComponent(); + await pressTab('Perpetuals'); + expect(screen.getByTestId('perps-home-view')).toBeOnTheScreen(); + }); + + it('shows Predictions content after pressing the Predictions tab', async () => { + renderComponent(); + await pressTab('Predictions'); + expect(screen.getByTestId('predict-feed')).toBeOnTheScreen(); + }); + + it('returns to Portfolio content after switching back', async () => { + renderComponent(); + await pressTab('Perpetuals'); + await pressTab('Portfolio'); + expect(screen.getByTestId('homepage')).toBeOnTheScreen(); + }); + }); + + describe('portfolioHeader prop', () => { + it('renders portfolioHeader inside the Portfolio tab', () => { + renderComponent({ + portfolioHeader: Header, + }); + expect(screen.getByTestId('portfolio-header')).toBeOnTheScreen(); + }); + + it('keeps portfolioHeader mounted but hidden when switching to Perpetuals', async () => { + renderComponent({ + portfolioHeader: Header, + }); + await pressTab('Perpetuals'); + // Portfolio tab is keepMounted — header stays in tree but is not accessible + // (pointerEvents="none" + display:none via twClassName="hidden") + expect(screen.queryByTestId('perps-home-view')).toBeOnTheScreen(); + }); + }); + + describe('ref / imperative handle', () => { + it('exposes a refresh method via ref', () => { + const ref = React.createRef<{ refresh: () => Promise }>(); + render(); + expect(typeof ref.current?.refresh).toBe('function'); + }); + + it('calling refresh does not throw', async () => { + const ref = React.createRef<{ refresh: () => Promise }>(); + render(); + await act(async () => { + await ref.current?.refresh(); + }); + }); + }); + + describe('walletHeaderOffset prop', () => { + it('renders without throwing when walletHeaderOffset is provided', () => { + expect(() => renderComponent({ walletHeaderOffset: 100 })).not.toThrow(); + }); + + it('renders without throwing when walletHeaderOffset is 0', () => { + expect(() => renderComponent({ walletHeaderOffset: 0 })).not.toThrow(); + }); + }); +}); diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx new file mode 100644 index 000000000000..51c2f1ff9bce --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/HomepageDiscoveryTabs.tsx @@ -0,0 +1,368 @@ +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useRef, +} from 'react'; +import { Animated, StyleSheet, View } from 'react-native'; +import Reanimated, { + SharedValue, + withTiming, + Easing, +} from 'react-native-reanimated'; +import LinearGradient from 'react-native-linear-gradient'; +import TabsIconList from '../../../../../component-library/components-temp/Tabs/TabsIconList/TabsIconList'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { TabsIconListRef } from '../../../../../component-library/components-temp/Tabs/TabsIconList/TabsIconList.types'; +import Homepage from '../../Homepage'; +import PerpsHomeView from '../../../../UI/Perps/Views/PerpsHomeView/PerpsHomeView'; +import PredictFeed from '../../../../UI/Predict/views/PredictFeed'; +import { PerpsConnectionProvider } from '../../../../UI/Perps/providers/PerpsConnectionProvider'; +import { PerpsStreamProvider } from '../../../../UI/Perps/providers/PerpsStreamManager'; +import { PredictPreviewSheetProvider } from '../../../../UI/Predict/contexts'; +import { SectionRefreshHandle } from '../../types'; +import { IconName } from '../../../../../component-library/components/Icons/Icon/Icon.types'; +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './HomepageDiscoveryTabs.styles'; +import { useDiscoveryScrollManager } from '../../../../UI/Predict/hooks/useDiscoveryScrollManager'; +import { useTheme } from '../../../../../util/theme'; +import { AppThemeKey } from '../../../../../util/theme/models'; +import { TabIconAnimationContext } from '../../../../../component-library/components-temp/Tabs/TabsIconTab/TabsIconAnimationContext'; + +// Tab indices — kept as a const so future tabs can be added without renumbering. +const TAB_INDEX = { + PORTFOLIO: 0, + PERPETUALS: 1, + PREDICTIONS: 2, +} as const; + +// Static per-tab gradient color stops. Keyed by TAB_INDEX so adding a new tab +// only requires adding an entry here. +// Design spec: linear-gradient(180deg, 0%, rgba(, 0) 100%) +const TAB_GRADIENT_COLORS: Record = { + [TAB_INDEX.PORTFOLIO]: [ + 'rgba(75, 80, 92, 0.9)', + 'rgba(75, 80, 92, 0.2)', + 'transparent', + ], + [TAB_INDEX.PERPETUALS]: [ + 'rgba(25, 0, 102, 0.9)', + 'rgba(25, 0, 102, 0.2)', + 'transparent', + ], + [TAB_INDEX.PREDICTIONS]: [ + 'rgba(61, 6, 95, 0.9)', + 'rgba(61, 6, 95, 0.2)', + 'transparent', + ], +}; + +/** + * Thin wrapper that exposes a `tabLabel` prop consumed by TabsList to build + * the tab bar. The children are rendered as the tab's content. + */ +interface DiscoveryTabViewProps { + tabLabel: string; + tabIcon?: IconName; + keepMounted?: boolean; + children?: React.ReactNode; +} + +const discoveryTabViewStyles = StyleSheet.create({ root: { flex: 1 } }); + +const DiscoveryTabView: React.FC = ({ children }) => ( + {children} +); + +export interface HomepageDiscoveryTabsProps { + /** + * Content rendered above the Homepage sections inside the Portfolio tab scroll. + * Receives AccountGroupBalance, AssetDetailsActions, and Carousel from Wallet. + */ + portfolioHeader?: React.ReactNode; + /** + * Forwarded to the Portfolio tab ScrollView — used by HomepageScrollContext + * pub/sub to notify scroll subscribers without triggering re-renders. + */ + onPortfolioScroll?: () => void; + /** + * RefreshControl element for pull-to-refresh on the Portfolio tab. + */ + refreshControl?: React.ReactElement; + /** + * Combined height of the wallet header + safe area top inset, used to + * position the gradient overlay so it bleeds up into the header area. + */ + walletHeaderOffset?: number; + /** + * Reanimated SharedValue controlling vertical translation of the wallet header. + * Updated from the scroll worklet so the header hides/shows on the native thread. + */ + walletHeaderTranslateY?: SharedValue; + /** + * Height of the wallet header — used to know how far to translate it off screen. + */ + walletHeaderHeight?: number; +} + +/** + * HomepageDiscoveryTabs + * + * Hub Page Navigational Discovery Tabs (coreMCU589AbtestHubPageDiscoveryTabs). + * + * Uses the design-system TabsList which renders the TabsBar at the top and + * lazy-mounts tab content via InteractionManager — screens only initialise + * when the user first visits that tab. + * + * Tabs: + * - Portfolio: scrollable homepage sections with balance header + * - Perpetuals: PerpsHomeView wrapped in connection + stream providers + * - Predictions: PredictFeed wrapped in preview sheet provider + */ +const HomepageDiscoveryTabs = forwardRef< + SectionRefreshHandle, + HomepageDiscoveryTabsProps +>( + ( + { + portfolioHeader, + onPortfolioScroll, + refreshControl, + walletHeaderOffset = 0, + walletHeaderTranslateY, + walletHeaderHeight = 0, + }, + ref, + ) => { + const tabsRef = useRef(null); + const homepageRef = useRef(null); + const perpsTabEnterRef = useRef<(() => void) | null>(null); + const tw = useTailwind(); + const { styles } = useStyles(styleSheet, {}); + const { themeAppearance } = useTheme(); + const isDarkMode = themeAppearance === AppThemeKey.dark; + // One Animated.Value per tab — pre-rendered at mount so no re-render is needed + // during a tab switch. Portfolio starts fully visible; others start at 0. + const tabGradientOpacities = useRef( + Object.keys(TAB_GRADIENT_COLORS).map( + (_, i) => new Animated.Value(i === TAB_INDEX.PORTFOLIO ? 1 : 0), + ), + ).current; + const activeTabIndexRef = useRef(TAB_INDEX.PORTFOLIO); + + // 0 = icons expanded (header visible), 1 = icons collapsed (header hidden) + const iconCollapseAnim = useRef(new Animated.Value(0)).current; + // Ref so the animated reaction closure always calls the latest animation starter + const iconCollapseAnimRef = useRef(iconCollapseAnim); + + // Drives TabsBar height collapse on the Predictions tab only (useNativeDriver: false) + const tabBarCollapseAnim = useRef(new Animated.Value(0)).current; + const tabBarCollapseAnimRef = useRef(tabBarCollapseAnim); + + // Triggered directly from the scroll worklet via onHeaderHiddenChange — + // fires in the same frame as the hide/show decision, not based on position. + const animateIcons = useCallback((hidden: boolean) => { + const toValue = hidden ? 1 : 0; + const duration = hidden ? 300 : 250; + + Animated.timing(iconCollapseAnimRef.current, { + toValue, + duration, + useNativeDriver: true, + }).start(); + + if (activeTabIndexRef.current === TAB_INDEX.PREDICTIONS) { + Animated.timing(tabBarCollapseAnimRef.current, { + toValue, + duration, + useNativeDriver: false, + }).start(); + } + }, []); + + const { scrollHandler, onTabEnter: portfolioOnTabEnter } = + useDiscoveryScrollManager({ + walletHeaderHeight, + walletHeaderTranslateY, + onPortfolioScroll, + onHeaderHiddenChange: animateIcons, + }); + + useImperativeHandle(ref, () => ({ + refresh: async () => { + await homepageRef.current?.refresh(); + }, + })); + + const handleChangeTab = useCallback( + ({ i }: { i: number }) => { + const prevIndex = activeTabIndexRef.current; + activeTabIndexRef.current = i; + + // Restore each tab's own header state on entry. + // Predictions has no scroll manager so we always show the header. + if (i === TAB_INDEX.PORTFOLIO) { + portfolioOnTabEnter(); + } else if (i === TAB_INDEX.PERPETUALS) { + if (perpsTabEnterRef.current) { + perpsTabEnterRef.current(); + } else { + // First visit — Perps not mounted yet so ref is null. It will mount + // at the top of scroll, so show the header/icons immediately. + walletHeaderTranslateY && + (walletHeaderTranslateY.value = withTiming(0, { + duration: 250, + easing: Easing.out(Easing.cubic), + })); + Animated.timing(iconCollapseAnimRef.current, { + toValue: 0, + duration: 250, + useNativeDriver: true, + }).start(); + } + } else { + // Predictions has no scroll manager — always show header + icons on entry. + walletHeaderTranslateY && + (walletHeaderTranslateY.value = withTiming(0, { + duration: 250, + easing: Easing.out(Easing.cubic), + })); + Animated.timing(iconCollapseAnimRef.current, { + toValue: 0, + duration: 250, + useNativeDriver: true, + }).start(); + } + + // Reset tab bar collapse when leaving Predictions + if ( + prevIndex === TAB_INDEX.PREDICTIONS && + i !== TAB_INDEX.PREDICTIONS + ) { + Animated.timing(tabBarCollapseAnimRef.current, { + toValue: 0, + duration: 250, + useNativeDriver: false, + }).start(); + } + + if (prevIndex !== i) { + // Snap outgoing to 1 and incoming to 0 before animating, in case a + // previous transition was interrupted mid-flight. + tabGradientOpacities[prevIndex].setValue(1); + tabGradientOpacities[i].setValue(0); + + Animated.parallel([ + Animated.timing(tabGradientOpacities[prevIndex], { + toValue: 0, + duration: 350, + useNativeDriver: true, + }), + Animated.timing(tabGradientOpacities[i], { + toValue: 1, + duration: 350, + useNativeDriver: true, + }), + ]).start(); + } + }, + [tabGradientOpacities, portfolioOnTabEnter, walletHeaderTranslateY], + ); + + return ( + + + + + + {portfolioHeader} + + + + + + + + + + + + + + + + + + + + {/* Gradient overlay — dark mode only. One layer per tab, each always mounted + with fixed colors. Crossfade is pure opacity animation on the native thread — + no state update or unmount/remount during the transition. + Outer wrapper fades the entire gradient out when the header/icons collapse. */} + {walletHeaderOffset > 0 && + isDarkMode && + Object.entries(TAB_GRADIENT_COLORS).map(([idx, colors]) => ( + + + + ))} + + + ); + }, +); + +HomepageDiscoveryTabs.displayName = 'HomepageDiscoveryTabs'; + +export default HomepageDiscoveryTabs; diff --git a/app/components/Views/Homepage/components/HomepageDiscoveryTabs/index.ts b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/index.ts new file mode 100644 index 000000000000..822f80a9a584 --- /dev/null +++ b/app/components/Views/Homepage/components/HomepageDiscoveryTabs/index.ts @@ -0,0 +1 @@ +export { default } from './HomepageDiscoveryTabs'; diff --git a/app/components/Views/MultichainAccounts/sheets/EditAccountName.testIds.ts b/app/components/Views/MultichainAccounts/sheets/EditAccountName.testIds.ts index d1cd888882bc..f8a8123a08a2 100644 --- a/app/components/Views/MultichainAccounts/sheets/EditAccountName.testIds.ts +++ b/app/components/Views/MultichainAccounts/sheets/EditAccountName.testIds.ts @@ -2,4 +2,5 @@ export const EditAccountNameIds = { EDIT_ACCOUNT_NAME_CONTAINER: 'edit-account-name-container', ACCOUNT_NAME_INPUT: 'edit-account-name-input', SAVE_BUTTON: 'edit-account-name-save-button', + BACK_BUTTON: 'edit-multichain-account-name-back-button', }; diff --git a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.styles.ts b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.styles.ts deleted file mode 100644 index c8f575dccd12..000000000000 --- a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.styles.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Theme } from '../../../../../util/theme/models'; -import { Platform, StatusBar, StyleSheet } from 'react-native'; - -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - const { colors } = theme; - - return StyleSheet.create({ - safeArea: { - paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0, - flex: 1, - backgroundColor: colors.background.default, - }, - keyboardAvoidingView: { - flex: 1, - justifyContent: 'space-between', - }, - contentContainer: { - marginTop: 16, - paddingLeft: 24, - paddingRight: 24, - gap: 16, - }, - input: { - borderRadius: 8, - borderWidth: 2, - width: '100%', - borderColor: colors.border.default, - padding: 10, - height: 40, - color: colors.text.default, - }, - saveButtonContainer: { - paddingHorizontal: 24, - marginTop: 16, - paddingVertical: 10, - width: '100%', - }, - header: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - margin: 16, - }, - }); -}; - -export default styleSheet; diff --git a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.test.tsx b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.test.tsx index 1363eb994509..3b0e0bdc3d78 100644 --- a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.test.tsx +++ b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { fireEvent } from '@testing-library/react-native'; +import { Platform, StatusBar } from 'react-native'; import { EditMultichainAccountName } from './EditMultichainAccountName'; import { strings } from '../../../../../../locales/i18n'; import { EditAccountNameIds } from '../EditAccountName.testIds'; @@ -37,12 +38,29 @@ jest.mock('../../../../../core/Engine', () => ({ })); describe('EditMultichainAccountName', () => { + const originalPlatformOs = Platform.OS; + const originalStatusBarCurrentHeight = StatusBar.currentHeight; const render = () => renderWithProvider(); beforeEach(() => { jest.clearAllMocks(); mockSetAccountGroupName.mockReset(); mockUseRoute.mockReturnValue(mockRoute); + Platform.OS = 'ios'; + Object.defineProperty(StatusBar, 'currentHeight', { + configurable: true, + writable: true, + value: originalStatusBarCurrentHeight, + }); + }); + + afterAll(() => { + Platform.OS = originalPlatformOs; + Object.defineProperty(StatusBar, 'currentHeight', { + configurable: true, + writable: true, + value: originalStatusBarCurrentHeight, + }); }); describe('rendering', () => { @@ -85,9 +103,9 @@ describe('EditMultichainAccountName', () => { }); it('navigates back when back button is pressed', () => { - const { getByRole } = render(); + const { getByTestId } = render(); - const backButton = getByRole('button'); + const backButton = getByTestId(EditAccountNameIds.BACK_BUTTON); fireEvent.press(backButton); expect(mockGoBack).toHaveBeenCalledTimes(1); @@ -235,5 +253,28 @@ describe('EditMultichainAccountName', () => { const nameInput = getByTestId(EditAccountNameIds.ACCOUNT_NAME_INPUT); expect(nameInput.props.value).toBe(''); }); + + it('renders fallback header when route omits account group', () => { + mockUseRoute.mockReturnValue({ + params: {}, + } as typeof mockRoute); + + const { getByText } = render(); + expect(getByText('Account Group')).toBeOnTheScreen(); + }); + }); + + describe('platform-specific layout', () => { + it('renders on Android with status bar inset and keyboard avoiding height behavior', () => { + Platform.OS = 'android'; + Object.defineProperty(StatusBar, 'currentHeight', { + configurable: true, + writable: true, + value: 24, + }); + + const { getByTestId } = render(); + expect(getByTestId(EditAccountNameIds.BACK_BUTTON)).toBeOnTheScreen(); + }); }); }); diff --git a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx index 92c4ed85f121..a178d619dd13 100644 --- a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx +++ b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx @@ -1,5 +1,11 @@ import React, { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; +import { + KeyboardAvoidingView, + Platform, + StatusBar, + TextInput, +} from 'react-native'; import { strings } from '../../../../../../locales/i18n'; import Engine from '../../../../../core/Engine'; import { @@ -8,31 +14,28 @@ import { useNavigation, useRoute, } from '@react-navigation/native'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import { Box } from '../../../../UI/Box/Box'; import { + Box, + BoxFlexDirection, Button, + ButtonIcon, + ButtonIconSize, + ButtonSize, ButtonVariant, - ButtonBaseSize, + HeaderBase, + IconName, + Text, + TextColor, + TextVariant, + FontWeight, } from '@metamask/design-system-react-native'; -import styleSheet from './EditMultichainAccountName.styles'; -import { useStyles } from '../../../../hooks/useStyles'; -import { useTheme } from '../../../../../util/theme'; -import { TextInput, KeyboardAvoidingView, Platform } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; import { EditAccountNameIds } from '../EditAccountName.testIds'; import { AccountGroupObject } from '@metamask/account-tree-controller'; import { RootState } from '../../../../../reducers'; import { selectAccountGroupById } from '../../../../../selectors/multichainAccounts/accountTreeController'; -import HeaderBase from '../../../../../component-library/components/HeaderBase/HeaderBase'; -import ButtonLink from '../../../../../component-library/components/Buttons/Button/variants/ButtonLink'; -import Icon, { - IconName, - IconSize, -} from '../../../../../component-library/components/Icons/Icon'; +import { useTheme } from '../../../../../util/theme'; interface RootNavigationParamList extends ParamListBase { EditMultichainAccountName: { @@ -46,7 +49,7 @@ type EditMultichainAccountNameRouteProp = RouteProp< >; export const EditMultichainAccountName = () => { - const { styles } = useStyles(styleSheet, {}); + const tw = useTailwind(); const { colors, themeAppearance } = useTheme(); const route = useRoute(); const { accountGroup: initialAccountGroup } = route.params; @@ -65,6 +68,18 @@ export const EditMultichainAccountName = () => { const [accountName, setAccountName] = useState(initialName); const [error, setError] = useState(null); + const safeAreaStyle = tw.style( + 'flex-1 bg-default', + Platform.OS === 'android' && StatusBar.currentHeight + ? { paddingTop: StatusBar.currentHeight } + : undefined, + ); + + const inputStyle = tw.style( + 'h-10 w-full rounded-lg border-2 border-default p-2.5', + { color: colors.text.default }, + ); + const handleAccountNameChange = useCallback(() => { // Validate that account name is not empty if (!accountName || accountName.trim() === '') { @@ -93,13 +108,14 @@ export const EditMultichainAccountName = () => { }, [accountName, accountGroup, navigation]); return ( - + } + navigation.goBack()} /> } @@ -107,19 +123,20 @@ export const EditMultichainAccountName = () => { {accountGroup?.metadata?.name || 'Account Group'} - + {strings('multichain_accounts.edit_account_name.account_name')} { setAccountName(newName); @@ -136,13 +153,13 @@ export const EditMultichainAccountName = () => { autoFocus editable /> - {error && {error}} + {error ? {error} : null} - + + ); diff --git a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx index a7b8770f4155..25dbdae28f26 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection.tsx @@ -31,6 +31,7 @@ import { RootState } from '../../../../../../reducers'; import { useAutoSignIn } from '../../../../../../util/identity/hooks/useAuthentication'; import OAuthService from '../../../../../../core/OAuthService/OAuthService'; import Logger from '../../../../../../util/Logger'; +import { updateCachedConsent } from '../../../../../../util/trace'; import { selectSeedlessOnboardingLoginFlow } from '../../../../../../selectors/seedlessOnboardingController'; import { selectOnboardingAccountType } from '../../../../../../selectors/onboarding'; import { storePna25Acknowledged } from '../../../../../../actions/legalNotices'; @@ -75,6 +76,7 @@ const MetaMetricsAndDataCollectionSection: React.FC< // Error already logged in optOut }); setAnalyticsEnabled(false); + updateCachedConsent(false); dispatch(setDataCollectionForMarketing(false)); return; } @@ -93,6 +95,7 @@ const MetaMetricsAndDataCollectionSection: React.FC< fetchMarketingStatus(); } setAnalyticsEnabled(analytics.isEnabled()); + updateCachedConsent(analytics.isEnabled()); }, [ setAnalyticsEnabled, autoSignIn, @@ -110,6 +113,7 @@ const MetaMetricsAndDataCollectionSection: React.FC< await analytics.optIn(); setAnalyticsEnabled(true); + updateCachedConsent(true); analytics.identify(consolidatedTraits); analytics.trackEvent( @@ -158,6 +162,7 @@ const MetaMetricsAndDataCollectionSection: React.FC< await analytics.optOut(); setAnalyticsEnabled(false); + updateCachedConsent(false); if (isDataCollectionForMarketingEnabled) { dispatch(setDataCollectionForMarketing(false)); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx index 244837500491..8e02d0cdbaf7 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx @@ -92,6 +92,17 @@ jest.mock('../../../../core/ClipboardManager', () => ({ setString: jest.fn().mockResolvedValue(undefined), })); +// Pressing buy mounts QuickBuyBottomSheet. Jest's global mock for design-system +// `BottomSheet` (see app/util/test/testSetup.js) invokes `onOpenBottomSheet`'s +// callback synchronously, so `QuickBuyBottomSheetContent` mounts in the same turn +// and runs `useQuickBuyBottomSheet` (bridge selectors, device version compare, +// NetworkController, …). This file intentionally uses a minimal Redux store, so +// we stub the sheet here. +jest.mock('./components/QuickBuyBottomSheet', () => ({ + __esModule: true, + default: () => null, +})); + jest.mock('../../../../util/haptics', () => { const actual = jest.requireActual( '../../../../util/haptics', diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx index 2f37f6c9bfc6..6d42545cb2f6 100644 --- a/app/components/Views/TrendingView/TrendingView.tsx +++ b/app/components/Views/TrendingView/TrendingView.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { TouchableOpacity } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; @@ -33,6 +33,19 @@ import SportsTab from './tabs/SportsTab'; import DappsTab from './tabs/DappsTab'; import { TrendingViewSelectorsIDs } from './TrendingView.testIds'; import ExplorePageV1 from './ExplorePageV1'; +import { + trackExploreInteracted, + type ExploreTabName, +} from './search/analytics'; + +const TAB_NAMES: ExploreTabName[] = [ + 'Now', + 'Macro', + 'RWAs', + 'Crypto', + 'Sports', + 'Sites', +]; export const ExploreFeed: React.FC = () => { const tw = useTailwind(); @@ -89,6 +102,19 @@ export const ExploreFeed: React.FC = () => { navigation.navigate(Routes.EXPLORE_SEARCH); }, [navigation]); + const previousTabRef = useRef('Now'); + + const handleTabChange = useCallback(({ i }: { i: number }) => { + const destinationTab = TAB_NAMES[i]; + if (!destinationTab) return; + trackExploreInteracted({ + interaction_type: 'tab_switched', + tab_name: destinationTab, + previous_tab: previousTabRef.current, + }); + previousTabRef.current = destinationTab; + }, []); + return ( { {!isBasicFunctionalityEnabled ? ( ) : isExplorePageV2Enabled ? ( - + void; testID?: string; + /** Tab context for analytics — required when onViewAll is set. */ + tabName?: ExploreTabName; + /** Section context for analytics — required when onViewAll is set. */ + sectionName?: ExploreSectionName; } const SectionHeader: React.FC = ({ @@ -19,24 +28,39 @@ const SectionHeader: React.FC = ({ subtitle, onViewAll, testID, -}) => ( - <> - - {subtitle && ( - - {subtitle} - - )} - -); + tabName, + sectionName, +}) => { + const handleViewAll = useCallback(() => { + if (tabName && sectionName) { + trackExploreInteracted({ + interaction_type: 'section_see_all_tapped', + tab_name: tabName, + section_name: sectionName, + }); + } + onViewAll?.(); + }, [onViewAll, tabName, sectionName]); + + return ( + <> + + {subtitle && ( + + {subtitle} + + )} + + ); +}; export default SectionHeader; diff --git a/app/components/Views/TrendingView/feeds/dapps/SiteTileRowItem.tsx b/app/components/Views/TrendingView/feeds/dapps/SiteTileRowItem.tsx index ddde0d1ecbf7..d19aae4afa72 100644 --- a/app/components/Views/TrendingView/feeds/dapps/SiteTileRowItem.tsx +++ b/app/components/Views/TrendingView/feeds/dapps/SiteTileRowItem.tsx @@ -55,17 +55,23 @@ const styleSheet = ({ theme }: { theme: Theme }) => interface SiteTileRowItemProps { site: SiteData; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; } /** * Compact tile (icon, title, url) for Explore "Recents" / "Networks" carousels. */ -const SiteTileRowItem: React.FC = ({ site }) => { +const SiteTileRowItem: React.FC = ({ + site, + onCardPress, +}) => { const navigation = useNavigation(); const { styles } = useStyles(styleSheet); const tw = useTailwind(); const onPress = () => { + onCardPress?.(); navigation.navigate(Routes.BROWSER.HOME, { screen: Routes.BROWSER.VIEW, params: { diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsPillItem.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsPillItem.tsx index 2872d094b51a..c24549de87c7 100644 --- a/app/components/Views/TrendingView/feeds/perps/PerpsPillItem.tsx +++ b/app/components/Views/TrendingView/feeds/perps/PerpsPillItem.tsx @@ -15,9 +15,11 @@ const LOGO_SIZE = 24; interface PerpsPillItemProps { item: PerpsFeedItem; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; } -const PerpsPillItem: React.FC = ({ item }) => { +const PerpsPillItem: React.FC = ({ item, onCardPress }) => { const navigation = useNavigation>(); const { market } = item; @@ -44,6 +46,7 @@ const PerpsPillItem: React.FC = ({ item }) => { }, [market.change24hPercent]); const onPress = () => { + onCardPress?.(); navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: { market, source: PERPS_EVENT_VALUE.SOURCE.EXPLORE }, diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsRowItem.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsRowItem.tsx index 1d2e09bfd29a..cef900b91247 100644 --- a/app/components/Views/TrendingView/feeds/perps/PerpsRowItem.tsx +++ b/app/components/Views/TrendingView/feeds/perps/PerpsRowItem.tsx @@ -10,15 +10,18 @@ import Routes from '../../../../../constants/navigation/Routes'; interface PerpsRowItemProps { market: PerpsMarketData; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; } /** Compact list row for perps — used by pill-toggled lists and search. */ -const PerpsRowItem: React.FC = ({ market }) => { +const PerpsRowItem: React.FC = ({ market, onCardPress }) => { const navigation = useNavigation>(); return ( { + onCardPress?.(); navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: { market, source: PERPS_EVENT_VALUE.SOURCE.EXPLORE }, diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsTileRowItem.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsTileRowItem.tsx index 5839b6c3fceb..d640187d8fdd 100644 --- a/app/components/Views/TrendingView/feeds/perps/PerpsTileRowItem.tsx +++ b/app/components/Views/TrendingView/feeds/perps/PerpsTileRowItem.tsx @@ -9,11 +9,14 @@ import type { PerpsFeedItem } from './usePerpsFeed'; interface PerpsTileRowItemProps { item: PerpsFeedItem; testIdPrefix: string; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; } const PerpsTileRowItem: React.FC = ({ item, testIdPrefix, + onCardPress, }) => { const navigation = useNavigation>(); const { market, sparkline, isWatchlisted } = item; @@ -25,6 +28,7 @@ const PerpsTileRowItem: React.FC = ({ showFavoriteTag={isWatchlisted} testID={`${testIdPrefix}-${market.symbol}`} onPress={() => { + onCardPress?.(); navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: { market, source: PERPS_EVENT_VALUE.SOURCE.EXPLORE }, diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx new file mode 100644 index 000000000000..048a00954c44 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx @@ -0,0 +1,98 @@ +import React, { useCallback, useRef } from 'react'; +import { Box } from '@metamask/design-system-react-native'; +import type { ListRenderItem } from '@shopify/flash-list'; +import type { PerpsMarketData } from '@metamask/perps-controller'; +import PerpsRowItem from './PerpsRowItem'; +import PerpsRowSkeleton from '../../../../UI/Perps/components/PerpsRowSkeleton'; +import PillToggleCardList, { + type PillToggleCardListTab, +} from '../../components/PillToggleCardList'; +import SectionHeader from '../../components/SectionHeader'; +import { + type ExploreTabName, + type ExploreSectionName, + trackExploreInteracted, +} from '../../search/analytics'; + +const PerpsRowSingleSkeleton: React.FC = () => ; + +export interface PerpsToggleBlockProps { + title: string; + tabs: PillToggleCardListTab[]; + isLoading: boolean; + defaultPillKey: string; + onViewAll: (filter: string) => void; + /** Analytics context */ + tabName: ExploreTabName; + sectionName: ExploreSectionName; + /** Test IDs */ + headerTestID: string; + idPrefix: string; + testIdPrefix: string; + listTestId: string; +} + +/** + * Shared perps section that renders a pill-toggled list of perp rows with + * a "See all" header. Used by MacroTab and RwasTab. + */ +const PerpsToggleBlock: React.FC = ({ + title, + tabs, + isLoading, + defaultPillKey, + onViewAll, + tabName, + sectionName, + headerTestID, + idPrefix, + testIdPrefix, + listTestId, +}) => { + const activePillKey = useRef(defaultPillKey); + + const renderItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: tabName, + section_name: sectionName, + asset_type: 'perp', + position: index, + item_clicked: item.symbol, + }) + } + /> + ), + [tabName, sectionName], + ); + + return ( + + onViewAll(activePillKey.current)} + testID={headerTestID} + tabName={tabName} + sectionName={sectionName} + /> + + tabs={tabs} + isLoading={isLoading} + renderItem={renderItem} + Skeleton={PerpsRowSingleSkeleton} + idPrefix={idPrefix} + onPillChange={(key) => { + activePillKey.current = key; + }} + testIdPrefix={testIdPrefix} + listTestId={listTestId} + /> + + ); +}; + +export default PerpsToggleBlock; diff --git a/app/components/Views/TrendingView/feeds/predictions/PredictionRowItem.tsx b/app/components/Views/TrendingView/feeds/predictions/PredictionRowItem.tsx index 9700f539ae61..47140b46a6a6 100644 --- a/app/components/Views/TrendingView/feeds/predictions/PredictionRowItem.tsx +++ b/app/components/Views/TrendingView/feeds/predictions/PredictionRowItem.tsx @@ -7,17 +7,23 @@ import type { PredictMarket as PredictMarketType } from '../../../../UI/Predict/ interface PredictionCarouselRowItemProps { market: PredictMarketType; testIdPrefix?: string; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; + /** Called when the user taps a buy button (before betslip opens). */ + onBuyButtonPress?: (marketId: string) => void; } /** Carousel-style market card used inside Explore home tabs. */ export const PredictionCarouselRowItem: React.FC< PredictionCarouselRowItemProps -> = ({ market, testIdPrefix }) => ( +> = ({ market, testIdPrefix, onCardPress, onBuyButtonPress }) => ( ); diff --git a/app/components/Views/TrendingView/feeds/sites/SiteRowItem.tsx b/app/components/Views/TrendingView/feeds/sites/SiteRowItem.tsx index 8c49c0592d66..0c53d1687d32 100644 --- a/app/components/Views/TrendingView/feeds/sites/SiteRowItem.tsx +++ b/app/components/Views/TrendingView/feeds/sites/SiteRowItem.tsx @@ -22,27 +22,41 @@ const openSiteInBrowser = (navigation: AppNavigationProp, site: SiteData) => { interface SiteRowItemProps { site: SiteData; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; } /** Generic site row (sites + dapps_favorites without remove action). */ -export const SiteRowItem: React.FC = ({ site }) => { +export const SiteRowItem: React.FC = ({ + site, + onCardPress, +}) => { const navigation = useNavigation(); return ( openSiteInBrowser(navigation, site)} + onPress={() => { + onCardPress?.(); + openSiteInBrowser(navigation, site); + }} /> ); }; /** Favorite-site row with the "remove from favorites" affordance. */ -export const FavoriteSiteRowItem: React.FC = ({ site }) => { +export const FavoriteSiteRowItem: React.FC = ({ + site, + onCardPress, +}) => { const navigation = useNavigation(); const dispatch = useDispatch(); return ( openSiteInBrowser(navigation, site)} + onPress={() => { + onCardPress?.(); + openSiteInBrowser(navigation, site); + }} onRemoveFavorite={() => dispatch( removeBookmark({ diff --git a/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.tsx b/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.tsx index 7f3c0dd441fe..3500053eebcd 100644 --- a/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.tsx +++ b/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.tsx @@ -26,19 +26,27 @@ const LOGO_SIZE = 24; interface CryptoMoversPillItemProps { token: TrendingAsset; index: number; + /** Called synchronously before the card's press handler fires. */ + onCardPress?: () => void; } const CryptoMoversPillItem: React.FC = ({ token, index, + onCardPress, }) => { - const { onPress } = useTrendingTokenPress({ + const { onPress: defaultOnPress } = useTrendingTokenPress({ token, index, filterContext: CRYPTO_MOVERS_HOME_FILTER_CONTEXT, tokenDetailsSource: TokenDetailsSource.ExploreNowMovers, }); + const onPress = React.useCallback(async () => { + onCardPress?.(); + await defaultOnPress(); + }, [onCardPress, defaultOnPress]); + const networkBadgeImageSource = useMemo(() => { const caipChainId = getCaipChainIdFromAssetId(token.assetId); if (!isCaipChainId(caipChainId)) return undefined; diff --git a/app/components/Views/TrendingView/feeds/tokens/TokenRowItem.tsx b/app/components/Views/TrendingView/feeds/tokens/TokenRowItem.tsx index 297710c5b21e..514e607e1649 100644 --- a/app/components/Views/TrendingView/feeds/tokens/TokenRowItem.tsx +++ b/app/components/Views/TrendingView/feeds/tokens/TokenRowItem.tsx @@ -13,6 +13,8 @@ interface TokenRowItemProps { index: number; /** When omitted, defaults to {@link TokenDetailsSource.Trending} in the row item. */ tokenDetailsSource?: TokenDetailsSource; + /** Called synchronously before the card's press handler fires. */ + onCardPress?: () => void; } /** Token row used inside the home tabs. */ @@ -20,12 +22,14 @@ export const TokenRowItem: React.FC = ({ token, index, tokenDetailsSource, + onCardPress, }) => ( ); diff --git a/app/components/Views/TrendingView/search/analytics.ts b/app/components/Views/TrendingView/search/analytics.ts index d8c87e2325cf..2676dca84879 100644 --- a/app/components/Views/TrendingView/search/analytics.ts +++ b/app/components/Views/TrendingView/search/analytics.ts @@ -3,6 +3,63 @@ import { analytics } from '../../../../util/analytics/analytics'; import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; +export type ExploreTabName = + | 'Now' + | 'Macro' + | 'RWAs' + | 'Crypto' + | 'Sports' + | 'Sites'; + +export type ExploreSectionName = + | 'tokens_movers' + | 'tokens_trending' + | 'perps_movers' + | 'perps_stocks_commodities' + | 'perps_markets' + | 'perps_crypto' + | 'stocks' + | 'predictions_trending' + | 'predictions_politics' + | 'predictions_crypto' + | 'predictions_sports' + | 'predictions_football' + | 'predictions_basketball' + | 'predictions_tennis' + | 'sites_recents' + | 'sites_favorites' + | 'sites_ecosystems' + | 'sites_popular'; + +export interface ExploreInteractedProperties { + interaction_type: + | 'tab_switched' + | 'section_see_all_tapped' + | 'section_item_tapped' + | 'prediction_voted'; + tab_name: ExploreTabName; + section_name?: ExploreSectionName; + position?: number; + asset_type?: 'token' | 'stock' | 'perp' | 'prediction' | 'dapp'; + previous_tab?: ExploreTabName; + token_address?: string; + token_symbol?: string; + chain_id?: string; + item_clicked?: string; +} + +export const trackExploreInteracted = ( + properties: ExploreInteractedProperties, +): void => { + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( + MetaMetricsEvents.EXPLORE_INTERACTED, + ) + .addProperties(properties as unknown as Record) + .build(), + ); +}; + /** Single-line wrapper around the analytics builder boilerplate. */ export const trackExploreEvent = ( event: Parameters[0], diff --git a/app/components/Views/TrendingView/tabs/CryptoTab.tsx b/app/components/Views/TrendingView/tabs/CryptoTab.tsx index 267201865b9f..7a5213d96f4c 100644 --- a/app/components/Views/TrendingView/tabs/CryptoTab.tsx +++ b/app/components/Views/TrendingView/tabs/CryptoTab.tsx @@ -12,6 +12,7 @@ import Routes from '../../../../constants/navigation/Routes'; import { strings } from '../../../../../locales/i18n'; import { TokenDetailsSource } from '../../../UI/TokenDetails/constants/constants'; import { useTokensFeed } from '../feeds/tokens/useTokensFeed'; +import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/TrendingTokenRowItem/utils'; import { TokenRowItem } from '../feeds/tokens/TokenRowItem'; import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; import { usePerpsFeed, type PerpsFeedItem } from '../feeds/perps/usePerpsFeed'; @@ -29,6 +30,7 @@ import HorizontalCarousel from '../components/HorizontalCarousel'; import SectionHeader from '../components/SectionHeader'; import TileCarousel from '../components/TileCarousel'; import type { TabProps } from '../hooks/useExploreRefresh'; +import { trackExploreInteracted } from '../search/analytics'; interface CryptoPerpsBlockProps { refresh: TabProps['refresh']; @@ -53,14 +55,26 @@ const CryptoPerpsBlock: React.FC = ({ title={strings('trending.crypto_perps_section')} onViewAll={onViewAll} testID="section-header-view-all-crypto_perps" + tabName="Crypto" + sectionName="perps_crypto" /> data={perps.data} isLoading={perps.isLoading} - renderItem={(item) => ( + renderItem={(item, index) => ( + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Crypto', + section_name: 'perps_crypto', + asset_type: 'perp', + position: index, + item_clicked: item.market.symbol, + }) + } /> )} keyExtractor={(item) => item.market.symbol} @@ -91,16 +105,46 @@ const CryptoTab: React.FC = ({ refresh, refreshing, onRefresh }) => { token={item} index={index} tokenDetailsSource={TokenDetailsSource.ExploreCryptoTrending} + onCardPress={() => + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Crypto', + section_name: 'tokens_trending', + asset_type: 'token', + position: index, + token_symbol: item.symbol, + chain_id: getCaipChainIdFromAssetId(item.assetId), + item_clicked: item.assetId, + }) + } /> ), [], ); const renderPredictionItem: ListRenderItem = useCallback( - ({ item }) => ( + ({ item, index }) => ( + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Crypto', + section_name: 'predictions_crypto', + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'Crypto', + section_name: 'predictions_crypto', + item_clicked: marketId, + }) + } /> ), [], @@ -120,6 +164,8 @@ const CryptoTab: React.FC = ({ refresh, refreshing, onRefresh }) => { navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW) } testID="section-header-view-all-tokens" + tabName="Crypto" + sectionName="tokens_trending" /> data={tokens.data} @@ -148,6 +194,8 @@ const CryptoTab: React.FC = ({ refresh, refreshing, onRefresh }) => { title={strings('trending.predictions')} onViewAll={() => navigateToPredictionsList(navigation, 'crypto')} testID="section-header-view-all-crypto_predictions" + tabName="Crypto" + sectionName="predictions_crypto" /> data={cryptoPredictions.data} diff --git a/app/components/Views/TrendingView/tabs/DappsTab.tsx b/app/components/Views/TrendingView/tabs/DappsTab.tsx index 4f47a786ad31..6e5ad0b18e4e 100644 --- a/app/components/Views/TrendingView/tabs/DappsTab.tsx +++ b/app/components/Views/TrendingView/tabs/DappsTab.tsx @@ -19,6 +19,7 @@ import ExploreScroll from '../components/ExploreScroll'; import SectionHeader from '../components/SectionHeader'; import TileCarousel from '../components/TileCarousel'; import type { TabProps } from '../hooks/useExploreRefresh'; +import { trackExploreInteracted } from '../search/analytics'; const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const navigation = useNavigation(); @@ -29,12 +30,40 @@ const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const sites = useSitesFeed({ refresh }); const renderFavorite: ListRenderItem = useCallback( - ({ item }) => , + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sites', + section_name: 'sites_favorites', + asset_type: 'dapp', + position: index, + item_clicked: item.url, + }) + } + /> + ), [], ); const renderSite: ListRenderItem = useCallback( - ({ item }) => , + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sites', + section_name: 'sites_popular', + asset_type: 'dapp', + position: index, + item_clicked: item.url, + }) + } + /> + ), [], ); @@ -53,7 +82,21 @@ const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { data={recents.data} isLoading={recents.isLoading} - renderItem={(site) => } + renderItem={(site, index) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sites', + section_name: 'sites_recents', + asset_type: 'dapp', + position: index, + item_clicked: site.url, + }) + } + /> + )} keyExtractor={(site) => site.url} Skeleton={SiteTileSkeleton} compactSectionTail @@ -70,6 +113,8 @@ const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { navigation.navigate(Routes.SITES_FULL_VIEW, { mode: 'favorites' }) } testID="section-header-view-all-dapps_favorites" + tabName="Sites" + sectionName="sites_favorites" /> data={favorites.data} @@ -90,7 +135,21 @@ const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { data={networks.data} isLoading={false} - renderItem={(site) => } + renderItem={(site, index) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sites', + section_name: 'sites_ecosystems', + asset_type: 'dapp', + position: index, + item_clicked: site.url, + }) + } + /> + )} keyExtractor={(site) => site.url} Skeleton={SiteTileSkeleton} testID="explore-dapps_networks-carousel" @@ -103,6 +162,8 @@ const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { title={strings('trending.popular')} onViewAll={() => navigation.navigate(Routes.SITES_FULL_VIEW)} testID="section-header-view-all-sites" + tabName="Sites" + sectionName="sites_popular" /> data={sites.data} diff --git a/app/components/Views/TrendingView/tabs/MacroTab.tsx b/app/components/Views/TrendingView/tabs/MacroTab.tsx index d50702420fb6..e20a30ee710c 100644 --- a/app/components/Views/TrendingView/tabs/MacroTab.tsx +++ b/app/components/Views/TrendingView/tabs/MacroTab.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { Box } from '@metamask/design-system-react-native'; @@ -12,8 +12,7 @@ import { selectPredictEnabledFlag } from '../../../UI/Predict'; import { strings } from '../../../../../locales/i18n'; import { usePerpsFeed } from '../feeds/perps/usePerpsFeed'; import PerpsSectionProvider from '../feeds/perps/PerpsSectionProvider'; -import PerpsRowItem from '../feeds/perps/PerpsRowItem'; -import PerpsRowSkeleton from '../../../UI/Perps/components/PerpsRowSkeleton'; +import PerpsToggleBlock from '../feeds/perps/PerpsToggleBlock'; import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation'; import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; import { PredictionCarouselRowItem } from '../feeds/predictions/PredictionRowItem'; @@ -21,13 +20,10 @@ import PredictionsSkeleton from '../feeds/predictions/PredictionsSkeleton'; import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavigation'; import ExploreScroll from '../components/ExploreScroll'; import HorizontalCarousel from '../components/HorizontalCarousel'; -import PillToggleCardList, { - type PillToggleCardListTab, -} from '../components/PillToggleCardList'; +import type { PillToggleCardListTab } from '../components/PillToggleCardList'; import SectionHeader from '../components/SectionHeader'; import type { TabProps } from '../hooks/useExploreRefresh'; - -const PerpsRowSingleSkeleton: React.FC = () => ; +import { trackExploreInteracted } from '../search/analytics'; interface MacroPerpsBlockProps { refresh: TabProps['refresh']; @@ -39,58 +35,43 @@ const MacroPerpsBlock: React.FC = ({ onViewAll, }) => { const perps = usePerpsFeed({ variant: 'macro', refresh }); - const activePillKey = useRef('stocks'); const tabs = useMemo[]>(() => { - const stocks = perps.data - .filter((d) => d.market.marketType === 'equity') - .slice(0, 3) - .map((d) => d.market); - const commodities = perps.data - .filter((d) => d.market.marketType === 'commodity') - .slice(0, 3) - .map((d) => d.market); + const byType = (type: PerpsMarketData['marketType']) => + perps.data + .filter((d) => d.market.marketType === type) + .slice(0, 3) + .map((d) => d.market); return [ { key: 'stocks', name: strings('trending.macro_pill_stocks'), - items: stocks, + items: byType('equity'), }, { key: 'commodities', name: strings('trending.macro_pill_commodities'), - items: commodities, + items: byType('commodity'), }, ]; }, [perps.data]); - const renderItem: ListRenderItem = useCallback( - ({ item }) => , - [], - ); - if (!perps.isLoading && perps.data.length === 0) return null; return ( - - onViewAll(activePillKey.current)} - testID="section-header-view-all-macro_stocks_commodity_perps" - /> - - tabs={tabs} - isLoading={perps.isLoading} - renderItem={renderItem} - Skeleton={PerpsRowSingleSkeleton} - idPrefix="macro_stocks_commodity_perps" - onPillChange={(key) => { - activePillKey.current = key; - }} - testIdPrefix="macro-stocks-commodity-pills" - listTestId="macro-stocks-commodity-perps-list" - /> - + ); }; @@ -104,10 +85,28 @@ const MacroTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const politics = usePredictionsFeed({ variant: 'politics', refresh }); const renderPredictionItem: ListRenderItem = useCallback( - ({ item }) => ( + ({ item, index }) => ( + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Macro', + section_name: 'predictions_politics', + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'Macro', + section_name: 'predictions_politics', + item_clicked: marketId, + }) + } /> ), [], @@ -126,6 +125,8 @@ const MacroTab: React.FC = ({ refresh, refreshing, onRefresh }) => { navigateToPredictionsList(appNavigation, 'politics') } testID="section-header-view-all-politics_predictions" + tabName="Macro" + sectionName="predictions_politics" /> data={politics.data} diff --git a/app/components/Views/TrendingView/tabs/NowTab.test.tsx b/app/components/Views/TrendingView/tabs/NowTab.test.tsx new file mode 100644 index 000000000000..245db97ea277 --- /dev/null +++ b/app/components/Views/TrendingView/tabs/NowTab.test.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import { NavigationContainer } from '@react-navigation/native'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +// Feed hooks — return empty/not-loading so NowTab renders without network calls. +jest.mock('../feeds/tokens/useTokensFeed', () => ({ + useTokensFeed: jest.fn(() => ({ data: [], isLoading: false })), +})); + +jest.mock('../feeds/perps/usePerpsFeed', () => ({ + usePerpsFeed: jest.fn(() => ({ data: [], isLoading: false })), +})); + +jest.mock('../feeds/predictions/usePredictionsFeed', () => ({ + usePredictionsFeed: jest.fn(() => ({ data: [], isLoading: false })), +})); + +jest.mock('../feeds/stocks/useStocksFeed', () => ({ + useStocksFeed: jest.fn(() => ({ data: [], isLoading: false })), +})); + +// Mock PerpsSectionProvider as a transparent passthrough. +jest.mock('../feeds/perps/PerpsSectionProvider', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createElement } = require('react'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View } = require('react-native'); + return ({ children }: { children: unknown }) => + createElement(View, null, children); +}); + +// Mock WhatsHappeningSection to keep its transitive deps (Engine, analytics) +// out of this unit test. We control rendering via mockWhatsHappeningImpl. +const mockWhatsHappeningImpl = jest.fn( + () => null, +); + +jest.mock('../../Homepage/Sections/WhatsHappening', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { forwardRef } = require('react'); + return { + __esModule: true, + default: forwardRef((_props: unknown, ref: unknown) => + mockWhatsHappeningImpl(ref), + ), + }; +}); + +import { useSelector } from 'react-redux'; +import { selectPerpsEnabledFlag } from '../../../UI/Perps'; +import { selectPredictEnabledFlag } from '../../../UI/Predict'; +import { selectWhatsHappeningEnabled } from '../../../../selectors/featureFlagController/whatsHappening'; +import NowTab from './NowTab'; +import type { RefreshConfig } from '../hooks/useExploreRefresh'; + +const defaultRefresh: RefreshConfig = { trigger: 0, silentRefresh: true }; +const defaultTabProps = { + refresh: defaultRefresh, + refreshing: false, + onRefresh: jest.fn(), +}; + +const renderNowTab = (props = defaultTabProps) => + render( + + + , + ); + +describe('NowTab — WhatsHappeningSection integration', () => { + const mockUseSelector = useSelector as jest.MockedFunction< + typeof useSelector + >; + + const mockSelectorBase = (selector: unknown) => { + if (selector === selectPerpsEnabledFlag) return false; + if (selector === selectPredictEnabledFlag) return false; + return undefined; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockImplementation(mockSelectorBase); + // Default: section mock renders nothing; individual tests override as needed. + mockWhatsHappeningImpl.mockReturnValue(null); + }); + + it('mounts WhatsHappeningSection and renders it when the feature flag is enabled', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectWhatsHappeningEnabled) return true; + return mockSelectorBase(selector); + }); + (mockWhatsHappeningImpl as jest.Mock).mockReturnValue( + React.createElement('View', { + testID: 'homepage-whats-happening-carousel', + }), + ); + + renderNowTab(); + + expect( + screen.getByTestId('homepage-whats-happening-carousel'), + ).toBeOnTheScreen(); + }); + + it('does not mount WhatsHappeningSection when the feature flag is disabled', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectWhatsHappeningEnabled) return false; + return mockSelectorBase(selector); + }); + + renderNowTab(); + + // Section is not even mounted, so the mock should never have been called. + expect(mockWhatsHappeningImpl).not.toHaveBeenCalled(); + expect( + screen.queryByTestId('homepage-whats-happening-carousel'), + ).toBeNull(); + }); + + it('passes a ref to WhatsHappeningSection so pull-to-refresh can trigger it', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectWhatsHappeningEnabled) return true; + return mockSelectorBase(selector); + }); + + renderNowTab(); + + // The mock's first argument is the forwarded ref (we dropped props in the mock). + // It should be a React ref object so the useEffect bridge can call .refresh(). + expect(mockWhatsHappeningImpl).toHaveBeenCalled(); + const [forwardedRef] = mockWhatsHappeningImpl.mock.calls[0]; + expect(forwardedRef).not.toBeNull(); + }); +}); diff --git a/app/components/Views/TrendingView/tabs/NowTab.tsx b/app/components/Views/TrendingView/tabs/NowTab.tsx index c7eb9240f902..94b6fb0ae929 100644 --- a/app/components/Views/TrendingView/tabs/NowTab.tsx +++ b/app/components/Views/TrendingView/tabs/NowTab.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { Box } from '@metamask/design-system-react-native'; @@ -27,12 +27,17 @@ import { PredictionCarouselRowItem } from '../feeds/predictions/PredictionRowIte import PredictionsSkeleton from '../feeds/predictions/PredictionsSkeleton'; import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavigation'; import { useStocksFeed } from '../feeds/stocks/useStocksFeed'; +import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/TrendingTokenRowItem/utils'; import CardList from '../components/CardList'; import ExploreScroll from '../components/ExploreScroll'; import HorizontalCarousel from '../components/HorizontalCarousel'; import PillScrollList from '../components/PillScrollList'; import SectionHeader from '../components/SectionHeader'; import type { TabProps } from '../hooks/useExploreRefresh'; +import { trackExploreInteracted } from '../search/analytics'; +import WhatsHappeningSection from '../../Homepage/Sections/WhatsHappening'; +import type { SectionRefreshHandle } from '../../Homepage/types'; +import { selectWhatsHappeningEnabled } from '../../../../selectors/featureFlagController/whatsHappening'; interface PerpsBlockProps { refresh: TabProps['refresh']; @@ -54,11 +59,27 @@ const PerpsBlock: React.FC = ({ refresh, navigation }) => { title={strings('trending.perps_movers')} onViewAll={() => navigateToPerpsMarketList(navigation)} testID="section-header-view-all-perps" + tabName="Now" + sectionName="perps_movers" /> data={perps.data} isLoading={perps.isLoading} - renderItem={(item) => } + renderItem={(item, index) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Now', + section_name: 'perps_movers', + asset_type: 'perp', + position: index, + item_clicked: item.market.symbol, + }) + } + /> + )} keyExtractor={(item) => item.market.symbol} Skeleton={CryptoMoversSkeleton} listTestId="explore-perps-pills-list" @@ -73,16 +94,42 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { useNavigation>(); const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); const isPredictEnabled = useSelector(selectPredictEnabledFlag); + const isWhatsHappeningEnabled = useSelector(selectWhatsHappeningEnabled); + + const whatsHappeningRef = useRef(null); + + useEffect(() => { + if (refresh.trigger === 0) return; + whatsHappeningRef.current?.refresh(); + }, [refresh.trigger]); const predictions = usePredictionsFeed({ refresh }); const cryptoMovers = useTokensFeed({ refresh }); const stocks = useStocksFeed({ refresh }); const renderPredictionItem: ListRenderItem = useCallback( - ({ item }) => ( + ({ item, index }) => ( + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Now', + section_name: 'predictions_trending', + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'Now', + section_name: 'predictions_trending', + item_clicked: marketId, + }) + } /> ), [], @@ -94,6 +141,18 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { token={item} index={index} tokenDetailsSource={TokenDetailsSource.ExploreNowStocks} + onCardPress={() => + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Now', + section_name: 'stocks', + asset_type: 'stock', + position: index, + token_symbol: item.symbol, + chain_id: getCaipChainIdFromAssetId(item.assetId), + item_clicked: item.assetId, + }) + } /> ), [], @@ -111,12 +170,24 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { onRefresh={onRefresh} testID={TrendingViewSelectorsIDs.TRENDING_FEED_SCROLL_VIEW} > + {isWhatsHappeningEnabled && ( + + + + )} + {showPredictions && ( navigateToPredictionsList(navigation, 'trending')} testID="section-header-view-all-predictions" + tabName="Now" + sectionName="predictions_trending" /> data={predictions.data} @@ -136,12 +207,29 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW) } testID="section-header-view-all-crypto_movers" + tabName="Now" + sectionName="tokens_movers" /> data={cryptoMovers.data} isLoading={cryptoMovers.isLoading} renderItem={(token, index) => ( - + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Now', + section_name: 'tokens_movers', + asset_type: 'token', + position: index, + token_symbol: token.symbol, + chain_id: getCaipChainIdFromAssetId(token.assetId), + item_clicked: token.assetId, + }) + } + /> )} keyExtractor={(token) => token.assetId ?? ''} Skeleton={CryptoMoversSkeleton} @@ -164,6 +252,8 @@ const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { navigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW) } testID="section-header-view-all-stocks" + tabName="Now" + sectionName="stocks" /> data={stocks.data} diff --git a/app/components/Views/TrendingView/tabs/RwasTab.tsx b/app/components/Views/TrendingView/tabs/RwasTab.tsx index a52d2c6932be..6cd85c8e3738 100644 --- a/app/components/Views/TrendingView/tabs/RwasTab.tsx +++ b/app/components/Views/TrendingView/tabs/RwasTab.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { Box } from '@metamask/design-system-react-native'; @@ -16,10 +16,10 @@ import { TokenDetailsSource } from '../../../UI/TokenDetails/constants/constants import { TokenRowItem } from '../feeds/tokens/TokenRowItem'; import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; import { useStocksFeed } from '../feeds/stocks/useStocksFeed'; +import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/TrendingTokenRowItem/utils'; import { usePerpsFeed } from '../feeds/perps/usePerpsFeed'; import PerpsSectionProvider from '../feeds/perps/PerpsSectionProvider'; -import PerpsRowItem from '../feeds/perps/PerpsRowItem'; -import PerpsRowSkeleton from '../../../UI/Perps/components/PerpsRowSkeleton'; +import PerpsToggleBlock from '../feeds/perps/PerpsToggleBlock'; import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation'; import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; import { PredictionCarouselRowItem } from '../feeds/predictions/PredictionRowItem'; @@ -28,13 +28,10 @@ import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavig import CardList from '../components/CardList'; import ExploreScroll from '../components/ExploreScroll'; import HorizontalCarousel from '../components/HorizontalCarousel'; -import PillToggleCardList, { - type PillToggleCardListTab, -} from '../components/PillToggleCardList'; +import type { PillToggleCardListTab } from '../components/PillToggleCardList'; import SectionHeader from '../components/SectionHeader'; import type { TabProps } from '../hooks/useExploreRefresh'; - -const PerpsRowSingleSkeleton: React.FC = () => ; +import { trackExploreInteracted } from '../search/analytics'; interface RwaPerpsBlockProps { refresh: TabProps['refresh']; @@ -46,7 +43,6 @@ const RwaPerpsBlock: React.FC = ({ onViewAll, }) => { const perps = usePerpsFeed({ variant: 'rwa', refresh }); - const activePillKey = useRef('commodities'); const tabs = useMemo[]>(() => { const byType = (type: PerpsMarketData['marketType']) => @@ -73,33 +69,22 @@ const RwaPerpsBlock: React.FC = ({ ]; }, [perps.data]); - const renderItem: ListRenderItem = useCallback( - ({ item }) => , - [], - ); - if (!perps.isLoading && perps.data.length === 0) return null; return ( - - onViewAll(activePillKey.current)} - testID="section-header-view-all-rwa_perps" - /> - - tabs={tabs} - isLoading={perps.isLoading} - renderItem={renderItem} - Skeleton={PerpsRowSingleSkeleton} - idPrefix="rwa_perps" - onPillChange={(key) => { - activePillKey.current = key; - }} - testIdPrefix="rwa-perps-pills" - listTestId="rwa-perps-pill-toggled-list" - /> - + ); }; @@ -114,10 +99,28 @@ const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const stocks = useStocksFeed({ refresh }); const renderPredictionItem: ListRenderItem = useCallback( - ({ item }) => ( + ({ item, index }) => ( + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'RWAs', + section_name: 'predictions_politics', + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'RWAs', + section_name: 'predictions_politics', + item_clicked: marketId, + }) + } /> ), [], @@ -129,6 +132,18 @@ const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => { token={item} index={index} tokenDetailsSource={TokenDetailsSource.ExploreRwasStocks} + onCardPress={() => + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'RWAs', + section_name: 'stocks', + asset_type: 'stock', + position: index, + token_symbol: item.symbol, + chain_id: getCaipChainIdFromAssetId(item.assetId), + item_clicked: item.assetId, + }) + } /> ), [], @@ -148,6 +163,8 @@ const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => { navigateToPredictionsList(appNavigation, 'politics') } testID="section-header-view-all-politics_predictions" + tabName="RWAs" + sectionName="predictions_politics" /> data={politics.data} @@ -167,6 +184,8 @@ const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => { appNavigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW) } testID="section-header-view-all-stocks" + tabName="RWAs" + sectionName="stocks" /> data={stocks.data} diff --git a/app/components/Views/TrendingView/tabs/SportsTab.tsx b/app/components/Views/TrendingView/tabs/SportsTab.tsx index 57963867e20e..e7d6d0ed1e33 100644 --- a/app/components/Views/TrendingView/tabs/SportsTab.tsx +++ b/app/components/Views/TrendingView/tabs/SportsTab.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useRef } from 'react'; import { ActivityIndicator, TouchableOpacity, @@ -36,6 +36,16 @@ import HorizontalCarousel from '../components/HorizontalCarousel'; import PillRow from '../components/PillRow'; import SectionHeader from '../components/SectionHeader'; import type { TabProps } from '../hooks/useExploreRefresh'; +import { + trackExploreInteracted, + type ExploreSectionName, +} from '../search/analytics'; + +const SPORT_KEY_TO_SECTION: Record = { + soccer: 'predictions_football', + basketball: 'predictions_basketball', + tennis: 'predictions_tennis', +}; interface SportsListHeaderProps { showSportsPredictions: boolean; @@ -47,10 +57,31 @@ interface SportsListHeaderProps { navigation: AppNavigationProp; } -const renderPredictionItem: ListRenderItem = ({ item }) => ( +const renderPredictionItem: ListRenderItem = ({ + item, + index, +}) => ( + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sports', + section_name: 'predictions_sports', + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'Sports', + section_name: 'predictions_sports', + item_clicked: marketId, + }) + } /> ); @@ -70,6 +101,8 @@ const SportsListHeader: React.FC = ({ title={strings('trending.predictions')} onViewAll={() => navigateToPredictionsList(navigation, 'sports')} testID="section-header-view-all-sports_predictions" + tabName="Sports" + sectionName="predictions_sports" /> data={sportsPredictionsData} @@ -81,39 +114,41 @@ const SportsListHeader: React.FC = ({ )} - - - + - - - {showAllSportsSkeleton && ( - - {[0, 1, 2].map((i) => ( - - - - ))} - - )} - - {showAllSportsEmpty && ( - - + - )} + + {showAllSportsSkeleton && ( + + {[0, 1, 2].map((i) => ( + + + + ))} + + )} + + {showAllSportsEmpty && ( + + + + )} + ); @@ -126,16 +161,44 @@ const SportsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { const sportsPredictions = usePredictionsFeed({ variant: 'sports', refresh }); const sportsMarkets = useSportsMarketsFeed({ refresh }); + const { active, activeKey } = sportsMarkets; + const activeKeyRef = useRef(activeKey); + activeKeyRef.current = activeKey; + const renderActiveMarketItem: ListRenderItem = useCallback( - ({ item }) => , + ({ item, index }) => { + const sectionName = + SPORT_KEY_TO_SECTION[activeKeyRef.current] ?? 'predictions_football'; + return ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sports', + section_name: sectionName, + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'Sports', + section_name: sectionName, + item_clicked: marketId, + }) + } + /> + ); + }, [], ); const showSportsPredictions = isPredictEnabled && (sportsPredictions.isLoading || sportsPredictions.data.length > 0); - - const { active, activeKey } = sportsMarkets; const showAllSportsSkeleton = active.isFetching && active.marketData.length === 0; const showAllSportsEmpty = diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx index 2aad1303f3ec..1a0faedde64e 100644 --- a/app/components/Views/Wallet/index.test.tsx +++ b/app/components/Views/Wallet/index.test.tsx @@ -91,6 +91,31 @@ jest.mock('../../../selectors/featureFlagController/homepage', () => ({ selectHomepageSectionsV1Enabled: jest.fn(() => mockHomepageSectionsEnabled), })); +// Control discovery tabs AB test variant per test (default control so existing tests are unaffected) +let mockDiscoveryTabsVariantName = 'control'; +jest.mock('../../../hooks', () => ({ + ...jest.requireActual('../../../hooks'), + useABTest: jest.fn(() => ({ + variantName: mockDiscoveryTabsVariantName, + variant: { + discoveryTabsEnabled: mockDiscoveryTabsVariantName === 'treatment', + }, + })), +})); + +// Track HomepageDiscoveryTabs renders +const mockHomepageDiscoveryTabs = jest.fn(); +jest.mock('../Homepage/components/HomepageDiscoveryTabs', () => { + const React = jest.requireActual('react'); + return { + __esModule: true, + default: React.forwardRef((props: unknown, _ref: unknown) => { + mockHomepageDiscoveryTabs(props); + return null; + }), + }; +}); + // Capture the HomepageScrollContext value by rendering a context-aware mock Homepage. // The mock is only invoked when mockHomepageSectionsEnabled=true (sections flag on), // so existing tests that leave the flag false are completely unaffected. @@ -1606,6 +1631,144 @@ describe('HomepageScrollContext callbacks', () => { }); }); +describe('HomepageDiscoveryTabs AB test', () => { + let mockNavigation: NavigationProp; + + beforeEach(() => { + jest.clearAllMocks(); + mockHomepageSectionsEnabled = true; + mockDiscoveryTabsVariantName = 'control'; + mockHomepageDiscoveryTabs.mockClear(); + + mockNavigation = { + navigate: mockNavigate, + setOptions: mockSetOptions, + addListener: jest.fn(() => jest.fn()), + isFocused: jest.fn(() => false), + dangerouslyGetParent: jest.fn(() => ({ + dangerouslyGetState: jest.fn(() => ({ type: 'stack' })), + addListener: jest.fn(() => jest.fn()), + dangerouslyGetParent: jest.fn(() => ({ + dangerouslyGetState: jest.fn(() => ({ type: 'tab' })), + addListener: jest.fn(() => jest.fn()), + dangerouslyGetParent: jest.fn(() => undefined), + })), + })), + } as unknown as NavigationProp; + + jest + .mocked(useSelector) + .mockImplementation((callback: (state: unknown) => unknown) => + callback(mockInitialState), + ); + }); + + afterEach(() => { + mockHomepageSectionsEnabled = false; + mockDiscoveryTabsVariantName = 'control'; + jest.clearAllMocks(); + }); + + it('renders HomepageDiscoveryTabs when variant is treatment and sections flag is on', () => { + mockDiscoveryTabsVariantName = 'treatment'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockHomepageDiscoveryTabs).toHaveBeenCalled(); + }); + + it('does not render HomepageDiscoveryTabs when variant is control', () => { + mockDiscoveryTabsVariantName = 'control'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockHomepageDiscoveryTabs).not.toHaveBeenCalled(); + }); + + it('passes portfolioHeader, onPortfolioScroll, and refreshControl to HomepageDiscoveryTabs', () => { + mockDiscoveryTabsVariantName = 'treatment'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + const props = mockHomepageDiscoveryTabs.mock.calls.at(-1)?.[0] as Record< + string, + unknown + >; + expect(props).toBeDefined(); + expect(props.portfolioHeader).toBeDefined(); + expect(typeof props.onPortfolioScroll).toBe('function'); + expect(props.refreshControl).toBeDefined(); + }); + + it('passes walletHeaderOffset and walletHeaderHeight to HomepageDiscoveryTabs', () => { + mockDiscoveryTabsVariantName = 'treatment'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + const props = mockHomepageDiscoveryTabs.mock.calls.at(-1)?.[0] as Record< + string, + unknown + >; + expect(typeof props.walletHeaderOffset).toBe('number'); + expect(typeof props.walletHeaderHeight).toBe('number'); + }); + + it('renders Homepage scroll view (not HomepageDiscoveryTabs) when variant is control and sections flag is on', () => { + mockDiscoveryTabsVariantName = 'control'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + // HomepageDiscoveryTabs must not render; the legacy Homepage mock renders instead + expect(mockHomepageDiscoveryTabs).not.toHaveBeenCalled(); + expect(capturedContext).toBeDefined(); + }); + + it('does not render HomepageDiscoveryTabs when sections flag is off regardless of variant', () => { + mockHomepageSectionsEnabled = false; + mockDiscoveryTabsVariantName = 'treatment'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockHomepageDiscoveryTabs).not.toHaveBeenCalled(); + }); +}); + describe('useHomeDeepLinkEffects', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index bcb28723cef6..ab5d51d24560 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -22,7 +22,14 @@ import { StyleSheet as RNStyleSheet, View, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { + SafeAreaView, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; +import Reanimated, { + useSharedValue, + useAnimatedStyle, +} from 'react-native-reanimated'; import { connect, useDispatch, useSelector } from 'react-redux'; import { strings } from '../../../../locales/i18n'; import { @@ -133,6 +140,13 @@ import { Hex } from '@metamask/utils'; import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; import { selectHomepageSectionsV1Enabled } from '../../../selectors/featureFlagController/homepage'; import Homepage from '../Homepage'; +import HomepageDiscoveryTabs from '../Homepage/components/HomepageDiscoveryTabs'; +import { + HUB_PAGE_DISCOVERY_TABS_AB_KEY, + HUB_PAGE_DISCOVERY_TABS_VARIANTS, + HubPageDiscoveryTabsVariant, +} from '../Homepage/abTestConfig'; +import { useABTest } from '../../../hooks'; import { SectionRefreshHandle } from '../Homepage/types'; import { HomepageScrollContext } from '../Homepage/context/HomepageScrollContext'; import type { HomeSectionName } from '../Homepage/hooks/useHomeViewedEvent'; @@ -226,6 +240,13 @@ const createStyles = ({ colors }: Theme) => }, headerAccountPickerStyle: { marginRight: 16, + backgroundColor: 'transparent', + }, + accountGroupBalanceContainer: { + marginBottom: 16, + }, + walletHeaderRoot: { + zIndex: 2, }, }); @@ -596,6 +617,10 @@ const Wallet = ({ // ─── Homepage scroll context state ─────────────────────────────────────── const [viewportHeight, setViewportHeight] = useState(0); const [containerScreenY, setContainerScreenY] = useState(0); + const [headerHeight, setHeaderHeight] = useState(0); + const sharedHeaderHeight = useSharedValue(0); + const walletHeaderTranslateY = useSharedValue(0); + const insets = useSafeAreaInsets(); const { entryPoint, visitId } = useHomepageEntryPoint(navigation); // Ref to the scroll container View — used to measure its absolute screen Y @@ -1016,6 +1041,25 @@ const Wallet = ({ selectHomepageSectionsV1Enabled, ); + const { variantName: discoveryTabsVariantName } = useABTest( + HUB_PAGE_DISCOVERY_TABS_AB_KEY, + HUB_PAGE_DISCOVERY_TABS_VARIANTS, + ); + + const isDiscoveryTabsTreatment = + discoveryTabsVariantName === HubPageDiscoveryTabsVariant.Treatment; + + // translateY slides the header up; negative marginBottom collapses the layout + // space it occupied so the content below moves up in sync. + const animatedHeaderStyle = useAnimatedStyle(() => { + const h = sharedHeaderHeight.value; + return { + transform: [{ translateY: walletHeaderTranslateY.value }], + marginBottom: walletHeaderTranslateY.value, + opacity: h > 0 ? Math.max(0, 1 + walletHeaderTranslateY.value / h) : 1, + }; + }); + const isFocused = useIsFocused(); const homepageRef = useRef(null); @@ -1061,10 +1105,7 @@ const Wallet = ({ MetaMetricsEvents.ACTIVITY_CLICKED, ).build(), ); - navigation.navigate(Routes.TRANSACTIONS_VIEW, { - screen: Routes.TRANSACTIONS_VIEW, - params: { showBackButton: true }, - }); + navigation.navigate(Routes.TRANSACTIONS_VIEW); }, [navigation, trackEvent]); const getTokenAddedAnalyticsParams = useCallback( @@ -1304,62 +1345,91 @@ const Wallet = ({ ], ); - const content = ( + const bannerContent = ( + + {!basicFunctionalityEnabled ? ( + + {strings('wallet.banner.link')} + + } + /> + ) : null} + + + ); + + const portfolioHeaderBase = ( <> - - {!basicFunctionalityEnabled ? ( - - {strings('wallet.banner.link')} - - } - /> - ) : null} - - - <> - + {bannerContent} + + + {isCarouselBannersEnabled && } + + ); - + const portfolioHeader = ( + <> + {bannerContent} + + + + + {isCarouselBannersEnabled && } + + ); - {isCarouselBannersEnabled && } - - {isHomepageSectionsV1Enabled ? ( - <> - {isFocused && } - - - - - ) : ( - <> - {isFocused && } - - - )} - + // Legacy scroll view content — used only when the sections redesign is off. + const content = ( + <> + {bannerContent} + + + {isCarouselBannersEnabled && } + {isFocused && } + ); const renderLoader = useCallback( @@ -1384,49 +1454,84 @@ const Wallet = ({ > {selectedInternalAccount ? ( <> - - - {isMoneyHomeScreenEnabled && ( - - )} - - - - - {isNotificationsFeatureEnabled() ? ( - + { + const h = e.nativeEvent.layout.height; + if (h > 0) { + setHeaderHeight(h); + sharedHeaderHeight.value = h; } - badge={ - isNotificationEnabled && - unreadNotificationCount > 0 ? ( - - ) : null + } + : undefined + } + testID={WalletViewSelectorsIDs.WALLET_HEADER_ROOT} + style={undefined} + endAccessory={ + + + {isMoneyHomeScreenEnabled && ( + + )} + + + + + {isNotificationsFeatureEnabled() ? ( + 0 ? ( + + ) : null + } + > + + + ) : ( - - ) : ( - - )} + )} + - - } - twClassName="pl-1 pr-3" - > - - navigation.navigate(...createAccountSelectorNavDetails({})) } - testID={WalletViewSelectorsIDs.ACCOUNT_ICON} - hitSlop={touchAreaSlop} - style={styles.headerAccountPickerStyle} - /> - + twClassName="pl-1 pr-3" + > + + navigation.navigate( + ...createAccountSelectorNavDetails({}), + ) + } + testID={WalletViewSelectorsIDs.ACCOUNT_ICON} + hitSlop={touchAreaSlop} + style={styles.headerAccountPickerStyle} + /> + + - - ), - }} - > - {content} - + {isHomepageSectionsV1Enabled ? ( + <> + {isFocused && ( + + )} + + {isDiscoveryTabsTreatment ? ( + + } + /> + ) : ( + + ), + }} + > + {portfolioHeaderBase} + + + )} + + + ) : ( + + ), + }} + > + {content} + + )} ) : ( diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts index a4ec0278757b..5914481b92dc 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.test.ts @@ -234,6 +234,48 @@ describe('useTransactionPayMetrics', () => { }); }); + it('includes simulation_sending_assets_total_value for money account deposit', async () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN_MOCK, + setPayToken: noop, + } as ReturnType); + + runHook({ type: TransactionType.moneyAccountDeposit }); + + await act(async () => noop()); + + expect(updateConfirmationMetricMock).toHaveBeenCalledWith({ + id: transactionIdMock, + params: { + properties: expect.objectContaining({ + simulation_sending_assets_total_value: 1.23, + }), + sensitiveProperties: {}, + }, + }); + }); + + it('omits simulation_sending_assets_total_value for money account withdraw', async () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN_MOCK, + setPayToken: noop, + } as ReturnType); + + runHook({ type: TransactionType.moneyAccountWithdraw }); + + await act(async () => noop()); + + const calledProps = ( + updateConfirmationMetricMock.mock.calls[0]?.[0] as { + params: { properties: Record }; + } + )?.params?.properties; + + expect(calledProps).not.toHaveProperty( + 'simulation_sending_assets_total_value', + ); + }); + describe('mm_pay_quote_requested', () => { it('is false initially', async () => { useTransactionPayTokenMock.mockReturnValue({ diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts index 283e436f2afa..0a06177a7c07 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayMetrics.ts @@ -87,7 +87,10 @@ export function useTransactionPayMetrics() { if ( payToken && (hasTransactionType(transactionMeta, [TransactionType.perpsDeposit]) || - hasTransactionType(transactionMeta, [TransactionType.predictDeposit])) + hasTransactionType(transactionMeta, [TransactionType.predictDeposit]) || + hasTransactionType(transactionMeta, [ + TransactionType.moneyAccountDeposit, + ])) ) { properties.simulation_sending_assets_total_value = sendingValue; } diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index c3851369f74e..0593e003de7a 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -1,4 +1,5 @@ const Routes = { + HOME_TABS: 'Home', WALLET_VIEW: 'WalletView', BROWSER_TAB_HOME: 'BrowserTabHome', BROWSER_VIEW: 'BrowserView', diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 936ba0b0ae00..b2d02a781cb2 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -663,6 +663,9 @@ enum EVENT_NAME { // Explore Search EXPLORE_SEARCH_INTERACTED = 'Explore Search Interacted', + // Explore + EXPLORE_INTERACTED = 'Explore Page Interacted', + // Market Insights MARKET_INSIGHTS_CARD_SCROLLED_TO_VIEW = 'Market Insights Card Scrolled to View', MARKET_INSIGHTS_OPENED = 'Market Insights Opened', @@ -1772,6 +1775,8 @@ const events = { EXPLORE_SEARCH_INTERACTED: generateOpt(EVENT_NAME.EXPLORE_SEARCH_INTERACTED), + EXPLORE_INTERACTED: generateOpt(EVENT_NAME.EXPLORE_INTERACTED), + // Share SHARE_ACTION: generateOpt(EVENT_NAME.SHARE_ACTION), diff --git a/app/core/Analytics/MetaMetrics.types.ts b/app/core/Analytics/MetaMetrics.types.ts index abb4658e3b58..1b663295cf6a 100644 --- a/app/core/Analytics/MetaMetrics.types.ts +++ b/app/core/Analytics/MetaMetrics.types.ts @@ -161,6 +161,7 @@ export enum MonetizedPrimitive { Ramps = 'ramps', Predict = 'predict', MmPay = 'mm_pay', + MoneyAccount = 'money_account', } /** diff --git a/app/core/Analytics/events/transactions/utils.ts b/app/core/Analytics/events/transactions/utils.ts index 08ee3f2c01aa..f531ff786b1e 100644 --- a/app/core/Analytics/events/transactions/utils.ts +++ b/app/core/Analytics/events/transactions/utils.ts @@ -23,6 +23,9 @@ export function getMonetizedPrimitive( case TransactionType.predictWithdraw: case TransactionType.predictClaim: return MonetizedPrimitive.Predict; + case TransactionType.moneyAccountDeposit: + case TransactionType.moneyAccountWithdraw: + return MonetizedPrimitive.MoneyAccount; default: return undefined; } diff --git a/app/core/Engine/Engine.test.ts b/app/core/Engine/Engine.test.ts index 5da3501da786..bbc39fb7e3ca 100644 --- a/app/core/Engine/Engine.test.ts +++ b/app/core/Engine/Engine.test.ts @@ -1368,4 +1368,17 @@ describe('Engine', () => { expect(sortedControllersInState).toEqual(sortedExpectedControllers); }); }); + + describe('resetState', () => { + it('calls MoneyAccountController.clearState', async () => { + const engine = Engine.init(TEST_ANALYTICS_ID, backgroundState); + const clearStateSpy = jest + .spyOn(engine.context.MoneyAccountController, 'clearState') + .mockImplementation(() => undefined); + + await engine.resetState(); + + expect(clearStateSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 9162ca524714..0b9bcc0adfcc 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -1159,6 +1159,7 @@ export class Engine { SnapController, ///: END:ONLY_INCLUDE_IF LoggingController, + MoneyAccountController, } = this.context; // Remove all permissions. @@ -1189,6 +1190,9 @@ export class Engine { })); LoggingController.clear(); + + // Accounts: + MoneyAccountController.clearState(); }; removeAllListeners() { diff --git a/app/core/Engine/controllers/keyring-controller/keyring-controller-init.test.ts b/app/core/Engine/controllers/keyring-controller/keyring-controller-init.test.ts index 63b5bf74af5b..f2cdc3d0576e 100644 --- a/app/core/Engine/controllers/keyring-controller/keyring-controller-init.test.ts +++ b/app/core/Engine/controllers/keyring-controller/keyring-controller-init.test.ts @@ -1,12 +1,4 @@ import { buildMessengerClientInitRequestMock } from '../../utils/test-utils'; - -jest.mock('../../../../lib/Money/feature-flags', () => ({ - isMoneyAccountEnabled: jest.fn(), -})); - -const mockIsMoneyAccountEnabled = jest.requireMock( - '../../../../lib/Money/feature-flags', -).isMoneyAccountEnabled as jest.Mock; import { ExtendedMessenger } from '../../../ExtendedMessenger'; import { getKeyringControllerMessenger } from '../../messengers/keyring-controller-messenger'; import { MessengerClientInitRequest } from '../../types'; @@ -72,7 +64,6 @@ function getInitRequestMock(): jest.Mocked< describe('keyringControllerInit', () => { beforeEach(() => { jest.clearAllMocks(); - mockIsMoneyAccountEnabled.mockReturnValue(true); }); it('initializes the controller', () => { @@ -108,26 +99,12 @@ describe('keyringControllerInit', () => { return builder; } - it('includes a MoneyKeyring builder when the flag is enabled', () => { - mockIsMoneyAccountEnabled.mockReturnValue(true); - + it('always includes a MoneyKeyring builder', () => { const builder = getMoneyKeyringBuilder(); expect(builder).toBeDefined(); }); - it('does not include a MoneyKeyring builder when the flag is disabled', () => { - mockIsMoneyAccountEnabled.mockReturnValue(false); - - keyringControllerInit(getInitRequestMock()); - - const { keyringBuilders } = jest.mocked(KeyringController).mock - .calls[0][0] as { keyringBuilders: KeyringBuilder[] }; - - const builder = keyringBuilders.find((b) => b.type === MoneyKeyring.type); - expect(builder).toBeUndefined(); - }); - it('creates a MoneyKeyring instance when invoked', () => { const builder = getMoneyKeyringBuilder(); diff --git a/app/core/Engine/controllers/keyring-controller/keyring-controller-init.ts b/app/core/Engine/controllers/keyring-controller/keyring-controller-init.ts index f134b2f89223..514fed8f2786 100644 --- a/app/core/Engine/controllers/keyring-controller/keyring-controller-init.ts +++ b/app/core/Engine/controllers/keyring-controller/keyring-controller-init.ts @@ -1,5 +1,4 @@ import { MessengerClientInitFunction } from '../../types'; -import { isMoneyAccountEnabled } from '../../../../lib/Money/feature-flags'; import { CryptographicFunctions } from '@metamask/key-tree'; import { encodeMnemonic } from '@metamask/keyring-sdk'; import { @@ -44,10 +43,6 @@ export const keyringControllerInit: MessengerClientInitFunction< qrKeyringScanner, getMessengerClient, }) => { - const { remoteFeatureFlags } = getMessengerClient( - 'RemoteFeatureFlagController', - ).state; - // Required by the HD keyring and money keyring to use native crypto functions. const cryptographicFunctions: CryptographicFunctions = { pbkdf2Sha512: pbkdf2, @@ -81,35 +76,34 @@ export const keyringControllerInit: MessengerClientInitFunction< hdKeyringBuilder.type = HdKeyring.type; additionalKeyrings.push(hdKeyringBuilder); - // We only need this keyring if Money accounts are enabled. - if (isMoneyAccountEnabled(remoteFeatureFlags)) { - const moneyKeyringBuilder = () => - new MoneyKeyring({ - cryptographicFunctions, - getMnemonic: async (entropySource: string) => - // This builder needs the controller itself, so we re-use `getMessengerClient` to access - // the controller instance as it will be available when this method gets called. - // NOTE: This is required since we cannot self-use our own actions with the init messenger. - getMessengerClient('KeyringController').withKeyringUnsafe( - { - filter: (keyring, metadata): keyring is HdKeyring => - keyring.type === KeyringTypes.hd && - metadata.id === entropySource, - }, - async ({ keyring }) => { - if (!keyring?.mnemonic) { - throw new Error( - `Unable to get mnemonic to initialize MoneyKeyring`, - ); - } + // The builder is always registered so the KeyringController can recognise the + // MoneyKeyring type during vault deserialization (even if the feature flag is + // disabled at that time). + const moneyKeyringBuilder = () => + new MoneyKeyring({ + cryptographicFunctions, + getMnemonic: async (entropySource: string) => + // This builder needs the controller itself, so we re-use `getMessengerClient` to access + // the controller instance as it will be available when this method gets called. + // NOTE: This is required since we cannot self-use our own actions with the init messenger. + getMessengerClient('KeyringController').withKeyringUnsafe( + { + filter: (keyring, metadata): keyring is HdKeyring => + keyring.type === KeyringTypes.hd && metadata.id === entropySource, + }, + async ({ keyring }) => { + if (!keyring?.mnemonic) { + throw new Error( + `Unable to get mnemonic to initialize MoneyKeyring`, + ); + } - return encodeMnemonic(keyring.mnemonic); - }, - ), - }); - moneyKeyringBuilder.type = MoneyKeyring.type; - additionalKeyrings.push(moneyKeyringBuilder); - } + return encodeMnemonic(keyring.mnemonic); + }, + ), + }); + moneyKeyringBuilder.type = MoneyKeyring.type; + additionalKeyrings.push(moneyKeyringBuilder); ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) const snapKeyringBuilder = getMessengerClient('SnapKeyringBuilder'); diff --git a/app/core/Engine/controllers/money-account-controller-init.test.ts b/app/core/Engine/controllers/money-account-controller-init.test.ts index 68f948762dd3..4f120d04acfc 100644 --- a/app/core/Engine/controllers/money-account-controller-init.test.ts +++ b/app/core/Engine/controllers/money-account-controller-init.test.ts @@ -1,6 +1,10 @@ import { buildMessengerClientInitRequestMock } from '../utils/test-utils'; import { ExtendedMessenger } from '../../ExtendedMessenger'; -import { getMoneyAccountControllerMessenger } from '../messengers/money-account-controller-messenger'; +import { + getMoneyAccountControllerInitMessenger, + getMoneyAccountControllerMessenger, + MoneyAccountControllerInitMessenger, +} from '../messengers/money-account-controller-messenger'; import { MessengerClientInitRequest } from '../types'; import { moneyAccountControllerInit } from './money-account-controller-init'; import { @@ -8,31 +12,86 @@ import { MoneyAccountControllerMessenger, } from '@metamask/money-account-controller'; import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; +import { RemoteFeatureFlagControllerStateChangeEvent } from '@metamask/remote-feature-flag-controller'; +import { isMoneyAccountEnabled } from '../../../lib/Money/feature-flags'; +import Logger from '../../../util/Logger'; jest.mock('@metamask/money-account-controller'); +jest.mock('../../../lib/Money/feature-flags'); +jest.mock('../../../util/Logger'); + +const EMPTY_MONEY_ACCOUNTS = { moneyAccounts: {} }; +const NON_EMPTY_MONEY_ACCOUNTS = { moneyAccounts: { 'mock-account-id': {} } }; -function getInitRequestMock(): jest.Mocked< - MessengerClientInitRequest -> { - const baseMessenger = new ExtendedMessenger({ +function buildInitRequestMock< + Events extends RemoteFeatureFlagControllerStateChangeEvent = never, +>( + baseMessenger = new ExtendedMessenger({ namespace: MOCK_ANY_NAMESPACE, - }); + }), +): { + requestMock: jest.Mocked< + MessengerClientInitRequest< + MoneyAccountControllerMessenger, + MoneyAccountControllerInitMessenger + > + >; + baseMessenger: ExtendedMessenger; +} { + baseMessenger.registerActionHandler( + // @ts-expect-error: Action not allowed on root messenger. + 'RemoteFeatureFlagController:getState', + jest.fn().mockReturnValue({ remoteFeatureFlags: {} }), + ); - return { + baseMessenger.registerActionHandler( + // @ts-expect-error: Action not allowed on root messenger. + 'KeyringController:getState', + jest.fn().mockReturnValue({ isUnlocked: true }), + ); + + const requestMock = { ...buildMessengerClientInitRequestMock(baseMessenger), controllerMessenger: getMoneyAccountControllerMessenger(baseMessenger), - initMessenger: undefined, - }; + initMessenger: getMoneyAccountControllerInitMessenger(baseMessenger), + } as jest.Mocked< + MessengerClientInitRequest< + MoneyAccountControllerMessenger, + MoneyAccountControllerInitMessenger + > + >; + + return { requestMock, baseMessenger }; +} + +function publishStateChange( + baseMessenger: ExtendedMessenger< + MockAnyNamespace, + never, + RemoteFeatureFlagControllerStateChangeEvent + >, +) { + baseMessenger.publish( + 'RemoteFeatureFlagController:stateChange', + { remoteFeatureFlags: {}, cacheTimestamp: 0 }, + [], + ); } describe('moneyAccountControllerInit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('initializes the controller', () => { - const { controller } = moneyAccountControllerInit(getInitRequestMock()); + const { requestMock } = buildInitRequestMock(); + const { controller } = moneyAccountControllerInit(requestMock); expect(controller).toBeInstanceOf(MoneyAccountController); }); it('passes the proper arguments to the controller', () => { - moneyAccountControllerInit(getInitRequestMock()); + const { requestMock } = buildInitRequestMock(); + moneyAccountControllerInit(requestMock); const controllerMock = jest.mocked(MoneyAccountController); expect(controllerMock).toHaveBeenCalledWith({ @@ -40,4 +99,116 @@ describe('moneyAccountControllerInit', () => { state: undefined, }); }); + + describe('RemoteFeatureFlagController:stateChange subscription', () => { + function buildStateChangeSetup() { + const baseMessenger = new ExtendedMessenger< + MockAnyNamespace, + never, + RemoteFeatureFlagControllerStateChangeEvent + >({ namespace: MOCK_ANY_NAMESPACE }); + + const { requestMock } = buildInitRequestMock(baseMessenger); + return { requestMock, baseMessenger }; + } + + it('calls controller.init() when flag is enabled and keyring is unlocked', async () => { + jest.mocked(isMoneyAccountEnabled).mockReturnValue(true); + + const { requestMock, baseMessenger } = buildStateChangeSetup(); + const { controller } = moneyAccountControllerInit(requestMock); + (controller as unknown as { state: unknown }).state = + EMPTY_MONEY_ACCOUNTS; + + publishStateChange(baseMessenger); + await Promise.resolve(); + + expect(jest.mocked(controller.init)).toHaveBeenCalledTimes(1); + }); + + it('does not call controller.init() when flag is enabled but keyring is locked', async () => { + jest.mocked(isMoneyAccountEnabled).mockReturnValue(true); + + const { requestMock, baseMessenger } = buildStateChangeSetup(); + + baseMessenger.unregisterActionHandler( + // @ts-expect-error: Action not allowed on root messenger. + 'KeyringController:getState', + ); + baseMessenger.registerActionHandler( + // @ts-expect-error: Action not allowed on root messenger. + 'KeyringController:getState', + jest.fn().mockReturnValue({ isUnlocked: false }), + ); + + const { controller } = moneyAccountControllerInit(requestMock); + (controller as unknown as { state: unknown }).state = + EMPTY_MONEY_ACCOUNTS; + + publishStateChange(baseMessenger); + await Promise.resolve(); + + expect(jest.mocked(controller.init)).not.toHaveBeenCalled(); + }); + + it('does not call controller.init() when flag is enabled but money account already exists', async () => { + jest.mocked(isMoneyAccountEnabled).mockReturnValue(true); + + const { requestMock, baseMessenger } = buildStateChangeSetup(); + const { controller } = moneyAccountControllerInit(requestMock); + (controller as unknown as { state: unknown }).state = + NON_EMPTY_MONEY_ACCOUNTS; + + publishStateChange(baseMessenger); + await Promise.resolve(); + + expect(jest.mocked(controller.init)).not.toHaveBeenCalled(); + }); + + it('calls controller.clearState() when flag is disabled and money accounts exist', async () => { + jest.mocked(isMoneyAccountEnabled).mockReturnValue(false); + + const { requestMock, baseMessenger } = buildStateChangeSetup(); + const { controller } = moneyAccountControllerInit(requestMock); + (controller as unknown as { state: unknown }).state = + NON_EMPTY_MONEY_ACCOUNTS; + + publishStateChange(baseMessenger); + await Promise.resolve(); + + expect(jest.mocked(controller.clearState)).toHaveBeenCalledTimes(1); + }); + + it('does not call controller.clearState() when flag is disabled and no money accounts exist', async () => { + jest.mocked(isMoneyAccountEnabled).mockReturnValue(false); + + const { requestMock, baseMessenger } = buildStateChangeSetup(); + const { controller } = moneyAccountControllerInit(requestMock); + (controller as unknown as { state: unknown }).state = + EMPTY_MONEY_ACCOUNTS; + + publishStateChange(baseMessenger); + await Promise.resolve(); + + expect(jest.mocked(controller.clearState)).not.toHaveBeenCalled(); + }); + + it('logs an error when the stateChange callback throws', async () => { + const error = new Error('mock error'); + jest.mocked(isMoneyAccountEnabled).mockImplementation(() => { + throw error; + }); + + const { requestMock, baseMessenger } = buildStateChangeSetup(); + moneyAccountControllerInit(requestMock); + + publishStateChange(baseMessenger); + await Promise.resolve(); + + expect(jest.mocked(Logger.error)).toHaveBeenCalledWith( + error, + 'MoneyAccountController: error handling RemoteFeatureFlagController state change', + ); + }); + }); }); diff --git a/app/core/Engine/controllers/money-account-controller-init.ts b/app/core/Engine/controllers/money-account-controller-init.ts index 48e653df89b2..c493253bed14 100644 --- a/app/core/Engine/controllers/money-account-controller-init.ts +++ b/app/core/Engine/controllers/money-account-controller-init.ts @@ -3,6 +3,9 @@ import { MoneyAccountController, MoneyAccountControllerMessenger, } from '@metamask/money-account-controller'; +import { MoneyAccountControllerInitMessenger } from '../messengers/money-account-controller-messenger'; +import { isMoneyAccountEnabled } from '../../../lib/Money/feature-flags'; +import Logger from '../../../util/Logger'; /** * Initialize the money account controller. @@ -15,12 +18,46 @@ import { */ export const moneyAccountControllerInit: MessengerClientInitFunction< MoneyAccountController, - MoneyAccountControllerMessenger -> = ({ controllerMessenger, persistedState }) => { + MoneyAccountControllerMessenger, + MoneyAccountControllerInitMessenger +> = ({ controllerMessenger, initMessenger, persistedState }) => { const controller = new MoneyAccountController({ messenger: controllerMessenger, state: persistedState.MoneyAccountController, }); + // Re-check the Money account feature flag whenever remote flags are updated. + initMessenger.subscribe( + 'RemoteFeatureFlagController:stateChange', + async ({ remoteFeatureFlags }) => { + try { + const isEnabled = isMoneyAccountEnabled(remoteFeatureFlags); + const hasMoneyAccount = + Object.keys(controller.state.moneyAccounts).length > 0; + + if (isEnabled && !hasMoneyAccount) { + const { isUnlocked } = initMessenger.call( + 'KeyringController:getState', + ); + // Check for the `KeyringController` to be unlocked, otherwise we won't be able + // to create the Money keyring if it doesn't exist yet! + if (isUnlocked) { + // This call is idempotent, so it is safe to call even if the + // controller is already initialized. + await controller.init(); + } + } else if (!isEnabled && hasMoneyAccount) { + // Clear state if we had a previous Money account and FF is off. + controller.clearState(); + } + } catch (error) { + Logger.error( + error as Error, + 'MoneyAccountController: error handling RemoteFeatureFlagController state change', + ); + } + }, + ); + return { controller }; }; diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/base.test.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/base.test.ts index acad8bf98c34..1df3ef755da2 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/base.test.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/base.test.ts @@ -104,6 +104,8 @@ describe('getTransactionTypeValue', () => { ['predict_deposit', TransactionType.predictDeposit], ['predict_withdraw', TransactionType.predictWithdraw], ['perps_withdraw', TransactionType.perpsWithdraw], + ['money_account_deposit', TransactionType.moneyAccountDeposit], + ['money_account_withdraw', TransactionType.moneyAccountWithdraw], ['musd_conversion', TransactionType.musdConversion], ['musd_claim', TransactionType.musdClaim], ])('returns %s if nested transaction type is %s', (expected, nestedType) => { diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/base.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/base.ts index dd1abdae43bf..f3892d459c0d 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/base.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/base.ts @@ -74,6 +74,18 @@ export function getTransactionTypeValue( return 'predict_claim'; } + if ( + hasTransactionType(transactionMeta, [TransactionType.moneyAccountDeposit]) + ) { + return 'money_account_deposit'; + } + + if ( + hasTransactionType(transactionMeta, [TransactionType.moneyAccountWithdraw]) + ) { + return 'money_account_withdraw'; + } + if (hasTransactionType(transactionMeta, [TransactionType.musdConversion])) { return 'musd_conversion'; } diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts index df0f7c4c6208..d849fea411d8 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts @@ -83,6 +83,75 @@ describe('Metamask Pay Metrics', () => { }); }); + it.each([ + TransactionType.moneyAccountDeposit, + TransactionType.moneyAccountWithdraw, + ])('returns nothing if %s without controller state', (type) => { + request.transactionMeta.type = type; + + const result = getMetaMaskPayProperties(request); + + expect(result).toStrictEqual({ + properties: {}, + sensitiveProperties: {}, + }); + }); + + it.each([ + [TransactionType.moneyAccountDeposit, 'money_account_deposit'], + [TransactionType.moneyAccountWithdraw, 'money_account_withdraw'], + ])( + 'derives mm_pay_use_case=%s for %s parent', + (parentType, expectedUseCase) => { + getStateMock.mockReturnValue({ + engine: { + backgroundState: { + TokensController: { allTokens: {} }, + TransactionPayController: { + transactionData: { + 'parent-1': { + paymentToken: { symbol: 'USDC', chainId: '0x1' }, + quotes: [{ strategy: TransactionPayStrategy.Relay }], + tokens: [{ skipIfBalance: false, amountUsd: '50' }], + totals: { + targetAmount: { usd: '49.5', fiat: '49.5' }, + fees: { + metaMask: { usd: '0', fiat: '0' }, + provider: { usd: '0.2', fiat: '0.2' }, + sourceNetwork: { estimate: { usd: '0.1', fiat: '0.1' } }, + targetNetwork: { usd: '0', fiat: '0' }, + }, + }, + }, + }, + }, + }, + }, + } as never); + + request.allTransactions = [ + { + id: 'parent-1', + type: parentType, + metamaskPay: { chainId: '0x1', tokenAddress: '0xA0b8' }, + requiredTransactionIds: ['child-1'], + } as unknown as TransactionMeta, + ]; + + const result = getMetaMaskPayProperties(request); + + expect(result).toStrictEqual({ + properties: expect.objectContaining({ + mm_pay: true, + mm_pay_use_case: expectedUseCase, + mm_pay_token_selected: 'USDC', + mm_pay_chain_selected: '0x1', + }), + sensitiveProperties: {}, + }); + }, + ); + it('derives parent mm_pay_* properties for child transaction from controller state', () => { getStateMock.mockReturnValue({ engine: { diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts index 72fe5cebe673..11e090c05318 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts @@ -20,6 +20,8 @@ import { BigNumber } from 'bignumber.js'; const FOUR_BYTE_SAFE_PROXY_CREATE = '0xa1884d2c'; const PAY_TYPES = [ + TransactionType.moneyAccountDeposit, + TransactionType.moneyAccountWithdraw, TransactionType.perpsDeposit, TransactionType.perpsWithdraw, TransactionType.predictDeposit, @@ -31,6 +33,8 @@ const USE_CASE_MAP: [TransactionType[], string][] = [ [[TransactionType.predictDeposit], 'predict_deposit'], [[TransactionType.perpsDeposit], 'perps_deposit'], [[TransactionType.perpsWithdraw], 'perps_withdraw'], + [[TransactionType.moneyAccountDeposit], 'money_account_deposit'], + [[TransactionType.moneyAccountWithdraw], 'money_account_withdraw'], ]; export const getMetaMaskPayProperties: TransactionMetricsBuilder = ({ diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.test.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.test.ts index f4526a23efa2..ad5180be115a 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.test.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.test.ts @@ -108,4 +108,34 @@ describe('getSwapTransactionActiveAbTestProperties', () => { sensitiveProperties: {}, }); }); + + it.each([ + TransactionType.moneyAccountDeposit, + TransactionType.moneyAccountWithdraw, + ])('returns active_ab_tests for %s Transaction Added', (type) => { + const abTests = [ + { key: 'homeTMCU470AbtestTrendingSections', value: 'trendingSections' }, + ]; + registerTransactionAbTestAttributionForIds([TX_ID], abTests); + const request = createMockRequest({ + transactionMeta: { + id: TX_ID, + type, + } as never, + }); + + expect(getSwapTransactionActiveAbTestProperties(request)).toEqual({ + properties: { + active_ab_tests: [ + { + key: 'homeTMCU470AbtestTrendingSections', + value: 'trendingSections', + key_value_pair: + 'homeTMCU470AbtestTrendingSections=trendingSections', + }, + ], + }, + sensitiveProperties: {}, + }); + }); }); diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.ts index 1cd60d3b5c2f..420d1732f359 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/swap-transaction-ab-tests.ts @@ -18,6 +18,8 @@ const TRANSACTION_TYPES_FOR_ACTIVE_AB_TESTS: ReadonlySet = TransactionType.swapApproval, TransactionType.swapAndSend, TransactionType.bridgeApproval, + TransactionType.moneyAccountDeposit, + TransactionType.moneyAccountWithdraw, TransactionType.perpsAcrossDeposit, TransactionType.perpsDeposit, TransactionType.perpsDepositAndOrder, diff --git a/app/core/Engine/messengers/index.ts b/app/core/Engine/messengers/index.ts index 1a3bc7ee7556..ae40559346eb 100644 --- a/app/core/Engine/messengers/index.ts +++ b/app/core/Engine/messengers/index.ts @@ -112,7 +112,10 @@ import { } from './identity/user-storage-controller-messenger'; import { getAuthenticationControllerMessenger } from './identity/authentication-controller-messenger'; import { getEarnControllerMessenger } from './earn-controller-messenger'; -import { getMoneyAccountControllerMessenger } from './money-account-controller-messenger'; +import { + getMoneyAccountControllerInitMessenger, + getMoneyAccountControllerMessenger, +} from './money-account-controller-messenger'; import { getMoneyAccountBalanceServiceMessenger } from './money-account-balance-service-messenger'; import { getGeolocationApiServiceMessenger } from './geolocation-api-service-messenger'; import { getGeolocationControllerMessenger } from './geolocation-controller-messenger'; @@ -340,7 +343,7 @@ export const MESSENGER_FACTORIES = { }, MoneyAccountController: { getMessenger: getMoneyAccountControllerMessenger, - getInitMessenger: noop, + getInitMessenger: getMoneyAccountControllerInitMessenger, }, MultichainTransactionsController: { getMessenger: getMultichainTransactionsControllerMessenger, diff --git a/app/core/Engine/messengers/money-account-controller-messenger.ts b/app/core/Engine/messengers/money-account-controller-messenger.ts index 114fc6d5352c..9d6627726973 100644 --- a/app/core/Engine/messengers/money-account-controller-messenger.ts +++ b/app/core/Engine/messengers/money-account-controller-messenger.ts @@ -4,6 +4,12 @@ import { MessengerEvents, } from '@metamask/messenger'; import { MoneyAccountControllerMessenger } from '@metamask/money-account-controller'; +import { + RemoteFeatureFlagControllerGetStateAction, + RemoteFeatureFlagControllerState, +} from '@metamask/remote-feature-flag-controller'; +import { KeyringControllerGetStateAction } from '@metamask/keyring-controller'; +import { ControllerStateChangeEvent } from '@metamask/base-controller'; import { RootMessenger } from '../types'; /** @@ -35,3 +41,48 @@ export function getMoneyAccountControllerMessenger( return messenger; } + +type AllowedInitializationActions = + | RemoteFeatureFlagControllerGetStateAction + | KeyringControllerGetStateAction; + +type AllowedInitializationEvents = ControllerStateChangeEvent< + 'RemoteFeatureFlagController', + RemoteFeatureFlagControllerState +>; + +export type MoneyAccountControllerInitMessenger = ReturnType< + typeof getMoneyAccountControllerInitMessenger +>; + +/** + * Get the messenger for the money account controller initialization. This is + * scoped to the actions and events needed during initialization. + * + * @param rootMessenger - The root messenger. + * @returns The MoneyAccountControllerInitMessenger. + */ +export function getMoneyAccountControllerInitMessenger( + rootMessenger: RootMessenger, +) { + const messenger = new Messenger< + 'MoneyAccountControllerInit', + AllowedInitializationActions, + AllowedInitializationEvents, + RootMessenger + >({ + namespace: 'MoneyAccountControllerInit', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: [ + 'RemoteFeatureFlagController:getState', + 'KeyringController:getState', + ], + events: ['RemoteFeatureFlagController:stateChange'], + messenger, + }); + + return messenger; +} diff --git a/app/core/HardwareWallet/HardwareWalletProvider.tsx b/app/core/HardwareWallet/HardwareWalletProvider.tsx index 7af5f7add2f9..d5ebe72d2c73 100644 --- a/app/core/HardwareWallet/HardwareWalletProvider.tsx +++ b/app/core/HardwareWallet/HardwareWalletProvider.tsx @@ -88,6 +88,7 @@ export const HardwareWalletProvider: React.FC = ({ const awaitingConfirmationRejectRef = useRef<(() => void) | null>(null); const operationTypeRef = useRef<'transaction' | 'message' | null>(null); + const qrScanRetryHandlerRef = useRef<(() => void) | null>(null); const [analyticsFlow, setAnalyticsFlow] = useState( HardwareWalletAnalyticsFlow.Connection, @@ -136,6 +137,10 @@ export const HardwareWalletProvider: React.FC = ({ [handleError], ); + const setQrScanRetryHandler = useCallback((handler: (() => void) | null) => { + qrScanRetryHandlerRef.current = handler; + }, []); + const showAwaitingConfirmation = useCallback( (operationType: 'transaction' | 'message', onReject?: () => void) => { DevLogger.log( @@ -187,6 +192,26 @@ export const HardwareWalletProvider: React.FC = ({ } await retryEnsureDeviceReady(); }, [handleCloseFlow, retryEnsureDeviceReady]); + + const handleRetryQrScan = useCallback(() => { + if (operationTypeRef.current !== null) { + updateConnectionState({ + status: ConnectionStatus.AwaitingConfirmation, + deviceId: deviceId ?? 'unknown', + operationType: operationTypeRef.current, + }); + return; + } + + const retryQrScan = qrScanRetryHandlerRef.current; + updateConnectionState({ status: ConnectionStatus.Disconnected }); + if (!retryQrScan) { + return; + } + + retryQrScan(); + }, [deviceId, updateConnectionState]); + const handleAwaitingConfirmationCancel = useCallback(() => { DevLogger.log('[HardwareWallet] handleAwaitingConfirmationCancel'); const onReject = awaitingConfirmationRejectRef.current; @@ -248,6 +273,7 @@ export const HardwareWalletProvider: React.FC = ({ setTargetWalletType: setters.setTargetWalletType, setPendingOperationAddress, showHardwareWalletError, + setQrScanRetryHandler, showAwaitingConfirmation, hideAwaitingConfirmation, qr: qrSigningValue, @@ -261,6 +287,7 @@ export const HardwareWalletProvider: React.FC = ({ setters.setTargetWalletType, setPendingOperationAddress, showHardwareWalletError, + setQrScanRetryHandler, showAwaitingConfirmation, hideAwaitingConfirmation, qrSigningValue, @@ -282,6 +309,7 @@ export const HardwareWalletProvider: React.FC = ({ onAwaitingConfirmationCancel={handleAwaitingConfirmationCancel} onConnectionSuccess={handleBottomSheetConnectionSuccess} onCTAClicked={trackCTAClicked} + onRetryQrScan={handleRetryQrScan} /> ); diff --git a/app/core/HardwareWallet/adapters/QRWalletAdapter.test.ts b/app/core/HardwareWallet/adapters/QRWalletAdapter.test.ts index 39a73c4f63f4..b02fe0293385 100644 --- a/app/core/HardwareWallet/adapters/QRWalletAdapter.test.ts +++ b/app/core/HardwareWallet/adapters/QRWalletAdapter.test.ts @@ -128,13 +128,11 @@ describe('QRWalletAdapter', () => { ).rejects.toThrow('Adapter has been destroyed'); }); - it('returns true and emits AppOpened (QR wallets are always ready)', async () => { + it('returns true when camera permission is granted (QR wallets are always ready)', async () => { const result = await adapter.ensureDeviceReady('qr-account-address'); expect(result).toBe(true); - expect(onDeviceEvent).toHaveBeenCalledWith({ - event: DeviceEvent.AppOpened, - }); + expect(onDeviceEvent).not.toHaveBeenCalled(); }); it('stores device ID', async () => { @@ -150,6 +148,7 @@ describe('QRWalletAdapter', () => { const result = await adapter.ensureDeviceReady('qr-account-address'); expect(result).toBe(false); + expect(mockRequestCameraPermission).not.toHaveBeenCalled(); expect(adapter.getConnectedDeviceId()).toBeNull(); expect(adapter.isConnected()).toBe(false); expect(onDeviceEvent).toHaveBeenCalledWith({ diff --git a/app/core/HardwareWallet/adapters/QRWalletAdapter.ts b/app/core/HardwareWallet/adapters/QRWalletAdapter.ts index 17ea1f0f8646..e76f18e6b818 100644 --- a/app/core/HardwareWallet/adapters/QRWalletAdapter.ts +++ b/app/core/HardwareWallet/adapters/QRWalletAdapter.ts @@ -114,12 +114,6 @@ export class QRWalletAdapter implements HardwareWalletAdapter { DevLogger.log('[QRWalletAdapter] Device is ready'); - // For QR wallets, we consider the "app" to always be open - // since there's no app concept like on Ledger - this.#emitEvent({ - event: DeviceEvent.AppOpened, - }); - return true; } @@ -271,12 +265,8 @@ export class QRWalletAdapter implements HardwareWalletAdapter { if (newStatus === 'granted') { return true; } - - this.#emitCameraPermissionDenied(); - return false; } - // status === 'denied' - emit error event this.#emitCameraPermissionDenied(); return false; } catch (error) { diff --git a/app/core/HardwareWallet/analytics/helpers.test.ts b/app/core/HardwareWallet/analytics/helpers.test.ts index 6f17db5f33c6..2f83faf78f53 100644 --- a/app/core/HardwareWallet/analytics/helpers.test.ts +++ b/app/core/HardwareWallet/analytics/helpers.test.ts @@ -16,7 +16,10 @@ import { getAnalyticsDeviceType, getErrorDetails, getAnalyticsFlowFromApproval, + getQrHardwareScanErrorAnalyticsProperties, } from './helpers'; +import { createQRHardwareScanError, QRHardwareScanErrorType } from '../errors'; +import { QrScanRequestType } from '@metamask/eth-qr-keyring'; describe('analytics helpers', () => { describe('getAnalyticsErrorType', () => { @@ -362,4 +365,105 @@ describe('analytics helpers', () => { ); }); }); + + describe('getQrHardwareScanErrorAnalyticsProperties', () => { + it('returns empty object for non-ErrorState', () => { + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.Disconnected, + }; + + expect(getQrHardwareScanErrorAnalyticsProperties(state)).toEqual({}); + }); + + it('returns empty object for ErrorState with non-QR error', () => { + const error = new HardwareWalletError('Test', { + code: ErrorCode.Unknown, + severity: Severity.Err, + category: Category.Connection, + userMessage: 'Test', + }); + + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error, + }; + + expect(getQrHardwareScanErrorAnalyticsProperties(state)).toEqual({}); + }); + + it('returns QR scan properties for non-UR QR scanned error', () => { + const qrError = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.NonURQrScanned, + purpose: QrScanRequestType.SIGN, + isUrFormat: false, + }); + + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: qrError, + }; + + const result = getQrHardwareScanErrorAnalyticsProperties(state); + expect(result).toEqual({ + error_category: 'non_ur_qr_scanned', + is_ur_format: false, + }); + }); + + it('returns received_ur_type for wrong UR type error', () => { + const qrError = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.WrongURType, + purpose: QrScanRequestType.SIGN, + receivedUrType: 'crypto-account', + isUrFormat: true, + }); + + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: qrError, + }; + + const result = getQrHardwareScanErrorAnalyticsProperties(state); + expect(result).toEqual({ + error_category: 'wrong_ur_type', + is_ur_format: true, + received_ur_type: 'crypto-account', + }); + }); + + it('returns empty string for received_ur_type when not provided', () => { + const qrError = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.WrongURType, + purpose: QrScanRequestType.PAIR, + isUrFormat: true, + }); + + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: qrError, + }; + + const result = getQrHardwareScanErrorAnalyticsProperties(state); + expect(result.received_ur_type).toBe(''); + }); + + it('returns QR scan properties for UR decode error', () => { + const qrError = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.URDecodeError, + purpose: QrScanRequestType.SIGN, + isUrFormat: true, + }); + + const state: HardwareWalletConnectionState = { + status: ConnectionStatus.ErrorState, + error: qrError, + }; + + const result = getQrHardwareScanErrorAnalyticsProperties(state); + expect(result).toEqual({ + error_category: 'ur_decode_error', + is_ur_format: true, + }); + }); + }); }); diff --git a/app/core/HardwareWallet/analytics/helpers.ts b/app/core/HardwareWallet/analytics/helpers.ts index 45cec0232785..7d3bb9fe884c 100644 --- a/app/core/HardwareWallet/analytics/helpers.ts +++ b/app/core/HardwareWallet/analytics/helpers.ts @@ -4,6 +4,7 @@ import { HardwareWalletConnectionState, ConnectionStatus, } from '@metamask/hw-wallet-sdk'; +import { isQRHardwareScanError, QRHardwareScanErrorType } from '../errors'; import { ApprovalType } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; @@ -186,3 +187,31 @@ export function getErrorDetails( } return { error_code: '', error_message: '' }; } + +/** + * Segment/MetaMetrics properties for QR hardware camera scan failures + * (`Hardware Wallet Connection Failed` / recovery UI), when the connection + * {@link ConnectionStatus.ErrorState} error is a {@link isQRHardwareScanError}. + */ +export function getQrHardwareScanErrorAnalyticsProperties( + connectionState: HardwareWalletConnectionState, +): Record { + if (connectionState.status !== ConnectionStatus.ErrorState) { + return {}; + } + const { error } = connectionState; + if (!isQRHardwareScanError(error)) { + return {}; + } + const metadata = error.metadata; + const payload: Record = { + error_category: metadata.qrHardwareScanErrorType, + is_ur_format: metadata.isUrFormat, + }; + if ( + metadata.qrHardwareScanErrorType === QRHardwareScanErrorType.WrongURType + ) { + payload.received_ur_type = metadata.receivedUrType ?? ''; + } + return payload; +} diff --git a/app/core/HardwareWallet/analytics/index.ts b/app/core/HardwareWallet/analytics/index.ts index 3c51279adcc1..e394183956a7 100644 --- a/app/core/HardwareWallet/analytics/index.ts +++ b/app/core/HardwareWallet/analytics/index.ts @@ -6,6 +6,7 @@ export { getErrorTypeFromConnectionState, getAnalyticsDeviceType, getErrorDetails, + getQrHardwareScanErrorAnalyticsProperties, } from './helpers'; export { useHardwareWalletAnalytics } from './useHardwareWalletAnalytics'; diff --git a/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.test.ts b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.test.ts index 4e4bd3ff5c80..0dd62771d961 100644 --- a/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.test.ts +++ b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.test.ts @@ -14,6 +14,8 @@ import { HardwareWalletAnalyticsFlow, } from './helpers'; import { MetaMetricsEvents } from '../../Analytics'; +import { createQRHardwareScanError, QRHardwareScanErrorType } from '../errors'; +import { QrScanRequestType } from '@metamask/eth-qr-keyring'; const mockTrackEvent = jest.fn(); const mockBuild = jest.fn().mockReturnValue({ name: 'built-event' }); @@ -86,6 +88,67 @@ describe('useHardwareWalletAnalytics', () => { expect(mockTrackEvent).toHaveBeenCalled(); }); + it('includes QR scan analytics when error is a QR hardware scan failure', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + const qrError = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.NonURQrScanned, + purpose: QrScanRequestType.PAIR, + isUrFormat: false, + }); + + rerender({ + ...defaultOptions, + walletType: HardwareWalletType.Qr, + connectionState: { + status: ConnectionStatus.ErrorState, + error: qrError, + }, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + error_type: HardwareWalletAnalyticsErrorType.GenericError, + error_category: 'non_ur_qr_scanned', + is_ur_format: false, + }), + ); + }); + + it('includes received_ur_type for wrong UR type QR scan errors', () => { + const { rerender } = renderHook( + (props) => useHardwareWalletAnalytics(props), + { initialProps: defaultOptions }, + ); + + const qrError = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.WrongURType, + purpose: QrScanRequestType.SIGN, + receivedUrType: 'eth-signature', + isUrFormat: true, + }); + + rerender({ + ...defaultOptions, + walletType: HardwareWalletType.Qr, + connectionState: { + status: ConnectionStatus.ErrorState, + error: qrError, + }, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + error_category: 'wrong_ur_type', + is_ur_format: true, + received_ur_type: 'eth-signature', + }), + ); + }); + it('fires when transitioning to AwaitingApp', () => { const { rerender } = renderHook( (props) => useHardwareWalletAnalytics(props), diff --git a/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.ts b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.ts index 803658f09986..b0a6ff0ddf11 100644 --- a/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.ts +++ b/app/core/HardwareWallet/analytics/useHardwareWalletAnalytics.ts @@ -12,6 +12,7 @@ import { getErrorTypeFromConnectionState, getAnalyticsDeviceType, getErrorDetails, + getQrHardwareScanErrorAnalyticsProperties, type ErrorDetails, } from './helpers'; @@ -59,6 +60,7 @@ export function useHardwareWalletAnalytics({ error_code: '', error_message: '', }); + const lastQrScanAnalyticsRef = useRef>({}); const prevStatusRef = useRef(ConnectionStatus.Disconnected); const resetAnalyticsState = useCallback(() => { @@ -66,6 +68,7 @@ export function useHardwareWalletAnalytics({ lastErrorTypeRef.current = null; lastErrorTypeViewCountRef.current = 0; lastErrorDetailsRef.current = { error_code: '', error_message: '' }; + lastQrScanAnalyticsRef.current = {}; }, []); useEffect(() => { @@ -90,6 +93,9 @@ export function useHardwareWalletAnalytics({ viewCountsRef.current.set(errorType, newCount); const errorDetails = getErrorDetails(connectionState); + const qrScanAnalytics = + getQrHardwareScanErrorAnalyticsProperties(connectionState); + lastQrScanAnalyticsRef.current = qrScanAnalytics; lastErrorTypeRef.current = errorType; lastErrorTypeViewCountRef.current = newCount; @@ -107,6 +113,7 @@ export function useHardwareWalletAnalytics({ error_type_view_count: newCount, error_code: errorDetails.error_code, error_message: errorDetails.error_message, + ...qrScanAnalytics, }) .build(), ); @@ -128,6 +135,7 @@ export function useHardwareWalletAnalytics({ error_type_view_count: lastErrorTypeViewCountRef.current, error_code: lastErrorDetailsRef.current.error_code, error_message: lastErrorDetailsRef.current.error_message, + ...lastQrScanAnalyticsRef.current, }), }) .build(), @@ -150,6 +158,8 @@ export function useHardwareWalletAnalytics({ if (!errorType) return; const errorDetails = getErrorDetails(connectionState); + const qrScanAnalytics = + getQrHardwareScanErrorAnalyticsProperties(connectionState); trackEvent( createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_RECOVERY_CTA_CLICKED) @@ -161,6 +171,7 @@ export function useHardwareWalletAnalytics({ error_type_view_count: viewCountsRef.current.get(errorType) ?? 1, error_code: errorDetails.error_code, error_message: errorDetails.error_message, + ...qrScanAnalytics, }) .build(), ); diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.test.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.test.tsx index 43acce80ba53..21b22431fe95 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.test.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.test.tsx @@ -17,6 +17,7 @@ const mockDeviceSelection = { }; const mockActions = { retryEnsureDeviceReady: jest.fn(), + onRetryQrScan: jest.fn(), selectDevice: jest.fn(), rescan: jest.fn(), connect: jest.fn(), @@ -55,34 +56,44 @@ jest.mock('./contents', () => ({ }, })); -// Track BottomSheet onClose callback -let lastBottomSheetOnClose: (() => void) | undefined; +// Track BottomSheet onClose callback (`mock*` prefix: required for use inside jest.mock factory) +const mockLastBottomSheetOnCloseRef = { + current: undefined as (() => void) | undefined, +}; // Mock bottom sheet jest.mock( '../../../../component-library/components/BottomSheets/BottomSheet', () => { + const React = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return { __esModule: true, - default: ({ - children, - testID, - onClose, - }: { - children: React.ReactNode; - testID?: string; - onClose?: () => void; - }) => { - lastBottomSheetOnClose = onClose; - return {children}; - }, + default: React.forwardRef( + ( + { + children, + testID, + onClose, + }: { + children: React.ReactNode; + testID?: string; + onClose?: () => void; + }, + _ref: React.Ref, + ) => { + // Test double: capture BottomSheet onClose for assertions (not production UI). + // eslint-disable-next-line react-compiler/react-compiler -- intentional mock side effect + mockLastBottomSheetOnCloseRef.current = onClose; + return {children}; + }, + ), }; }, ); import React from 'react'; -import { render } from '@testing-library/react-native'; +import { act, render } from '@testing-library/react-native'; import { HardwareWalletError, ErrorCode, @@ -91,12 +102,17 @@ import { ConnectionStatus, HardwareWalletType, } from '@metamask/hw-wallet-sdk'; +import { QrScanRequestType } from '@metamask/eth-qr-keyring'; import { HardwareWalletBottomSheet, HardwareWalletBottomSheetProps, HARDWARE_WALLET_BOTTOM_SHEET_TEST_ID, } from './HardwareWalletBottomSheet'; +import { + createQRHardwareScanError, + QRHardwareScanErrorType, +} from '../../errors'; /** * Build default props using the mutable mock objects. @@ -303,10 +319,10 @@ describe('HardwareWalletBottomSheet', () => { it('renders when scanning with selected device', () => { mockConnectionState.status = ConnectionStatus.Scanning; - const device = { id: 'device-1', name: 'Nano X' }; + const mockDevice = { id: 'device-1', name: 'Nano X' }; Object.assign(mockDeviceSelection, { - devices: [device], - selectedDevice: device, + devices: [mockDevice], + selectedDevice: mockDevice, isScanning: false, }); const { getByTestId } = render( @@ -321,7 +337,7 @@ describe('HardwareWalletBottomSheet', () => { describe('handleClose behavior', () => { beforeEach(() => { - lastBottomSheetOnClose = undefined; + mockLastBottomSheetOnCloseRef.current = undefined; }); it('calls onClose when sheet closes during scanning', () => { @@ -331,8 +347,8 @@ describe('HardwareWalletBottomSheet', () => { , ); - expect(lastBottomSheetOnClose).toBeDefined(); - lastBottomSheetOnClose?.(); + expect(mockLastBottomSheetOnCloseRef.current).toBeDefined(); + mockLastBottomSheetOnCloseRef.current?.(); expect(onClose).toHaveBeenCalled(); }); @@ -344,8 +360,8 @@ describe('HardwareWalletBottomSheet', () => { , ); - expect(lastBottomSheetOnClose).toBeDefined(); - lastBottomSheetOnClose?.(); + expect(mockLastBottomSheetOnCloseRef.current).toBeDefined(); + mockLastBottomSheetOnCloseRef.current?.(); expect(onClose).toHaveBeenCalled(); }); @@ -360,8 +376,8 @@ describe('HardwareWalletBottomSheet', () => { , ); - expect(lastBottomSheetOnClose).toBeDefined(); - lastBottomSheetOnClose?.(); + expect(mockLastBottomSheetOnCloseRef.current).toBeDefined(); + mockLastBottomSheetOnCloseRef.current?.(); expect(onClose).toHaveBeenCalled(); }); @@ -382,8 +398,8 @@ describe('HardwareWalletBottomSheet', () => { , ); - expect(lastBottomSheetOnClose).toBeDefined(); - lastBottomSheetOnClose?.(); + expect(mockLastBottomSheetOnCloseRef.current).toBeDefined(); + mockLastBottomSheetOnCloseRef.current?.(); expect(onClose).toHaveBeenCalled(); }); @@ -398,8 +414,8 @@ describe('HardwareWalletBottomSheet', () => { , ); - expect(lastBottomSheetOnClose).toBeDefined(); - lastBottomSheetOnClose?.(); + expect(mockLastBottomSheetOnCloseRef.current).toBeDefined(); + mockLastBottomSheetOnCloseRef.current?.(); expect(onClose).toHaveBeenCalled(); }); @@ -417,8 +433,8 @@ describe('HardwareWalletBottomSheet', () => { />, ); - expect(lastBottomSheetOnClose).toBeDefined(); - lastBottomSheetOnClose?.(); + expect(mockLastBottomSheetOnCloseRef.current).toBeDefined(); + mockLastBottomSheetOnCloseRef.current?.(); expect(onAwaitingConfirmationCancel).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled(); @@ -436,9 +452,9 @@ describe('HardwareWalletBottomSheet', () => { it('calls selectDevice when device is selected', () => { mockConnectionState.status = ConnectionStatus.Scanning; - const device = { id: 'device-1', name: 'Nano X' }; + const mockDevice = { id: 'device-1', name: 'Nano X' }; Object.assign(mockDeviceSelection, { - devices: [device], + devices: [mockDevice], selectedDevice: null, isScanning: false, }); @@ -448,9 +464,9 @@ describe('HardwareWalletBottomSheet', () => { d: unknown, ) => void; expect(onSelectDevice).toBeDefined(); - onSelectDevice(device); + onSelectDevice(mockDevice); - expect(mockActions.selectDevice).toHaveBeenCalledWith(device); + expect(mockActions.selectDevice).toHaveBeenCalledWith(mockDevice); }); it('calls rescan when rescan is triggered', () => { @@ -480,10 +496,10 @@ describe('HardwareWalletBottomSheet', () => { it('calls connect when device selection is confirmed', async () => { mockConnectionState.status = ConnectionStatus.Scanning; - const device = { id: 'device-1', name: 'Nano X' }; + const mockDevice = { id: 'device-1', name: 'Nano X' }; Object.assign(mockDeviceSelection, { - devices: [device], - selectedDevice: device, + devices: [mockDevice], + selectedDevice: mockDevice, isScanning: false, }); mockActions.connect.mockResolvedValue(undefined); @@ -596,4 +612,196 @@ describe('HardwareWalletBottomSheet', () => { expect(mockActions.retryEnsureDeviceReady).toHaveBeenCalled(); }); }); + + describe('QR scan error recovery', () => { + it('calls onRetryQrScan instead of retryEnsureDeviceReady for QR scan errors', async () => { + Object.assign(mockConnectionState, { + status: ConnectionStatus.ErrorState, + error: createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.NonURQrScanned, + purpose: QrScanRequestType.SIGN, + isUrFormat: false, + }), + }); + + render( + , + ); + + await act(async () => { + await ( + lastErrorContentProps.onContinue as (() => Promise) | undefined + )?.(); + }); + + expect(mockActions.onRetryQrScan).toHaveBeenCalled(); + expect(mockActions.retryEnsureDeviceReady).not.toHaveBeenCalled(); + }); + + it('does not auto-open the QR scanner after retrying a pairing QR scan error', async () => { + Object.assign(mockConnectionState, { + status: ConnectionStatus.ErrorState, + error: createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.NonURQrScanned, + purpose: QrScanRequestType.PAIR, + isUrFormat: false, + }), + }); + + const { rerender } = render( + , + ); + + await act(async () => { + await ( + lastErrorContentProps.onContinue as (() => Promise) | undefined + )?.(); + }); + + expect(mockActions.onRetryQrScan).toHaveBeenCalled(); + expect(mockActions.retryEnsureDeviceReady).not.toHaveBeenCalled(); + + Object.assign(mockConnectionState, { + status: ConnectionStatus.Disconnected, + }); + + rerender( + , + ); + + Object.assign(mockConnectionState, { + status: ConnectionStatus.AwaitingConfirmation, + deviceId: 'device-123', + operationType: 'transaction', + }); + + rerender( + , + ); + + expect(lastAwaitingConfirmationProps.openQrScannerOnMount).toBe(false); + }); + + it('clears QR scanner auto-open state before retrying non-QR scan errors', async () => { + Object.assign(mockConnectionState, { + status: ConnectionStatus.ErrorState, + error: createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.WrongURType, + purpose: QrScanRequestType.SIGN, + receivedUrType: 'crypto-account', + isUrFormat: true, + }), + }); + + const { rerender } = render( + , + ); + + await act(async () => { + await ( + lastErrorContentProps.onContinue as (() => Promise) | undefined + )?.(); + }); + + Object.assign(mockConnectionState, { + status: ConnectionStatus.ErrorState, + error: new HardwareWalletError('Test error', { + code: ErrorCode.Unknown, + severity: Severity.Err, + category: Category.Unknown, + userMessage: 'Test error', + }), + }); + + rerender( + , + ); + + await act(async () => { + await ( + lastErrorContentProps.onContinue as (() => Promise) | undefined + )?.(); + }); + + Object.assign(mockConnectionState, { + status: ConnectionStatus.AwaitingConfirmation, + deviceId: 'device-123', + operationType: 'transaction', + }); + + rerender( + , + ); + + expect(lastAwaitingConfirmationProps.openQrScannerOnMount).toBe(false); + }); + + it('auto-opens the QR scanner after retrying a QR scan error', async () => { + Object.assign(mockConnectionState, { + status: ConnectionStatus.ErrorState, + error: createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.WrongURType, + purpose: QrScanRequestType.SIGN, + receivedUrType: 'crypto-account', + isUrFormat: true, + }), + }); + + const { rerender } = render( + , + ); + + await act(async () => { + await ( + lastErrorContentProps.onContinue as (() => Promise) | undefined + )?.(); + }); + + Object.assign(mockConnectionState, { + status: ConnectionStatus.AwaitingConfirmation, + deviceId: 'device-123', + operationType: 'transaction', + }); + + rerender( + , + ); + + expect(lastAwaitingConfirmationProps.openQrScannerOnMount).toBe(true); + + act(() => { + ( + lastAwaitingConfirmationProps.onQrScannerOpened as + | (() => void) + | undefined + )?.(); + }); + + rerender( + , + ); + + expect(lastAwaitingConfirmationProps.openQrScannerOnMount).toBe(false); + }); + }); }); diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx index 7c3eac4651ba..09cc9dda61cb 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/HardwareWalletBottomSheet.tsx @@ -12,6 +12,8 @@ import { HardwareWalletConnectionState, ConnectionStatus, } from '@metamask/hw-wallet-sdk'; +import { QrScanRequestType } from '@metamask/eth-qr-keyring'; +import { isQRHardwareScanError } from '../../errors'; import { ConnectingContent, @@ -54,6 +56,8 @@ export interface HardwareWalletBottomSheetProps { onAwaitingConfirmationCancel?: () => void; /** Callback fired when the user taps the CTA on an error/recovery screen. */ onCTAClicked?: () => void; + /** Callback when the user retries a QR scan error from the bottom sheet. */ + onRetryQrScan?: () => void; } /** @@ -82,11 +86,13 @@ export const HardwareWalletBottomSheet: React.FC< onConnectionSuccess, onAwaitingConfirmationCancel, onCTAClicked, + onRetryQrScan, }) => { const { colors } = useTheme(); const styles = useMemo(() => createStyles(colors), [colors]); const bottomSheetRef = useRef(null); + const [openQrScannerOnMount, setOpenQrScannerOnMount] = React.useState(false); const { devices, selectedDevice, isScanning } = deviceSelection; @@ -128,8 +134,27 @@ export const HardwareWalletBottomSheet: React.FC< const handleErrorContinue = useCallback(async () => { onCTAClicked?.(); + if ( + walletType === HardwareWalletType.Qr && + connectionState.status === ConnectionStatus.ErrorState && + isQRHardwareScanError(connectionState.error) + ) { + const qrErrorMetadata = connectionState.error.metadata; + setOpenQrScannerOnMount( + qrErrorMetadata.qrScanPurpose === QrScanRequestType.SIGN, + ); + onRetryQrScan?.(); + return; + } + setOpenQrScannerOnMount(false); await retryEnsureDeviceReady(); - }, [retryEnsureDeviceReady, onCTAClicked]); + }, [ + connectionState, + onCTAClicked, + onRetryQrScan, + retryEnsureDeviceReady, + walletType, + ]); const handleErrorDismiss = useCallback(() => { onCTAClicked?.(); @@ -165,6 +190,10 @@ export const HardwareWalletBottomSheet: React.FC< onClose(); }, [onClose]); + const handleQrScannerOpened = useCallback(() => { + setOpenQrScannerOnMount(false); + }, []); + const renderContent = () => { if (!walletType) return null; switch (connectionState.status) { @@ -210,6 +239,8 @@ export const HardwareWalletBottomSheet: React.FC< deviceType={walletType} operationType={connectionState.operationType} onCancel={handleAwaitingConfirmationCancel} + openQrScannerOnMount={openQrScannerOnMount} + onQrScannerOpened={handleQrScannerOpened} /> ); diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.test.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.test.tsx index 2a71c07fac71..814081b16660 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.test.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.test.tsx @@ -59,15 +59,29 @@ jest.mock('../../../../../components/UI/QRHardware/AnimatedQRScanner', () => ({ __esModule: true, default: ({ hideModal, + onQRHardwareScanError, + onModalHideComplete, onScanError, onScanSuccess, visible, }: { hideModal: () => void; + onModalHideComplete?: () => void; + onQRHardwareScanError?: (error: Error) => void; onScanError: (error: string) => void; onScanSuccess: (ur: { cbor: string; type: string }) => void; visible: boolean; }) => { + const ActualReact = jest.requireActual('react'); + const prevVisibleRef = ActualReact.useRef(visible); + + ActualReact.useEffect(() => { + if (prevVisibleRef.current && !visible) { + onModalHideComplete?.(); + } + prevVisibleRef.current = visible; + }, [visible, onModalHideComplete]); + if (!visible) return null; return ( @@ -81,6 +95,13 @@ jest.mock('../../../../../components/UI/QRHardware/AnimatedQRScanner', () => ({ title="onScanError" onPress={() => onScanError('scan failed')} /> + + onQRHardwareScanError?.(new Error('qr hardware scan failed')) + } + /> { const renderComponent = ( props = {}, qrSigningOverrides?: Partial, + contextOverrides?: Partial, ) => renderWithProvider( { expect(getByTestId('animated-qr-scanner-mock')).toBeOnTheScreen(); }); + it('opens scanner on mount after QR scan error retry', () => { + const onQrScannerOpened = jest.fn(); + const { getByTestId } = renderComponent( + { + deviceType: HardwareWalletType.Qr, + openQrScannerOnMount: true, + onQrScannerOpened, + }, + qrSigningOverrides, + ); + + expect(getByTestId('animated-qr-scanner-mock')).toBeOnTheScreen(); + expect(onQrScannerOpened).toHaveBeenCalledTimes(1); + }); + it('renders spinner in QR flow when not signing QR object', () => { const { getByTestId, queryByTestId } = renderComponent( { deviceType: HardwareWalletType.Qr }, @@ -405,6 +443,22 @@ describe('AwaitingConfirmationContent', () => { expect(getByText('scan failed')).toBeOnTheScreen(); }); + it('routes QR hardware scan errors to hardware wallet error state', () => { + const showHardwareWalletError = jest.fn(); + const { getByTestId } = renderComponent( + { deviceType: HardwareWalletType.Qr }, + qrSigningOverrides, + { showHardwareWalletError }, + ); + + openScanner(getByTestId); + fireEvent.press(getByTestId('scanner-qr-hardware-error-btn')); + + expect(showHardwareWalletError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'qr hardware scan failed' }), + ); + }); + it('dismisses error message when alert is pressed', () => { const { stringify } = jest.requireMock('uuid'); stringify.mockReturnValueOnce('different-request-id'); diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.tsx index 5e57f6d0148c..8cb44cea0dc0 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/AwaitingConfirmationContent.tsx @@ -27,6 +27,7 @@ import { HardwareWalletType } from '@metamask/hw-wallet-sdk'; import { getHardwareWalletTypeName } from '../../../helpers'; import { ContentLayout } from './ContentLayout'; import { useHardwareWallet } from '../../../contexts'; +import { useQrScanErrorForwarding } from '../../../hooks/useQrScanErrorForwarding'; import Engine from '../../../../Engine'; import AnimatedQRCode from '../../../../../components/UI/QRHardware/AnimatedQRCode'; import AnimatedQRScannerModal from '../../../../../components/UI/QRHardware/AnimatedQRScanner'; @@ -73,11 +74,21 @@ export interface AwaitingConfirmationContentProps { operationType?: string; /** Optional callback when user wants to cancel/reject */ onCancel?: () => void; + /** Open the QR scanner as soon as this content mounts after QR error retry. */ + openQrScannerOnMount?: boolean; + /** Callback fired after the mount-triggered QR scanner has opened. */ + onQrScannerOpened?: () => void; } export const AwaitingConfirmationContent: React.FC< AwaitingConfirmationContentProps -> = ({ deviceType, operationType, onCancel }) => { +> = ({ + deviceType, + operationType, + onCancel, + openQrScannerOnMount, + onQrScannerOpened, +}) => { const { colors } = useTheme(); const { createEventBuilder, trackEvent } = useAnalytics(); const { qr } = useHardwareWallet(); @@ -94,6 +105,15 @@ export const AwaitingConfirmationContent: React.FC< const [shouldPause, setShouldPause] = useState(false); const [errorMessage, setErrorMessage] = useState(); + useEffect(() => { + if (!openQrScannerOnMount || !isQrFlow || !isSigningQRObject) { + return; + } + + setScannerVisible(true); + onQrScannerOpened?.(); + }, [isQrFlow, isSigningQRObject, onQrScannerOpened, openQrScannerOnMount]); + useEffect(() => { if (!isSigningQRObject) { setScannerVisible(false); @@ -149,6 +169,12 @@ export const AwaitingConfirmationContent: React.FC< setErrorMessage(error); }, []); + const hideScanner = useCallback(() => { + setScannerVisible(false); + }, []); + const { onQRHardwareScanError, handleScannerModalHide } = + useQrScanErrorForwarding({ hideScanner }); + const onQrCancel = useCallback(async () => { setScannerVisible(false); try { @@ -286,6 +312,8 @@ export const AwaitingConfirmationContent: React.FC< purpose={QrScanRequestType.SIGN} onScanSuccess={onScanSuccess} onScanError={onScanError} + onQRHardwareScanError={onQRHardwareScanError} + onModalHideComplete={handleScannerModalHide} hideModal={() => setScannerVisible(false)} /> diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.test.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.test.tsx index 5aea536575ce..b08c6d763179 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.test.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import { Linking } from 'react-native'; import { HardwareWalletError, HardwareWalletType, @@ -15,7 +16,14 @@ import { ERROR_CONTENT_TITLE_TEST_ID, ERROR_CONTENT_MESSAGE_TEST_ID, ERROR_CONTENT_CONTINUE_BUTTON_TEST_ID, + ERROR_CONTENT_LEARN_MORE_BUTTON_TEST_ID, } from './ErrorContent'; +import { + createQRHardwareScanError, + QRHardwareScanErrorType, + isQRHardwareScanError as actualIsQRHardwareScanError, +} from '../../../errors'; +import { QrScanRequestType } from '@metamask/eth-qr-keyring'; // Mock dependencies jest.mock('../../../../../util/theme', () => ({ @@ -33,16 +41,26 @@ jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => key, })); -jest.mock('../../../errors', () => ({ - getIconForErrorCode: jest.fn().mockReturnValue('Danger'), - getIconColorForErrorCode: jest.fn().mockReturnValue('Error'), - getTitleForErrorCode: jest.fn().mockReturnValue('Error Title'), - getRecoveryActionForErrorCode: jest.fn().mockReturnValue('retry'), - RecoveryAction: { - RETRY: 'retry', - ACKNOWLEDGE: 'acknowledge', - }, -})); +jest.mock('../../../errors', () => { + const actual = jest.requireActual('../../../errors/qrScan'); + const actualQr = jest.requireActual('../../../errors/qrHardwareScanError'); + return { + ...actual, + ...actualQr, + getIconForErrorCode: jest.fn().mockReturnValue('Danger'), + getIconColorForErrorCode: jest.fn().mockReturnValue('Error'), + getTitleForErrorCode: jest.fn().mockReturnValue('Error Title'), + getRecoveryActionForErrorCode: jest.fn().mockReturnValue('retry'), + getQRHardwareScanErrorTitle: jest + .fn() + .mockReturnValue('QR Scan Error Title'), + RecoveryAction: { + RETRY: 'retry', + ACKNOWLEDGE: 'acknowledge', + OPEN_SETTINGS: 'open_settings', + }, + }; +}); // Mock component library jest.mock('../../../../../component-library/components/Texts/Text', () => { @@ -61,17 +79,17 @@ jest.mock('../../../../../component-library/components/Texts/Text', () => { }; }); -jest.mock('../../../../../component-library/components/Buttons/Button', () => { +jest.mock('@metamask/design-system-react-native', () => { const { TouchableOpacity, Text, View } = jest.requireActual('react-native'); return { __esModule: true, - default: ({ - label, + Button: ({ + children, onPress, testID, isDisabled, }: { - label: React.ReactNode; + children: React.ReactNode; onPress?: () => void; testID?: string; isDisabled?: boolean; @@ -79,18 +97,17 @@ jest.mock('../../../../../component-library/components/Buttons/Button', () => { - {typeof label === 'string' ? ( - {label} + {typeof children === 'string' ? ( + {children} ) : ( - {label} + {children} )} ), - ButtonVariants: { Primary: 'Primary', Secondary: 'Secondary' }, - ButtonSize: { Lg: 'Lg' }, - ButtonWidthTypes: { Full: 'Full' }, + ButtonVariant: { Primary: 'primary', Secondary: 'secondary' }, + ButtonSize: { Lg: 'lg' }, }; }); @@ -127,6 +144,10 @@ describe('ErrorContent', () => { userMessage: userMessage ?? message, }); + beforeEach(() => { + jest.clearAllMocks(); + }); + it('renders with test ID', () => { const error = createError('Test error'); const { getByTestId } = render( @@ -271,4 +292,144 @@ describe('ErrorContent', () => { expect(onContinue).toHaveBeenCalledTimes(1); }); }); + + it('renders QR scan error title without the generic icon', () => { + const error = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.NonURQrScanned, + purpose: QrScanRequestType.SIGN, + isUrFormat: false, + }); + + const { queryByTestId, getByTestId } = render( + , + ); + + expect(queryByTestId(ERROR_CONTENT_ICON_TEST_ID)).toBeNull(); + expect(getByTestId(ERROR_CONTENT_TITLE_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders learn more button for QR scan errors', () => { + const error = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.WrongURType, + purpose: QrScanRequestType.SIGN, + receivedUrType: 'crypto-account', + isUrFormat: true, + }); + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(ERROR_CONTENT_LEARN_MORE_BUTTON_TEST_ID), + ).toBeOnTheScreen(); + expect( + getByTestId(ERROR_CONTENT_CONTINUE_BUTTON_TEST_ID), + ).toBeOnTheScreen(); + }); + + it('opens support article when learn more is pressed for QR scan errors', async () => { + const openUrlSpy = jest.spyOn(Linking, 'openURL'); + openUrlSpy.mockResolvedValueOnce(undefined); + const error = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.URDecodeError, + purpose: QrScanRequestType.SIGN, + isUrFormat: true, + }); + + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(ERROR_CONTENT_LEARN_MORE_BUTTON_TEST_ID)); + + await waitFor(() => { + expect(openUrlSpy).toHaveBeenCalledWith( + 'https://support.metamask.io/more-web3/wallets/hardware-wallet-hub/#qr-codean-gapped-wallets', + ); + }); + + openUrlSpy.mockRestore(); + }); + + it('calls onContinue when try again is pressed for QR scan errors', async () => { + const onContinue = jest.fn().mockResolvedValue(undefined); + const error = createQRHardwareScanError({ + errorType: QRHardwareScanErrorType.NonURQrScanned, + purpose: QrScanRequestType.SIGN, + isUrFormat: false, + }); + + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(ERROR_CONTENT_CONTINUE_BUTTON_TEST_ID)); + + await waitFor(() => { + expect(onContinue).toHaveBeenCalled(); + }); + }); + + it('calls onDismiss when continue pressed for ACKNOWLEDGE recovery action', async () => { + const onDismiss = jest.fn(); + const { getRecoveryActionForErrorCode } = + jest.requireMock('../../../errors'); + getRecoveryActionForErrorCode.mockReturnValue('acknowledge'); + const error = createError('Test error'); + + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(ERROR_CONTENT_CONTINUE_BUTTON_TEST_ID)); + + await waitFor(() => { + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + }); + + it('opens device settings when continue pressed for OPEN_SETTINGS recovery action', async () => { + const openSettingsSpy = jest.spyOn(Linking, 'openSettings'); + openSettingsSpy.mockResolvedValueOnce(undefined); + const { getRecoveryActionForErrorCode } = + jest.requireMock('../../../errors'); + getRecoveryActionForErrorCode.mockReturnValue('open_settings'); + const error = createError('Test error'); + + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(ERROR_CONTENT_CONTINUE_BUTTON_TEST_ID)); + + await waitFor(() => { + expect(openSettingsSpy).toHaveBeenCalledTimes(1); + }); + + openSettingsSpy.mockRestore(); + }); + + it('renders view settings label for OPEN_SETTINGS recovery action', () => { + const { getRecoveryActionForErrorCode } = + jest.requireMock('../../../errors'); + getRecoveryActionForErrorCode.mockReturnValue('open_settings'); + const error = createError('Test error'); + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(ERROR_CONTENT_CONTINUE_BUTTON_TEST_ID), + ).toBeOnTheScreen(); + }); }); diff --git a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.tsx b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.tsx index c43cbf0106fe..b980ed392737 100644 --- a/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.tsx +++ b/app/core/HardwareWallet/components/HardwareWalletBottomSheet/contents/ErrorContent.tsx @@ -1,15 +1,15 @@ import React, { useMemo, useCallback, useState } from 'react'; -import { StyleSheet } from 'react-native'; +import { Linking, StyleSheet } from 'react-native'; import { HardwareWalletError, HardwareWalletType, } from '@metamask/hw-wallet-sdk'; -import Button, { - ButtonVariants, +import { + Button, + ButtonVariant, ButtonSize, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; +} from '@metamask/design-system-react-native'; import Icon, { IconSize, } from '../../../../../component-library/components/Icons/Icon'; @@ -26,6 +26,8 @@ import { getIconColorForErrorCode, getTitleForErrorCode, getRecoveryActionForErrorCode, + getQRHardwareScanErrorTitle, + isQRHardwareScanError, RecoveryAction, } from '../../../errors'; import { ContentLayout } from './ContentLayout'; @@ -36,6 +38,11 @@ export const ERROR_CONTENT_TITLE_TEST_ID = 'error-content-title'; export const ERROR_CONTENT_MESSAGE_TEST_ID = 'error-content-message'; export const ERROR_CONTENT_CONTINUE_BUTTON_TEST_ID = 'error-content-continue-button'; +export const ERROR_CONTENT_LEARN_MORE_BUTTON_TEST_ID = + 'error-content-learn-more-button'; + +const QR_HARDWARE_LEARN_MORE_URL = + 'https://support.metamask.io/more-web3/wallets/hardware-wallet-hub/#qr-codean-gapped-wallets'; const styles = StyleSheet.create({ message: { @@ -76,22 +83,52 @@ export const ErrorContent: React.FC = ({ return getRecoveryActionForErrorCode(error.code); }, [error]); + const isQrScanError = useMemo( + () => Boolean(error && isQRHardwareScanError(error)), + [error], + ); + const showLoading = - recoveryAction === RecoveryAction.RETRY && (isLoading || isRetrying); + !isQrScanError && + recoveryAction === RecoveryAction.RETRY && + (isLoading || isRetrying); const errorTitle = useMemo(() => { if (!error) return strings('hardware_wallet.error.something_went_wrong'); + if (isQRHardwareScanError(error)) { + return getQRHardwareScanErrorTitle(error); + } return getTitleForErrorCode(error.code, deviceType); }, [error, deviceType]); const errorMessage = useMemo(() => error?.userMessage ?? null, [error]); + const buttonLabel = useMemo(() => { + if (isQrScanError) { + return strings('hardware_wallet.common.try_again'); + } + if (recoveryAction === RecoveryAction.OPEN_SETTINGS) { + return strings('hardware_wallet.error.view_settings'); + } + return strings('hardware_wallet.common.continue'); + }, [isQrScanError, recoveryAction]); + const handleContinue = useCallback(async () => { + if (isQrScanError) { + await onContinue?.(); + return; + } + if (recoveryAction === RecoveryAction.ACKNOWLEDGE) { onDismiss?.(); return; } + if (recoveryAction === RecoveryAction.OPEN_SETTINGS) { + await Linking.openSettings(); + return; + } + if (showLoading) return; setIsRetrying(true); @@ -100,7 +137,11 @@ export const ErrorContent: React.FC = ({ } finally { setIsRetrying(false); } - }, [onContinue, onDismiss, recoveryAction, showLoading]); + }, [isQrScanError, onContinue, onDismiss, recoveryAction, showLoading]); + + const handleLearnMore = useCallback(async () => { + await Linking.openURL(QR_HARDWARE_LEARN_MORE_URL); + }, []); if (!error) { return null; @@ -111,12 +152,14 @@ export const ErrorContent: React.FC = ({ testID={ERROR_CONTENT_TEST_ID} titleTestID={ERROR_CONTENT_TITLE_TEST_ID} icon={ - + isQrScanError ? undefined : ( + + ) } title={errorTitle} body={ @@ -132,16 +175,30 @@ export const ErrorContent: React.FC = ({ ) : undefined } footer={ - + ) : null} + + } /> ); diff --git a/app/core/HardwareWallet/contexts/HardwareWalletContext.tsx b/app/core/HardwareWallet/contexts/HardwareWalletContext.tsx index d9cc439eb038..cb87acd6e5db 100644 --- a/app/core/HardwareWallet/contexts/HardwareWalletContext.tsx +++ b/app/core/HardwareWallet/contexts/HardwareWalletContext.tsx @@ -41,6 +41,8 @@ export interface HardwareWalletContextValue { setPendingOperationAddress: (address: string | null) => void; /** Show a hardware wallet error in the bottom sheet. Use after ensureDeviceReady succeeds. */ showHardwareWalletError: (error: unknown) => void; + /** Register a retry handler for QR scan errors outside the provider-managed flows. */ + setQrScanRetryHandler?: (handler: (() => void) | null) => void; /** Show "awaiting confirmation" bottom sheet. */ showAwaitingConfirmation: ( operationType: 'transaction' | 'message', diff --git a/app/core/HardwareWallet/errors/index.ts b/app/core/HardwareWallet/errors/index.ts index f4fb1368562a..18c7f613db25 100644 --- a/app/core/HardwareWallet/errors/index.ts +++ b/app/core/HardwareWallet/errors/index.ts @@ -15,6 +15,7 @@ export { parseErrorByType } from './parser'; export { createQRHardwareScanError, getQRHardwareScanErrorTitle, + isQRHardwareScanError, QRHardwareScanError, QRHardwareScanErrorType, } from './qrScan'; diff --git a/app/core/HardwareWallet/errors/qrHardwareScanError.ts b/app/core/HardwareWallet/errors/qrHardwareScanError.ts index 46f131ce9911..f75bd126989e 100644 --- a/app/core/HardwareWallet/errors/qrHardwareScanError.ts +++ b/app/core/HardwareWallet/errors/qrHardwareScanError.ts @@ -20,3 +20,9 @@ export class QRHardwareScanError extends HardwareWalletError { this.metadata = options.metadata; } } + +export function isQRHardwareScanError( + error: unknown, +): error is QRHardwareScanError { + return error instanceof QRHardwareScanError; +} diff --git a/app/core/HardwareWallet/errors/qrScan.ts b/app/core/HardwareWallet/errors/qrScan.ts index ac6708ad530f..cf3aad647865 100644 --- a/app/core/HardwareWallet/errors/qrScan.ts +++ b/app/core/HardwareWallet/errors/qrScan.ts @@ -21,7 +21,10 @@ export { type QRHardwareScanErrorOptions, }; -export { QRHardwareScanError } from './qrHardwareScanError'; +export { + QRHardwareScanError, + isQRHardwareScanError, +} from './qrHardwareScanError'; interface CreateQRHardwareScanErrorParams { errorType: QRHardwareScanErrorType; diff --git a/app/core/HardwareWallet/hooks/index.ts b/app/core/HardwareWallet/hooks/index.ts index 87e74356a88c..523c9c029b1d 100644 --- a/app/core/HardwareWallet/hooks/index.ts +++ b/app/core/HardwareWallet/hooks/index.ts @@ -9,3 +9,4 @@ export { useTransportMonitoring } from './useTransportMonitoring'; export { useDeviceDiscovery } from './useDeviceDiscovery'; export { useDeviceConnectionFlow } from './useDeviceConnectionFlow'; export { useQRSigningState } from './useQRSigningState'; +export { useQrScanErrorForwarding } from './useQrScanErrorForwarding'; diff --git a/app/core/HardwareWallet/hooks/useIsConfirmationFromQrAccount.test.ts b/app/core/HardwareWallet/hooks/useIsConfirmationFromQrAccount.test.ts new file mode 100644 index 000000000000..dd7486ad95dc --- /dev/null +++ b/app/core/HardwareWallet/hooks/useIsConfirmationFromQrAccount.test.ts @@ -0,0 +1,119 @@ +import { renderHookWithProvider } from '../../../util/test/renderWithProvider'; +import { + personalSignatureConfirmationState, + stakingDepositConfirmationState, +} from '../../../util/test/confirm-data-helpers'; +import { useIsConfirmationFromQrAccount } from './useIsConfirmationFromQrAccount'; + +jest.mock('../../../core/Engine', () => ({ + context: { + KeyringController: { + state: { + keyrings: [ + { + type: 'HD Key Tree', + accounts: ['0x935e73edb9ff52e23bac7f7e043a1ecd06d05477'], + }, + ], + }, + }, + }, +})); + +const resetMockKeyrings = () => { + jest.requireMock( + '../../../core/Engine', + ).context.KeyringController.state.keyrings = [ + { + type: 'HD Key Tree', + accounts: ['0x935e73edb9ff52e23bac7f7e043a1ecd06d05477'], + }, + ]; +}; + +describe('useIsConfirmationFromQrAccount', () => { + beforeEach(() => { + resetMockKeyrings(); + }); + + it('returns false when from address belongs to a non-QR keyring', () => { + const { result } = renderHookWithProvider( + () => useIsConfirmationFromQrAccount(), + { state: personalSignatureConfirmationState }, + ); + + expect(result.current).toBe(false); + }); + + it('returns true when from address belongs to a QR keyring', () => { + jest.requireMock( + '../../../core/Engine', + ).context.KeyringController.state.keyrings = [ + { + type: 'QR Hardware Wallet Device', + accounts: ['0x935e73edb9ff52e23bac7f7e043a1ecd06d05477'], + }, + ]; + + const { result } = renderHookWithProvider( + () => useIsConfirmationFromQrAccount(), + { state: personalSignatureConfirmationState }, + ); + + expect(result.current).toBe(true); + }); + + it('returns false when there is no from address', () => { + const stateWithNoFrom = { + ...stakingDepositConfirmationState, + engine: { + backgroundState: { + ...stakingDepositConfirmationState.engine.backgroundState, + ApprovalController: { + pendingApprovals: { + 'test-id': { + id: 'test-id', + origin: 'metamask', + type: 'transaction', + time: 1, + requestData: {}, + requestState: null, + expectsResult: false, + }, + }, + pendingApprovalCount: 1, + approvalFlows: [], + }, + TransactionController: { + transactions: [], + }, + }, + }, + }; + + const { result } = renderHookWithProvider( + () => useIsConfirmationFromQrAccount(), + { state: stateWithNoFrom }, + ); + + expect(result.current).toBe(false); + }); + + it('returns false when from address belongs to a Ledger keyring', () => { + jest.requireMock( + '../../../core/Engine', + ).context.KeyringController.state.keyrings = [ + { + type: 'Ledger Hardware', + accounts: ['0x935e73edb9ff52e23bac7f7e043a1ecd06d05477'], + }, + ]; + + const { result } = renderHookWithProvider( + () => useIsConfirmationFromQrAccount(), + { state: personalSignatureConfirmationState }, + ); + + expect(result.current).toBe(false); + }); +}); diff --git a/app/core/HardwareWallet/hooks/useIsConfirmationFromQrAccount.ts b/app/core/HardwareWallet/hooks/useIsConfirmationFromQrAccount.ts new file mode 100644 index 000000000000..58144cb9dfa2 --- /dev/null +++ b/app/core/HardwareWallet/hooks/useIsConfirmationFromQrAccount.ts @@ -0,0 +1,19 @@ +import { useMemo } from 'react'; + +import { isHardwareAccount } from '../../../util/address'; +import ExtendedKeyringTypes from '../../../constants/keyringTypes'; +import useApprovalRequest from '../../../components/Views/confirmations/hooks/useApprovalRequest'; +import { useTransactionMetadataRequest } from '../../../components/Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; + +export function useIsConfirmationFromQrAccount(): boolean { + const { approvalRequest } = useApprovalRequest(); + const transactionMetadata = useTransactionMetadataRequest(); + + return useMemo(() => { + const fromAddress = + (approvalRequest?.requestData?.from as string) || + (transactionMetadata?.txParams?.from as string); + if (!fromAddress) return false; + return !!isHardwareAccount(fromAddress, [ExtendedKeyringTypes.qr]); + }, [approvalRequest?.requestData?.from, transactionMetadata?.txParams?.from]); +} diff --git a/app/core/HardwareWallet/hooks/useQrConfirm.test.ts b/app/core/HardwareWallet/hooks/useQrConfirm.test.ts new file mode 100644 index 000000000000..28363dcb5d80 --- /dev/null +++ b/app/core/HardwareWallet/hooks/useQrConfirm.test.ts @@ -0,0 +1,246 @@ +import { renderHook, act } from '@testing-library/react-native'; + +const mockEnsureDeviceReady = jest.fn(); +const mockSetTargetWalletType = jest.fn(); +const mockShowAwaitingConfirmation = jest.fn(); +const mockHideAwaitingConfirmation = jest.fn(); +const mockShowHardwareWalletError = jest.fn(); +const mockIsUserCancellation = jest.fn().mockReturnValue(false); +const mockSetScannerVisible = jest.fn(); +const mockExecuteHardwareWalletOperation = jest.fn(); + +jest.mock('..', () => ({ + useHardwareWallet: () => ({ + ensureDeviceReady: mockEnsureDeviceReady, + setTargetWalletType: mockSetTargetWalletType, + showAwaitingConfirmation: mockShowAwaitingConfirmation, + hideAwaitingConfirmation: mockHideAwaitingConfirmation, + showHardwareWalletError: mockShowHardwareWalletError, + }), + isUserCancellation: (...args: unknown[]) => mockIsUserCancellation(...args), + executeHardwareWalletOperation: (...args: unknown[]) => + mockExecuteHardwareWalletOperation(...args), +})); + +const mockApprovalRequest = { requestData: { from: '0xTestAddress' } }; +const mockTransactionMetadata = { txParams: { from: '0xTestAddress' } }; + +jest.mock( + '../../../components/Views/confirmations/hooks/useApprovalRequest', + () => ({ + __esModule: true, + default: () => ({ approvalRequest: mockApprovalRequest }), + }), +); + +jest.mock( + '../../../components/Views/confirmations/hooks/transactions/useTransactionMetadataRequest', + () => ({ + useTransactionMetadataRequest: () => mockTransactionMetadata, + }), +); + +const mockIsSigningQRObject = { current: false }; + +jest.mock( + '../../../components/Views/confirmations/context/qr-hardware-context', + () => ({ + useQRHardwareContext: () => ({ + isSigningQRObject: mockIsSigningQRObject.current, + setScannerVisible: mockSetScannerVisible, + }), + }), +); + +import { useQrConfirm } from './useQrConfirm'; + +describe('useQrConfirm', () => { + const onReject = jest.fn(); + const onTransactionConfirm = jest.fn().mockResolvedValue(undefined); + const executeApproval = jest.fn().mockResolvedValue(undefined); + + const defaultOptions = { + onReject, + onTransactionConfirm, + executeApproval, + isTransactionReq: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockIsSigningQRObject.current = false; + mockExecuteHardwareWalletOperation.mockResolvedValue(true); + }); + + it('opens scanner when QR signing is already in progress', async () => { + mockIsSigningQRObject.current = true; + + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(mockSetScannerVisible).toHaveBeenCalledWith(true); + expect(mockExecuteHardwareWalletOperation).not.toHaveBeenCalled(); + }); + + it('calls rejectOnce when no fromAddress is available', async () => { + const originalRequestData = mockApprovalRequest.requestData; + const originalTxParams = mockTransactionMetadata.txParams; + mockApprovalRequest.requestData = + {} as typeof mockApprovalRequest.requestData; + mockTransactionMetadata.txParams = + {} as typeof mockTransactionMetadata.txParams; + + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(onReject).toHaveBeenCalledTimes(1); + expect(mockExecuteHardwareWalletOperation).not.toHaveBeenCalled(); + + mockApprovalRequest.requestData = originalRequestData; + mockTransactionMetadata.txParams = originalTxParams; + }); + + it('calls executeHardwareWalletOperation for message signing', async () => { + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(mockExecuteHardwareWalletOperation).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0xTestAddress', + operationType: 'message', + }), + ); + }); + + it('calls executeHardwareWalletOperation for transaction with transaction type', async () => { + const { result } = renderHook(() => + useQrConfirm({ ...defaultOptions, isTransactionReq: true }), + ); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(mockExecuteHardwareWalletOperation).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0xTestAddress', + operationType: 'transaction', + }), + ); + }); + + it('calls onTransactionConfirm inside execute for transaction requests', async () => { + mockExecuteHardwareWalletOperation.mockImplementation( + async ({ execute }) => { + await execute(); + }, + ); + + const { result } = renderHook(() => + useQrConfirm({ ...defaultOptions, isTransactionReq: true }), + ); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(onTransactionConfirm).toHaveBeenCalledWith({ + onError: expect.any(Function), + }); + expect(executeApproval).not.toHaveBeenCalled(); + }); + + it('calls executeApproval inside execute for message requests', async () => { + mockExecuteHardwareWalletOperation.mockImplementation( + async ({ execute }) => { + await execute(); + }, + ); + + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(executeApproval).toHaveBeenCalledTimes(1); + expect(onTransactionConfirm).not.toHaveBeenCalled(); + }); + + it('shows error and rejects on non-user-cancellation error', async () => { + const signingError = new Error('signing failed'); + mockExecuteHardwareWalletOperation.mockRejectedValueOnce(signingError); + + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(mockShowHardwareWalletError).toHaveBeenCalledWith(signingError); + expect(onReject).toHaveBeenCalledTimes(1); + }); + + it('does not show error on user cancellation', async () => { + const userCancelError = new Error('User rejected'); + mockExecuteHardwareWalletOperation.mockRejectedValueOnce(userCancelError); + mockIsUserCancellation.mockReturnValueOnce(true); + + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(mockShowHardwareWalletError).not.toHaveBeenCalled(); + expect(onReject).toHaveBeenCalledTimes(1); + }); + + it('does not show error when already rejected', async () => { + const error = new Error('fail'); + mockExecuteHardwareWalletOperation.mockImplementation( + async ({ onRejected }) => { + await onRejected(); + throw error; + }, + ); + + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(onReject).toHaveBeenCalledTimes(1); + }); + + it('uses from address from transaction metadata when approval request has no from', async () => { + const originalRequestData = mockApprovalRequest.requestData; + mockApprovalRequest.requestData = + {} as typeof mockApprovalRequest.requestData; + mockTransactionMetadata.txParams = { from: '0xTxMetaAddress' }; + + const { result } = renderHook(() => useQrConfirm(defaultOptions)); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(mockExecuteHardwareWalletOperation).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0xTxMetaAddress', + }), + ); + + mockApprovalRequest.requestData = originalRequestData; + }); +}); diff --git a/app/core/HardwareWallet/hooks/useQrConfirm.ts b/app/core/HardwareWallet/hooks/useQrConfirm.ts new file mode 100644 index 000000000000..2be471a1ba67 --- /dev/null +++ b/app/core/HardwareWallet/hooks/useQrConfirm.ts @@ -0,0 +1,119 @@ +import { useCallback, useRef } from 'react'; + +import { + useHardwareWallet, + executeHardwareWalletOperation, + isUserCancellation, +} from '..'; +import { useQRHardwareContext } from '../../../components/Views/confirmations/context/qr-hardware-context'; +import useApprovalRequest from '../../../components/Views/confirmations/hooks/useApprovalRequest'; +import { useTransactionMetadataRequest } from '../../../components/Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; + +interface UseQrConfirmOptions { + onReject: () => void; + onTransactionConfirm: (opts?: { + onError?: (err: unknown) => void; + }) => Promise; + executeApproval: () => Promise; + isTransactionReq: boolean; +} + +/** + * Coordinates QR hardware wallet confirmation for transactions and message approvals. + * + * Ensures the QR account is ready, shows the awaiting-confirmation UI, opens the + * scanner when a QR signing payload is already active, and forwards terminal + * errors through the hardware wallet error flow. + * + * @returns An `onConfirm` callback for the confirmation submit action. + */ +export function useQrConfirm({ + onReject, + onTransactionConfirm, + executeApproval, + isTransactionReq, +}: UseQrConfirmOptions) { + const { + ensureDeviceReady, + showAwaitingConfirmation, + hideAwaitingConfirmation, + showHardwareWalletError, + } = useHardwareWallet(); + + const { isSigningQRObject, setScannerVisible } = useQRHardwareContext(); + + const { approvalRequest } = useApprovalRequest(); + const transactionMetadata = useTransactionMetadataRequest(); + + const hasRejectedRef = useRef(false); + + const executeQrConfirmation = useCallback(async () => { + if (isTransactionReq) { + await onTransactionConfirm({ + onError: (err) => { + throw err; + }, + }); + return; + } + + await executeApproval(); + }, [executeApproval, isTransactionReq, onTransactionConfirm]); + + const onConfirm = useCallback(async () => { + hasRejectedRef.current = false; + + const rejectOnce = () => { + if (hasRejectedRef.current) return; + hasRejectedRef.current = true; + onReject(); + }; + + const fromAddress = + (approvalRequest?.requestData?.from as string) || + (transactionMetadata?.txParams?.from as string); + + if (!fromAddress) { + rejectOnce(); + return; + } + + // If QR signing is already in progress, open the camera scanner + if (isSigningQRObject) { + setScannerVisible(true); + return; + } + + try { + await executeHardwareWalletOperation({ + address: fromAddress, + operationType: isTransactionReq ? 'transaction' : 'message', + ensureDeviceReady, + showAwaitingConfirmation, + hideAwaitingConfirmation, + showHardwareWalletError, + execute: executeQrConfirmation, + onRejected: rejectOnce, + }); + } catch (err) { + if (!hasRejectedRef.current && !isUserCancellation(err)) { + showHardwareWalletError(err); + } + rejectOnce(); + } + }, [ + approvalRequest?.requestData?.from, + transactionMetadata?.txParams?.from, + isSigningQRObject, + isTransactionReq, + executeQrConfirmation, + onReject, + ensureDeviceReady, + showAwaitingConfirmation, + hideAwaitingConfirmation, + showHardwareWalletError, + setScannerVisible, + ]); + + return { onConfirm }; +} diff --git a/app/core/HardwareWallet/hooks/useQrScanErrorForwarding.test.ts b/app/core/HardwareWallet/hooks/useQrScanErrorForwarding.test.ts new file mode 100644 index 000000000000..00ac89451efe --- /dev/null +++ b/app/core/HardwareWallet/hooks/useQrScanErrorForwarding.test.ts @@ -0,0 +1,70 @@ +import { act, renderHook } from '@testing-library/react-native'; +import type { HardwareWalletError } from '@metamask/hw-wallet-sdk'; + +const mockShowHardwareWalletError = jest.fn(); + +jest.mock('../contexts', () => ({ + useHardwareWallet: () => ({ + showHardwareWalletError: mockShowHardwareWalletError, + }), +})); + +import { useQrScanErrorForwarding } from './useQrScanErrorForwarding'; + +describe('useQrScanErrorForwarding', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('closes the scanner and forwards QR scan errors after the modal hides', () => { + const hideScanner = jest.fn(); + const scanError = new Error( + 'Scanned QR code is not in UR format', + ) as HardwareWalletError; + const { result } = renderHook(() => + useQrScanErrorForwarding({ hideScanner }), + ); + + act(() => { + result.current.onQRHardwareScanError(scanError); + }); + + expect(hideScanner).toHaveBeenCalledTimes(1); + expect(mockShowHardwareWalletError).not.toHaveBeenCalled(); + + act(() => { + result.current.handleScannerModalHide(); + }); + + expect(mockShowHardwareWalletError).toHaveBeenCalledWith(scanError); + }); + + it('does not forward an error when the modal hides without a pending QR scan error', () => { + const hideScanner = jest.fn(); + const { result } = renderHook(() => + useQrScanErrorForwarding({ hideScanner }), + ); + + act(() => { + result.current.handleScannerModalHide(); + }); + + expect(mockShowHardwareWalletError).not.toHaveBeenCalled(); + }); + + it('clears the pending QR scan error after forwarding it', () => { + const hideScanner = jest.fn(); + const scanError = new Error('QR scan failed') as HardwareWalletError; + const { result } = renderHook(() => + useQrScanErrorForwarding({ hideScanner }), + ); + + act(() => { + result.current.onQRHardwareScanError(scanError); + result.current.handleScannerModalHide(); + result.current.handleScannerModalHide(); + }); + + expect(mockShowHardwareWalletError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/core/HardwareWallet/hooks/useQrScanErrorForwarding.ts b/app/core/HardwareWallet/hooks/useQrScanErrorForwarding.ts new file mode 100644 index 000000000000..223f41955c4c --- /dev/null +++ b/app/core/HardwareWallet/hooks/useQrScanErrorForwarding.ts @@ -0,0 +1,38 @@ +import { useCallback, useRef } from 'react'; +import type { HardwareWalletError } from '@metamask/hw-wallet-sdk'; + +import { useHardwareWallet } from '../contexts'; + +interface UseQrScanErrorForwardingOptions { + hideScanner: () => void; +} + +export function useQrScanErrorForwarding({ + hideScanner, +}: UseQrScanErrorForwardingOptions) { + const { showHardwareWalletError } = useHardwareWallet(); + const pendingQrScanErrorRef = useRef(null); + + const onQRHardwareScanError = useCallback( + (error: HardwareWalletError) => { + pendingQrScanErrorRef.current = error; + hideScanner(); + }, + [hideScanner], + ); + + const handleScannerModalHide = useCallback(() => { + const pendingError = pendingQrScanErrorRef.current; + if (!pendingError) { + return; + } + + pendingQrScanErrorRef.current = null; + showHardwareWalletError(pendingError); + }, [showHardwareWalletError]); + + return { + onQRHardwareScanError, + handleScannerModalHide, + }; +} diff --git a/app/multichain-accounts/AccountTreeInitService/index.test.ts b/app/multichain-accounts/AccountTreeInitService/index.test.ts index 4fc2d9cec026..102b30258530 100644 --- a/app/multichain-accounts/AccountTreeInitService/index.test.ts +++ b/app/multichain-accounts/AccountTreeInitService/index.test.ts @@ -117,10 +117,5 @@ describe('AccountTreeInitService', () => { await service.clearState(); expect(mockAccountTreeClearState).toHaveBeenCalled(); }); - - it('calls MoneyAccountController.clearState', async () => { - await service.clearState(); - expect(mockMoneyAccountClearState).toHaveBeenCalled(); - }); }); }); diff --git a/app/multichain-accounts/AccountTreeInitService/index.ts b/app/multichain-accounts/AccountTreeInitService/index.ts index ca342a33dd5c..89ae6650294b 100644 --- a/app/multichain-accounts/AccountTreeInitService/index.ts +++ b/app/multichain-accounts/AccountTreeInitService/index.ts @@ -29,18 +29,9 @@ export class AccountTreeInitService { }; clearState = async (): Promise => { - const { - AccountTreeController, - MoneyAccountController, - RemoteFeatureFlagController, - } = Engine.context; - const { remoteFeatureFlags } = RemoteFeatureFlagController.state; + const { AccountTreeController } = Engine.context; AccountTreeController.clearState(); - - if (isMoneyAccountEnabled(remoteFeatureFlags)) { - MoneyAccountController.clearState(); - } }; } diff --git a/app/util/activity/index.ts b/app/util/activity/index.ts index d154c6d5bef0..9912e6371cb1 100644 --- a/app/util/activity/index.ts +++ b/app/util/activity/index.ts @@ -11,6 +11,7 @@ import { BridgeHistoryItem } from '@metamask/bridge-status-controller'; import { AddressBookControllerState } from '@metamask/address-book-controller'; export const PAY_TYPES = [ + TransactionType.moneyAccountDeposit, TransactionType.perpsDeposit, TransactionType.predictDeposit, ]; diff --git a/app/util/test/testSetup.js b/app/util/test/testSetup.js index d600bd77442b..86bb38c24aec 100644 --- a/app/util/test/testSetup.js +++ b/app/util/test/testSetup.js @@ -850,6 +850,75 @@ jest.mock('../../component-library/components/BottomSheets/BottomSheet', () => { }; }); +// Mock @metamask/design-system-react-native BottomSheet to render children immediately +// and run open/close callbacks synchronously (bypasses reanimated animations). +// Matches the component-library BottomSheet mock above; components that migrated +// to the design-system sheet otherwise trigger act() warnings from Animated updates. +jest.mock('@metamask/design-system-react-native', () => { + const React = require('react'); + const PropTypes = require('prop-types'); + const { View } = require('react-native'); + const actual = jest.requireActual('@metamask/design-system-react-native'); + + const BottomSheet = React.forwardRef( + ( + { + children, + onClose, + onOpen, + goBack, + style, + twClassName: _twClassName, + testID, + accessibilityLabel, + }, + ref, + ) => { + React.useImperativeHandle(ref, () => ({ + onOpenBottomSheet: (callback) => { + onOpen?.(); + callback?.(); + }, + onCloseBottomSheet: (callback) => { + const hasCallback = Boolean(callback); + onClose?.(hasCallback); + goBack?.(); + callback?.(); + }, + })); + return React.createElement( + View, + { + testID: testID || 'design-system-bottom-sheet-mock', + style, + accessibilityLabel, + }, + children, + ); + }, + ); + BottomSheet.displayName = 'BottomSheet'; + BottomSheet.propTypes = { + children: PropTypes.node, + onClose: PropTypes.func, + onOpen: PropTypes.func, + goBack: PropTypes.func, + style: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.array, + PropTypes.number, + ]), + twClassName: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + testID: PropTypes.string, + accessibilityLabel: PropTypes.string, + }; + + return { + ...actual, + BottomSheet, + }; +}); + // Mock react-native-modal to render children immediately (bypasses animation) jest.mock('react-native-modal', () => { const React = require('react'); diff --git a/app/util/trace.test.ts b/app/util/trace.test.ts index 74ade05318ba..30d5f55f879a 100644 --- a/app/util/trace.test.ts +++ b/app/util/trace.test.ts @@ -31,6 +31,12 @@ jest.mock('../store/storage-wrapper', () => ({ getItem: jest.fn(), })); +jest.mock('redux-persist-filesystem-storage', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), +})); + jest.mock('../store', () => ({ store: { dispatch: jest.fn(), @@ -89,7 +95,6 @@ describe('Trace', () => { ); flushBufferedTraces(); - // Reset consent state to false by default updateCachedConsent(false); }); diff --git a/app/util/trace.ts b/app/util/trace.ts index 940e0e4132b9..9dd821d4de33 100644 --- a/app/util/trace.ts +++ b/app/util/trace.ts @@ -13,6 +13,7 @@ import performance from 'react-native-performance'; import { createModuleLogger, createProjectLogger } from '@metamask/utils'; import { AGREED, METRICS_OPT_IN } from '../constants/storage'; import StorageWrapper from '../store/storage-wrapper'; +import FilesystemStorage from 'redux-persist-filesystem-storage'; // Cannot create this 'sentry' logger in Sentry util file because of circular dependency const projectLogger = createProjectLogger('sentry'); @@ -62,6 +63,10 @@ export enum TraceName { EvmDiscoverAccounts = 'EVM Discover Accounts', SnapDiscoverAccounts = 'Snap Discover Accounts', FetchHistoricalPrices = 'Fetch Historical Prices', + /** Token overview advanced chart: skeleton cleared after initial load / asset or currency change. */ + TokenOverviewAdvancedChartInitialVisible = 'Token Overview Advanced Chart Initial Visible', + /** Token overview advanced chart: skeleton cleared after time range selector change only. */ + TokenOverviewAdvancedChartTimeRangeVisible = 'Token Overview Advanced Chart Time Range Visible', TransactionConfirmed = 'Transaction Confirmed', LoadCollectibles = 'Load Collectibles', DetectNfts = 'Detect Nfts', @@ -268,6 +273,10 @@ export enum TraceOperation { MarketInsightsViewportTracking = 'market_insights.viewport_tracking', // Homepage Section Performance HomepageSectionPerformance = 'homepage.section.performance', + /** Token overview OHLCV WebView: initial load or asset/currency change */ + TokenOverviewAdvancedChart = 'token_overview.advanced_chart', + /** Token overview OHLCV WebView: time range change only */ + TokenOverviewAdvancedChartTimeRange = 'token_overview.advanced_chart_time_range', } const ID_DEFAULT = 'default'; @@ -543,9 +552,32 @@ export async function flushBufferedTraces() { let cachedConsent: boolean | null = null; /** - * Check if user has given consent for metrics + * Check if user has given consent for metrics (for Sentry init). + * Reads from AnalyticsController's persisted state in FilesystemStorage. + * + * This bypasses Engine/Redux because Sentry initializes in index.js before they're available. + * Follows the same pattern as ControllerStorage.getAllPersistedState() in persistConfig. */ export async function hasMetricsConsent(): Promise { + try { + // Read directly from AnalyticsController's persisted state (same as ControllerStorage does) + const persistedData = await FilesystemStorage.getItem( + 'persist:AnalyticsController', + ); + if (persistedData) { + const parsed = JSON.parse(persistedData); + // Remove redux-persist metadata and get controller state + const { _persist, ...controllerState } = parsed; + if (typeof controllerState?.optedIn === 'boolean') { + cachedConsent = controllerState.optedIn; + return controllerState.optedIn; + } + } + } catch { + // Fall through to legacy storage + } + + // Fallback: legacy METRICS_OPT_IN (migration 108, may be stale) const metricsOptIn = await StorageWrapper.getItem(METRICS_OPT_IN); const hasConsent = metricsOptIn === AGREED; cachedConsent = hasConsent; diff --git a/builds.yml b/builds.yml index b47b2479f590..c08efa2c42c2 100644 --- a/builds.yml +++ b/builds.yml @@ -428,69 +428,3 @@ builds: DEV_OAUTH_CONFIG: 'true' secrets: *secrets code_fencing: *code_fencing_flask - - # ───────────────────────────────────────────────────────────────────────────── - # QA Builds (legacy - for internal testing) - # ───────────────────────────────────────────────────────────────────────────── - - # QA production build - qa-prod: - github_environment: build-uat - signing: *signing_uat - env: - <<: *public_envs - METAMASK_ENVIRONMENT: 'production' - METAMASK_BUILD_TYPE: 'qa' - secrets: - <<: *secrets - # QA uses QA Segment project - SEGMENT_WRITE_KEY: SEGMENT_WRITE_KEY_QA - SEGMENT_PROXY_URL: SEGMENT_PROXY_URL_QA - SEGMENT_DELETE_API_SOURCE_ID: SEGMENT_DELETE_API_SOURCE_ID_QA - SEGMENT_REGULATIONS_ENDPOINT: SEGMENT_REGULATIONS_ENDPOINT_QA - # QA uses UAT OAuth credentials - IOS_GOOGLE_CLIENT_ID: MAIN_IOS_GOOGLE_CLIENT_ID_UAT - IOS_GOOGLE_REDIRECT_URI: MAIN_IOS_GOOGLE_REDIRECT_URI_UAT - ANDROID_GOOGLE_CLIENT_ID: MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT - ANDROID_APPLE_CLIENT_ID: MAIN_ANDROID_APPLE_CLIENT_ID_UAT - ANDROID_GOOGLE_SERVER_CLIENT_ID: MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT - MM_CARD_BAANX_API_CLIENT_KEY: MM_CARD_BAANX_API_CLIENT_KEY_UAT - code_fencing: *code_fencing_main - - # QA dev build / Expo development build (Runway .app for simulator) - qa-dev: - github_environment: build-uat - env: - <<: *public_envs - METAMASK_ENVIRONMENT: 'dev' - METAMASK_BUILD_TYPE: 'qa' - MM_PORTFOLIO_URL: 'https://portfolio.dev-api.cx.metamask.io' - MM_PREDICT_BUILDER_CODE: '0xd48300a99deac0f23265dad1f59d32920be33b75919201a88ed80f457f97b924' - REWARDS_API_URL: 'https://rewards.uat-api.cx.metamask.io' - BAANX_API_URL: 'https://dev.api.baanx.com' - DIGEST_API_URL: 'https://digest.dev-api.cx.metamask.io/api/v1' - SOCIAL_API_URL: 'https://social.dev-api.cx.metamask.io' - BRIDGE_USE_DEV_APIS: 'true' - RAMPS_ENVIRONMENT: 'staging' - RAMP_INTERNAL_BUILD: 'true' - RAMP_DEV_BUILD: 'true' - IS_TEST: 'false' - MM_ENABLE_SETTINGS_PAGE_DEV_OPTIONS: 'true' - IS_SIM_BUILD: 'true' - CONFIGURATION: 'Debug' - DEV_OAUTH_CONFIG: 'true' - secrets: - <<: *secrets - # QA uses QA Segment project - SEGMENT_WRITE_KEY: SEGMENT_WRITE_KEY_QA - SEGMENT_PROXY_URL: SEGMENT_PROXY_URL_QA - SEGMENT_DELETE_API_SOURCE_ID: SEGMENT_DELETE_API_SOURCE_ID_QA - SEGMENT_REGULATIONS_ENDPOINT: SEGMENT_REGULATIONS_ENDPOINT_QA - # QA uses UAT OAuth credentials - IOS_GOOGLE_CLIENT_ID: MAIN_IOS_GOOGLE_CLIENT_ID_UAT - IOS_GOOGLE_REDIRECT_URI: MAIN_IOS_GOOGLE_REDIRECT_URI_UAT - ANDROID_GOOGLE_CLIENT_ID: MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT - ANDROID_APPLE_CLIENT_ID: MAIN_ANDROID_APPLE_CLIENT_ID_UAT - ANDROID_GOOGLE_SERVER_CLIENT_ID: MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT - MM_CARD_BAANX_API_CLIENT_KEY: MM_CARD_BAANX_API_CLIENT_KEY_UAT - code_fencing: *code_fencing_main diff --git a/locales/languages/en.json b/locales/languages/en.json index 7ef2b7ed478f..5205ab4d836f 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6543,9 +6543,9 @@ "card": "Card" }, "earnings": { - "title": "Earnings", - "lifetime": "Lifetime earnings", - "projected": "Projected earnings", + "title": "Estimated earnings", + "estimated_monthly": "Monthly", + "estimated_yearly": "Annual", "info_label": "Earnings info" }, "how_it_works": { @@ -6569,11 +6569,11 @@ "subtitle": "Spend your money anywhere.", "virtual_card": "Virtual card", "metal_card": "Metal card", - "cashback": "{{percentage}}% cashback", + "cashback": "{{percentage}}% mUSD back", "get_now": "Get now", "link_title": "Link MetaMask Card", "link_subtitle": "Spend your Money balance and earn.", - "link_bullet_cashback": "Up to 3% cash back", + "link_bullet_cashback": "Up to 3% mUSD back", "link_bullet_apy": "Up to {{apy}}% APY", "link_card": "Link card" }, @@ -6583,7 +6583,7 @@ "benefit_dollar_backed": "Keep your money secure in mUSD, a 1:1 dollar-backed stablecoin", "benefit_liquidity": "Get full liquidity with no lockups, so you can trade or withdraw anytime", "benefit_spend_prefix": "Spend at 150M+ merchants with MetaMask Card and earn ", - "benefit_spend_cashback": "1-3% cashback", + "benefit_spend_cashback": "1-3% mUSD back", "benefit_transfer": "Transfer money to any of your wallets across MetaMask", "benefit_global": "Send and receive money globally", "learn_more": "Learn more" @@ -6622,12 +6622,8 @@ "learn_more": "Learn more" }, "earnings_tooltip": { - "title": "Earnings", - "lifetime_heading": "Lifetime earnings", - "lifetime_body": "The total yield you've earned since opening your Money account.", - "projected_heading": "Projected earnings", - "projected_body": "A projection of what you'd earn over a year based on your current balance and rate.", - "disclaimer": "Projection assumes {{percentage}}% Annual Percentage Yield (APY) remains unchanged for 1 year. APY is variable and may change due to various factors. No guarantee of return." + "title": "Estimated earnings", + "body": "An estimate of how much you could earn over a period based on your current balance and today's APY. Estimates are not guaranteed returns and remain subject to change." }, "activity": { "title": "Activity", @@ -6684,7 +6680,7 @@ "faq_q7": "Does the APY rate change?", "faq_q8": "Is this a savings account or a spending account?", "faq_q9": "Who controls my money?", - "faq_q10": "What cash back do I get with the MetaMask Card?", + "faq_q10": "What mUSD back do I get with the MetaMask Card?", "sounds_good": "Sounds good" } }, @@ -7620,17 +7616,17 @@ "price": "Free", "feature_1": "Virtual card for Apple Pay and Google Pay", "feature_2": "Pay with crypto (USDC, USDT, WETH, and more)", - "feature_3": "1% USDC cashback on every purchase" + "feature_3": "1% mUSD back on every purchase" }, "metal_card": { "name": "Metal Card", "price": "$199/year", "everything_in_virtual": "Everything in virtual, plus:", "feature_1": "Premium engraved metal card", - "feature_2": "3% cashback on first $10,000/year", + "feature_2": "3% mUSD back on first $10,000/year", "feature_3": "No foreign transaction fees" }, - "earn_up_to_badge": "Earn up to $300 in cashback annually", + "earn_up_to_badge": "Earn up to $300 in mUSD back annually", "upgrade_to_metal_label": "Or upgrade to Metal for 3x rewards" }, "review_order": { @@ -7651,7 +7647,7 @@ "order_completed": { "title": "YOUR CARD\nIS ORDERED", "subtitle": "It should arrive in 4 to 6 weeks.", - "description": "Set up your virtual card and add it to your digital wallet to start earning cashback.", + "description": "Set up your virtual card and add it to your digital wallet to start earning mUSD back.", "set_up_card_button": "Set up card", "back_to_card_button": "Back to Card" }, @@ -7670,7 +7666,7 @@ }, "card_onboarding": { "title": "Spend\nand Earn", - "description": "The MetaMask Card is the fast and\neasy way to spend your crypto and\nearn up to 3% cashback.", + "description": "The MetaMask Card is the fast and\neasy way to spend your crypto and\nearn up to 3% mUSD back.", "apply_now_button": "Set up now", "login_button": "Log in", "not_now_button": "Not now", @@ -7952,9 +7948,9 @@ "card_tos_title": "Terms and conditions", "order_metal_card": "Metal Card", "order_metal_card_description": "Order your physical Metal Card now", - "cashback": "Cashback", - "cashback_description": "Earn 1% back on all spending", - "cashback_description_metal": "Earn 3% back on all spending", + "cashback": "mUSD Back", + "cashback_description": "Earn 1% mUSD back on all spending", + "cashback_description_metal": "Earn 3% mUSD back on all spending", "freeze_card": "Freeze card", "unfreeze_card": "Unfreeze card", "freeze_card_description": "Pause all spending on your card", @@ -7996,8 +7992,8 @@ "token_label": "Token" }, "cashback_screen": { - "title": "Cashback", - "available_cashback": "Available cashback", + "title": "mUSD Back", + "available_cashback": "Available mUSD", "network_fee": "Network fee", "expected_to_receive": "Expected to receive", "withdraw": "Withdraw", @@ -8005,11 +8001,11 @@ "withdrawal_initiated": "Withdrawal has been initiated", "withdrawal_success": "Withdrawal completed successfully", "withdrawal_failed": "Withdrawal failed. Please try again.", - "no_cashback": "No cashback available", - "loading_error": "Failed to load cashback. Please try again.", + "no_cashback": "No mUSD back available", + "loading_error": "Failed to load mUSD back. Please try again.", "funding_required": { "title": "Set up Linea funding", - "description": "You need at least one approved funding source on Linea before redeeming cashback.", + "description": "You need at least one approved funding source on Linea before redeeming mUSD back.", "confirm_button_label": "Set up funding" } }, diff --git a/scripts/build.sh b/scripts/build.sh index 0e0d56c466f1..b8dda7bf5959 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -246,15 +246,7 @@ remapEnvVariableQA() { remapEnvVariable "SEGMENT_PROXY_URL_QA" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "MAIN_IOS_GOOGLE_CLIENT_ID_UAT" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_IOS_GOOGLE_REDIRECT_URI_UAT" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "MAIN_ANDROID_APPLE_CLIENT_ID_UAT" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_UAT" "MM_CARD_BAANX_API_CLIENT_KEY" - } # Mapping for Main env variables in the e2e environment @@ -264,13 +256,6 @@ remapMainE2EEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_QA" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "MAIN_IOS_GOOGLE_CLIENT_ID_UAT" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_IOS_GOOGLE_REDIRECT_URI_UAT" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "MAIN_ANDROID_APPLE_CLIENT_ID_UAT" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_UAT" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -281,13 +266,6 @@ remapMainTestEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_QA" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "MAIN_IOS_GOOGLE_CLIENT_ID_UAT" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_IOS_GOOGLE_REDIRECT_URI_UAT" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "MAIN_ANDROID_APPLE_CLIENT_ID_UAT" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_UAT" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -298,13 +276,6 @@ remapMainProdEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_PROD" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_PROD" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_PROD" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "MAIN_IOS_GOOGLE_CLIENT_ID_PROD" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_IOS_GOOGLE_REDIRECT_URI_PROD" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "MAIN_ANDROID_APPLE_CLIENT_ID_PROD" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_CLIENT_ID_PROD" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_PROD" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -315,13 +286,6 @@ remapFlaskProdEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_FLASK" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_FLASK" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_FLASK" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "FLASK_IOS_GOOGLE_CLIENT_ID_PROD" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "FLASK_IOS_GOOGLE_REDIRECT_URI_PROD" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "FLASK_ANDROID_APPLE_CLIENT_ID_PROD" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_PROD" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -332,13 +296,6 @@ remapFlaskTestEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_QA" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "FLASK_IOS_GOOGLE_CLIENT_ID_PROD" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "FLASK_IOS_GOOGLE_REDIRECT_URI_PROD" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "FLASK_ANDROID_APPLE_CLIENT_ID_PROD" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_UAT" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -349,13 +306,6 @@ remapFlaskE2EEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_QA" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "FLASK_IOS_GOOGLE_CLIENT_ID_PROD" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "FLASK_IOS_GOOGLE_REDIRECT_URI_PROD" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "FLASK_ANDROID_APPLE_CLIENT_ID_PROD" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_UAT" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -366,13 +316,6 @@ remapMainBetaEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_BETA" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "MAIN_IOS_GOOGLE_CLIENT_ID_PROD" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_IOS_GOOGLE_REDIRECT_URI_PROD" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "MAIN_ANDROID_APPLE_CLIENT_ID_PROD" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_CLIENT_ID_PROD" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_PROD" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -383,13 +326,6 @@ remapMainReleaseCandidateEnvVariables() { remapEnvVariable "SEGMENT_PROXY_URL_QA" "SEGMENT_PROXY_URL" remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" - - remapEnvVariable "MAIN_IOS_GOOGLE_CLIENT_ID_PROD" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_IOS_GOOGLE_REDIRECT_URI_PROD" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "MAIN_ANDROID_APPLE_CLIENT_ID_PROD" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_CLIENT_ID_PROD" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_PROD" "MM_CARD_BAANX_API_CLIENT_KEY" } @@ -401,13 +337,6 @@ remapMainExperimentalEnvVariables() { remapEnvVariable "SEGMENT_DELETE_API_SOURCE_ID_QA" "SEGMENT_DELETE_API_SOURCE_ID" remapEnvVariable "SEGMENT_REGULATIONS_ENDPOINT_QA" "SEGMENT_REGULATIONS_ENDPOINT" remapEnvVariable "MAIN_WEB3AUTH_NETWORK_PROD" "WEB3AUTH_NETWORK" - - remapEnvVariable "MAIN_IOS_GOOGLE_CLIENT_ID_UAT" "IOS_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_IOS_GOOGLE_REDIRECT_URI_UAT" "IOS_GOOGLE_REDIRECT_URI" - remapEnvVariable "MAIN_ANDROID_APPLE_CLIENT_ID_UAT" "ANDROID_APPLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT" "ANDROID_GOOGLE_CLIENT_ID" - remapEnvVariable "MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT" "ANDROID_GOOGLE_SERVER_CLIENT_ID" - remapEnvVariable "MM_CARD_BAANX_API_CLIENT_KEY_UAT" "MM_CARD_BAANX_API_CLIENT_KEY" } diff --git a/tests/component-view/mocks.ts b/tests/component-view/mocks.ts index 73694f2bb1e2..345e9c7b445f 100644 --- a/tests/component-view/mocks.ts +++ b/tests/component-view/mocks.ts @@ -187,6 +187,8 @@ jest.mock('../../app/core/Engine', () => { getBalance: jest.fn().mockResolvedValue(0), getPositions: jest.fn().mockResolvedValue([]), getPrices: jest.fn().mockResolvedValue({ providerId: '', results: [] }), + subscribeToMarketPrices: jest.fn(() => () => undefined), + getConnectionStatus: jest.fn(() => ({ marketConnected: false })), trackFeedViewed: jest.fn(), trackTabChanged: jest.fn(), trackMarketDetailsOpened: jest.fn(), diff --git a/tests/framework/PlaywrightMatchers.ts b/tests/framework/PlaywrightMatchers.ts index 2347b26f1fbd..7037bc93d201 100644 --- a/tests/framework/PlaywrightMatchers.ts +++ b/tests/framework/PlaywrightMatchers.ts @@ -229,12 +229,19 @@ export default class PlaywrightMatchers { /** * Get element by name on iOS * @param name - The name to search for + * @param lazy - Whether to get a lazy element. Lazy elements are not required to be present in the DOM. This is useful for negative assertions where the element may never have been rendered (e.g. waitForDisplayed({ reverse: true })). * @returns The wrapped element */ - static async getElementByNameiOS(name: string): Promise { + static async getElementByNameiOS( + name: string, + lazy = false, + ): Promise { const isIOS = await PlatformDetector.isIOS(); if (!isIOS) throw new Error('This function is only valid for iOS'); const xpath = `//*[contains(@name,'${name}')]`; + if (lazy) { + return await this.getLazyElementByXPath(xpath); + } return await this.getElementByXPath(xpath); } diff --git a/tests/page-objects/MMConnect/DappConnectionModal.ts b/tests/page-objects/MMConnect/DappConnectionModal.ts index 1a12895191fa..1252e45e3de2 100644 --- a/tests/page-objects/MMConnect/DappConnectionModal.ts +++ b/tests/page-objects/MMConnect/DappConnectionModal.ts @@ -8,6 +8,7 @@ import PlaywrightMatchers from '../../framework/PlaywrightMatchers'; import UnifiedGestures from '../../framework/UnifiedGestures'; import { getDriver } from '../../framework/PlaywrightUtilities'; import { ConnectAccountBottomSheetSelectorsIDs } from '../../../app/components/Views/AccountConnect/ConnectAccountBottomSheet.testIds'; +import { ConnectedAccountsSelectorsIDs } from '../../../app/components/Views/AccountConnect/ConnectedAccountModal.testIds'; import { AccountCellIds } from '../../../app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.testIds'; import { CellComponentSelectorsIDs } from '../../../app/component-library/components/Cells/Cell/CellComponent.testIds'; import { sleep } from '../../framework'; @@ -33,10 +34,16 @@ class DappConnectionModal { get editAccountsButton(): EncapsulatedElementType { return encapsulated({ - appium: () => - PlaywrightMatchers.getElementByXPath( - '//android.view.ViewGroup[@content-desc="Edit accounts"]', - ), + appium: { + android: () => + PlaywrightMatchers.getElementByXPath( + '//android.view.ViewGroup[@content-desc="Edit accounts"]', + ), + ios: () => + PlaywrightMatchers.getElementById( + ConnectedAccountsSelectorsIDs.ACCOUNT_LIST_BOTTOM_SHEET, + ), + }, }); } @@ -130,10 +137,10 @@ class DappConnectionModal { direction: 'down', percent: 1.0, }); - const element = await asPlaywrightElement( + const networkButton = await asPlaywrightElement( this.getNetworkButton(networkName), ); - await element.click(); + await networkButton.click(); }, }); } diff --git a/tests/page-objects/MMConnect/UniswapDapp.ts b/tests/page-objects/MMConnect/UniswapDapp.ts new file mode 100644 index 000000000000..c585d87c2956 --- /dev/null +++ b/tests/page-objects/MMConnect/UniswapDapp.ts @@ -0,0 +1,248 @@ +import { + asPlaywrightElement, + encapsulated, + encapsulatedAction, + EncapsulatedElementType, + PlatformDetector, + PlaywrightAssertions, + PlaywrightGestures, + PlaywrightMatchers, + sleep, + UnifiedGestures, +} from '../../framework'; + +class UniswapDapp { + private getByXPath(xpath: string): EncapsulatedElementType { + return encapsulated({ + appium: () => PlaywrightMatchers.getLazyElementByXPath(xpath), + }); + } + + get connectButton(): EncapsulatedElementType { + return encapsulated({ + appium: { + android: () => + PlaywrightMatchers.getLazyElementByXPath( + '//*[@data-testid="navbar-connect-wallet"]', + ), + ios: () => + PlaywrightMatchers.getElementById('Connect', { exact: true }), + }, + }); + } + + get walletConnect(): EncapsulatedElementType { + return encapsulated({ + appium: { + android: () => + PlaywrightMatchers.getElementByXPath( + '//*[contains(normalize-space(.), "WalletConnect")]', + ), + ios: () => + PlaywrightMatchers.getElementByXPath( + '//XCUIElementTypeStaticText[@name="WalletConnect"]', + ), + }, + }); + } + + get metaMaskWalletOption(): EncapsulatedElementType { + return encapsulated({ + appium: { + android: () => + PlaywrightMatchers.getLazyElementByXPath( + '//android.widget.Button[@text="MetaMask MetaMask"]', + ), + ios: () => + PlaywrightMatchers.getElementById('MetaMask MetaMask', { + exact: true, + }), + }, + }); + } + + get metaMaskDeeplinkButton(): EncapsulatedElementType { + return encapsulated({ + appium: { + android: () => + PlaywrightMatchers.getLazyElementByXPath( + '//android.widget.TextView[@text="MetaMask"]', + ), + ios: () => + PlaywrightMatchers.getLazyElementByXPath( + '//XCUIElementTypeOther[@name="textfield"]', + ), + }, + }); + } + + get uniswapDialog(): EncapsulatedElementType { + return this.getByXPath('//android.app.AlertDialog'); + } + + get uniswapIcon(): EncapsulatedElementType { + return encapsulated({ + appium: () => PlaywrightMatchers.getElementById('account-icon'), + }); + } + + get solanaPopup(): EncapsulatedElementType { + return encapsulated({ + appium: () => + PlaywrightMatchers.getElementByText('Use Solana on Uniswap'), + }); + } + + get SolanaPopup(): EncapsulatedElementType { + return this.solanaPopup; + } + + async waitForConnectButtonVisible(timeoutMs = 20000): Promise { + await this.waitForElementVisible( + this.connectButton, + timeoutMs, + 'UniswapDapp: connect button not visible', + ); + } + + async waitForWalletConnectVisible(timeoutMs = 15000): Promise { + await this.waitForElementVisible( + this.walletConnect, + timeoutMs, + 'UniswapDapp: WalletConnect option not visible', + ); + } + + async tapConnect(): Promise { + await PlaywrightGestures.waitAndTap( + await asPlaywrightElement(this.connectButton), + { + delay: 3000, // 3 seconds - DOM might not be ready yet + }, + ); + } + + async tapOnWalletConnect(): Promise { + await PlaywrightGestures.waitAndTap( + await asPlaywrightElement(this.walletConnect), + { + delay: 3000, // 3 seconds - DOM might not be ready yet + }, + ); + } + + async connectWithMetaMask(): Promise { + await this.waitForConnectButtonVisible(); + await this.tapConnect(); + await this.waitForWalletConnectVisible(); + await this.tapOnWalletConnect(); + } + + async connectIOS(timeoutMs = 20000): Promise { + await this.waitForConnectButtonVisible(timeoutMs); + await this.tapConnect(); + } + + async selectWalletConnectOption(): Promise { + await this.tapOnWalletConnect(); + } + + async tapOnMetaMaskWalletOption(): Promise { + await UnifiedGestures.waitAndTap(this.metaMaskWalletOption, { + description: 'tap MetaMask wallet option', + }); + } + + async tapOnMetaMaskDeeplinkButton(): Promise { + await encapsulatedAction({ + appium: async () => { + await sleep(2000); + await PlaywrightGestures.waitAndTap( + await asPlaywrightElement(this.metaMaskDeeplinkButton), + ); + }, + }); + } + + async tapOnMetaMaskWalletOptionAndOpenDeeplink(): Promise { + await this.tapOnMetaMaskWalletOption(); + if (PlatformDetector.isAndroid()) { + await this.tapOnMetaMaskDeeplinkButton(); + } + } + + async isUniswapDisplayed(timeoutMs = 30000): Promise { + await encapsulatedAction({ + appium: async () => { + if (PlatformDetector.isAndroid()) { + const dialogVisible = await this.isElementVisible( + this.uniswapDialog, + timeoutMs, + ); + + if (dialogVisible) { + return; + } + + const iconVisible = await this.isElementVisible( + this.uniswapIcon, + timeoutMs, + ); + + if (!iconVisible) { + throw new Error( + 'Neither Uniswap dialog nor account icon is visible in Android context', + ); + } + + return; + } + + await this.waitForElementVisible( + this.solanaPopup, + timeoutMs, + 'UniswapDapp: Solana popup not visible', + ); + }, + }); + } + + private async waitForElementVisible( + targetElement: EncapsulatedElementType, + timeoutMs: number, + timeoutMsg: string, + ): Promise { + await encapsulatedAction({ + appium: async () => { + await PlaywrightAssertions.expectConditionWithRetry( + async () => { + const resolvedElement = await asPlaywrightElement(targetElement); + await resolvedElement.waitForDisplayed({ + timeout: timeoutMs, + timeoutMsg, + }); + }, + { + maxRetries: 5, + description: timeoutMsg, + }, + ); + }, + }); + } + + private async isElementVisible( + targetElement: EncapsulatedElementType, + timeoutMs: number, + ): Promise { + try { + const resolvedElement = await asPlaywrightElement(targetElement); + await resolvedElement.waitForDisplayed({ timeout: timeoutMs }); + return true; + } catch { + return false; + } + } +} + +export default new UniswapDapp(); diff --git a/tests/performance/login/uniswap-interaction.spec.js b/tests/performance/login/uniswap-interaction.spec.js deleted file mode 100644 index 03e5d22bb159..000000000000 --- a/tests/performance/login/uniswap-interaction.spec.js +++ /dev/null @@ -1,106 +0,0 @@ -import { test } from '../../framework/fixtures/performance/index.ts'; -import TimerHelper from '../../framework/TimerHelper.ts'; - -import { login } from '../../framework/utils/Flows.js'; -import { - switchToMobileBrowser, - navigateToDapp, - launchMobileBrowser, -} from '../../framework/utils/MobileBrowser.js'; -import WalletMainScreen from '../../../wdio/screen-objects/WalletMainScreen.js'; -import UniswapDapp from '../../../wdio/screen-objects/UniswapDapp.js'; -import AndroidScreenHelpers from '../../../wdio/screen-objects/Native/Android.js'; -import DappConnectionModal from '../../../wdio/screen-objects/Modals/DappConnectionModal.js'; -import AccountListComponent from '../../../wdio/screen-objects/AccountListComponent.js'; -import AppwrightHelpers from '../../framework/AppwrightHelpers.ts'; -import { unlockIfLockScreenVisible } from '../mm-connect/utils.js'; -import { PerformanceLogin } from '../../tags.performance.js'; -import AppwrightSelectors from '../../framework/AppwrightSelectors.ts'; - -const UNISWAP_URL = 'https://app.uniswap.org'; -const UNISWAP_DAPP_NAME = 'Uniswap'; - -// TODO(MMQA-1616): Re-enable after migrating this spec to tests/framework/fixture. - -test.describe(`${PerformanceLogin}`, () => { - test.setTimeout(240000); - - test.skip( - 'Connect to Uniswap dapp, edit accounts, choose another account, and skip Solana popup', - { tag: '@metamask-mobile-platform' }, - async ({ device, performanceTracker }, testInfo) => { - WalletMainScreen.device = device; - UniswapDapp.device = device; - AndroidScreenHelpers.device = device; - DappConnectionModal.device = device; - AccountListComponent.device = device; - - const metamaskTimer = new TimerHelper( - 'Time since the user selects Metamask until Metamask app is opened', - { ios: 15000, android: 20000 }, - device, - ); - - const connectTimer = new TimerHelper( - 'Time since the user taps Connect in MetaMask until Uniswap is displayed', - { ios: 15000, android: 20000 }, - device, - ); - await login(device); - // 1. Login and navigate to Uniswap in the mobile browser - await AppwrightHelpers.withNativeAction(device, async () => { - await launchMobileBrowser(device); - await navigateToDapp(device, UNISWAP_URL, UNISWAP_DAPP_NAME); - }); - - // Wait for Uniswap to fully load before interacting - await new Promise((resolve) => setTimeout(resolve, 5000)); - - // 2. Tap Connect on Uniswap and select MetaMask from the wallet picker - if (AppwrightSelectors.isAndroid(device)) { - await AppwrightHelpers.withWebAction( - device, - async () => { - await UniswapDapp.connectWithMetaMask(); - }, - UNISWAP_URL, - ); - } else { - await AppwrightHelpers.withNativeAction(device, async () => { - await UniswapDapp.connectIOS(); - await new Promise((resolve) => setTimeout(resolve, 3000)); - await UniswapDapp.selectWalletConnectOption(); - }); - } - - // 3. Click MetaMask in native wallet picker. - await AppwrightHelpers.withNativeAction(device, async () => { - await UniswapDapp.tapOnMetaMaskWalletOptionAndOpenDeeplink(); - }); - metamaskTimer.start(); - // 4. Handle MetaMask connection modal in native context: - // - unlock if lock screen is shown - // - edit account selection to pick a different account - // - tap Connect (timer starts here) - await AppwrightHelpers.withNativeAction(device, async () => { - await unlockIfLockScreenVisible(device); - metamaskTimer.stop(); - await DappConnectionModal.tapEditAccountsButton(); - await DappConnectionModal.tapUpdateAccountsButton(); - - await DappConnectionModal.tapConnectButton(); - }); - connectTimer.start(); - await switchToMobileBrowser(device); - await AppwrightHelpers.withNativeAction(device, async () => { - if (AppwrightSelectors.isAndroid(device)) { - // with the current framework we are limited with autoaccept alerts and on ios it clicks it before we can make the assertion - await UniswapDapp.isUniswapDisplayed(); - } - }); - connectTimer.stop(); - - performanceTracker.addTimers(metamaskTimer, connectTimer); - }, - ); -}); diff --git a/tests/performance/login/uniswap-interaction.spec.ts b/tests/performance/login/uniswap-interaction.spec.ts new file mode 100644 index 000000000000..abbdf3f6563f --- /dev/null +++ b/tests/performance/login/uniswap-interaction.spec.ts @@ -0,0 +1,94 @@ +import { test as perfTest } from '../../framework/fixture'; +import TimerHelper from '../../framework/TimerHelper'; +import UniswapDapp from '../../page-objects/MMConnect/UniswapDapp'; +import DappConnectionModal from '../../page-objects/MMConnect/DappConnectionModal'; +import { unlockIfLockScreenVisible } from '../mm-connect/utils'; +import { PerformanceLogin } from '../../tags.performance.js'; +import { loginToAppPlaywright } from '../../flows/wallet.flow'; +import PlaywrightContextHelpers from '../../framework/PlaywrightContextHelpers'; +import { + launchMobileBrowser, + navigateToDapp, + switchToMobileBrowser, +} from '../../flows/native-browser.flow'; + +const UNISWAP_URL = 'https://app.uniswap.org'; + +perfTest.describe(`${PerformanceLogin}`, () => { + perfTest.setTimeout(10 * 60 * 1000); + + perfTest( + 'Connect to Uniswap dapp, edit accounts, choose another account, and skip Solana popup', + { tag: '@metamask-mobile-platform' }, + async ({ currentDeviceDetails, driver: _driver, performanceTracker }) => { + const { platform } = currentDeviceDetails; + + const metamaskTimer = new TimerHelper( + 'Time since the user selects Metamask until Metamask app is opened', + { ios: 15000, android: 20000 }, + platform, + ); + + const connectTimer = new TimerHelper( + 'Time since the user taps Connect in MetaMask until Uniswap is displayed', + { ios: 15000, android: 20000 }, + platform, + ); + await loginToAppPlaywright(); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await launchMobileBrowser(); + await navigateToDapp(UNISWAP_URL); + }); + + // Wait for Uniswap to fully load before interacting + await new Promise((resolve) => setTimeout(resolve, 5000)); + + if (platform === 'android') { + await PlaywrightContextHelpers.withWebAction(async () => { + await UniswapDapp.connectWithMetaMask(); + }, UNISWAP_URL); + } else { + await PlaywrightContextHelpers.withNativeAction(async () => { + await UniswapDapp.connectIOS(); + await UniswapDapp.selectWalletConnectOption(); + }); + } + + // Android comes from a webAction so needs to be in native context + if (platform === 'android') { + await PlaywrightContextHelpers.withNativeAction(async () => { + await UniswapDapp.tapOnMetaMaskWalletOptionAndOpenDeeplink(); + }); + } else { + // iOS comes from a nativeAction so no need to change context + await UniswapDapp.tapOnMetaMaskWalletOptionAndOpenDeeplink(); + } + + metamaskTimer.start(); + + // Still on Native Context + await unlockIfLockScreenVisible(); + metamaskTimer.stop(); + await DappConnectionModal.tapEditAccountsButton(); + await DappConnectionModal.tapUpdateAccountsButton(); + + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + + connectTimer.start(); + + await switchToMobileBrowser(); + + if (platform === 'android') { + await UniswapDapp.isUniswapDisplayed(); + } + + connectTimer.stop(); + + performanceTracker.addTimers(metamaskTimer, connectTimer); + }, + ); +}); diff --git a/tests/scripts/update-e2e-fixture.sh b/tests/scripts/update-e2e-fixture.sh index 3904efa161fc..3a0a3f5f96d2 100755 --- a/tests/scripts/update-e2e-fixture.sh +++ b/tests/scripts/update-e2e-fixture.sh @@ -10,7 +10,7 @@ TARGET_FILE="tests/framework/fixtures/json/default-fixture.json" if [ ! -f "$REPORT_FILE" ]; then echo "Error: $REPORT_FILE not found." echo "Run the fixture validation test first:" - echo " yarn detox test tests/regression/fixtures/fixture-validation.spec.ts -c " + echo " yarn detox test tests/smoke/fixtures/fixture-validation.spec.ts -c " exit 1 fi diff --git a/tests/regression/fixtures/fixture-validation.spec.ts b/tests/smoke/fixtures/fixture-validation.spec.ts similarity index 100% rename from tests/regression/fixtures/fixture-validation.spec.ts rename to tests/smoke/fixtures/fixture-validation.spec.ts