diff --git a/.claude/commands/create-bug.md b/.claude/commands/create-bug.md index a531bb76145a..7fecc5a308e1 100644 --- a/.claude/commands/create-bug.md +++ b/.claude/commands/create-bug.md @@ -124,7 +124,32 @@ For `render: shell` fields, wrap the value in a code fence: - Output the issue URL so the user can view it - Ask the user: "Would you like me to investigate the codebase for the possible root cause? (yes/no)" - Default is **no** — only proceed if the user explicitly says yes -- If yes, investigate the codebase (research only — no code changes) and add a comment using: +- If yes, perform the full **Root Cause Analysis** (Step 6) + +## Step 6: Root Cause Analysis + +When the user opts in, perform all three phases below and post a single comment on the issue with the combined findings. + +### 6a: Investigate the root cause + +- Trace the bug through the code to identify the exact files, lines, and logic causing the issue +- Research only — no code changes + +### 6b: Identify regression PRs + +- Check `git log` history of affected files, comparing the release branch against the previous release branch +- Identify which PRs introduced or surfaced the bug (new code, removed compensating mechanisms, refactors, etc.) +- Note whether the issue is a new regression or pre-existing + +### 6c: Scope analysis + +- Search the codebase for the same pattern, function, or anti-pattern causing the bug +- Identify all affected areas beyond the originally reported bug +- If other features are impacted, file separate bug issues for each and link them + +### Comment format + +Post findings as a comment using: ```bash gh issue comment --repo MetaMask/metamask-mobile --body "..." @@ -132,7 +157,10 @@ gh issue comment --repo MetaMask/metamask-mobile --body "..." The comment should include: -- A summary of the possible root cause -- The error flow with relevant file paths and line numbers -- Key files table (file, line(s), description) -- A suggested fix approach +- **Summary** of the root cause +- **Likely Regression PR(s)** — PR numbers, titles, authors, and explanation of what changed (or note if pre-existing) +- **Error flow** with relevant file paths and line numbers +- **Full scope of impact** — all affected files, hooks, and components beyond the reported bug +- **Key files table** (file, line(s), description) +- **Suggested fix approach** +- **Links to related bugs** filed from the scope analysis diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json new file mode 100644 index 000000000000..cbb8fe98f1fc --- /dev/null +++ b/.cursor/worktrees.json @@ -0,0 +1,9 @@ +{ + "setup-worktree": [ + "cp $ROOT_WORKTREE_PATH/.js.env .js.env", + "cp $ROOT_WORKTREE_PATH/.ios.env .ios.env", + "cp $ROOT_WORKTREE_PATH/.android.env .android.env", + "cp $ROOT_WORKTREE_PATH/.e2e.env .e2e.env", + "cp -r $ROOT_WORKTREE_PATH/.cursor/plans .cursor/plans" + ] +} diff --git a/.e2e.env.example b/.e2e.env.example index f70f8c38bb3e..fc88ce2c6896 100644 --- a/.e2e.env.example +++ b/.e2e.env.example @@ -27,6 +27,18 @@ export TEST_SRP_2= export TEST_SRP_3= export BROWSERSTACK_USERNAME= export BROWSERSTACK_ACCESS_KEY= + +# Appwright performance -> Sentry instrumentation +# If E2E_PERFORMANCE_SENTRY_DSN is defined, each scenario uploads timers to Sentry. +export E2E_PERFORMANCE_SENTRY_DSN= +# Optional. Set to false to disable upload even when DSN is present. +export E2E_PERFORMANCE_SENTRY_ENABLED=true +# Optional sample rate between 0 and 1 (default: 1). +export E2E_PERFORMANCE_SENTRY_SAMPLE_RATE=1 +# Optional metadata for Sentry events. +export E2E_PERFORMANCE_SENTRY_ENVIRONMENT=e2e-performance +export E2E_PERFORMANCE_SENTRY_RELEASE= + # Set BROWSERSTACK_LOCAL=true when using run-appwright:mm-connect-android-bs-local (BrowserStack Local tunnel) # BROWSERSTACK_LOCAL=true export SEEDLESS_ONBOARDING_ENABLED= diff --git a/.eslintignore b/.eslintignore index f654ed55c5e8..15edab97f270 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,8 @@ /scripts/inpage-bridge /app/core/InpageBridgeWeb3.js +/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js +/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts +/app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js /app/util/blockies.js __snapshots__ android diff --git a/.eslintrc.js b/.eslintrc.js index 060b6c19802d..5e5c90cfa8cd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -105,11 +105,29 @@ module.exports = { }, }, { + // Temporary rollout strategy: + // Keep color-no-hex disabled for all tests by default, then re-enable it + // for specific folders in small PR batches. Once migration is complete, + // remove this override and enforce across all tests in: + // - app/components/ + // - app/component-library/ files: ['**/*.test.{js,ts,tsx}', '**/*.stories.{js,ts,tsx}'], rules: { '@metamask/design-tokens/color-no-hex': 'off', }, }, + { + files: ['app/components/UI/Card/**/*.{js,jsx,ts,tsx}'], + rules: { + '@metamask/design-tokens/color-no-hex': 'error', + }, + }, + { + files: ['app/components/Snaps/**/*.{js,jsx,ts,tsx}'], + rules: { + '@metamask/design-tokens/color-no-hex': 'error', + }, + }, { files: [ 'app/components/UI/Name/**/*.{js,ts,tsx}', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 161fdb4836da..912fdf92b376 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -70,8 +70,11 @@ app/selectors/rampsController @MetaMask/ramp **/ramps/** @MetaMask/ramp # Card Team -app/components/UI/Card/ @MetaMask/card -app/core/redux/slices/card/ @MetaMask/card +app/components/UI/Card/ @MetaMask/card +app/core/redux/slices/card/ @MetaMask/card +app/core/Engine/controllers/card-controller @MetaMask/card +app/core/Engine/messengers/card-controller-messenger @MetaMask/card +app/selectors/cardController.ts @MetaMask/card # Confirmation Team app/components/Views/confirmations @MetaMask/confirmations @@ -107,7 +110,6 @@ app/core/SnapKeyring @MetaMask/accounts-e # Co-owned by accounts and mobile-core-ux app/components/Views/AccountSelector @MetaMask/accounts-engineers @MetaMask/mobile-core-ux -app/components/UI/EvmAccountSelectorList @MetaMask/accounts-engineers @MetaMask/mobile-core-ux # Multichain Accounts **/multichain-accounts/** @MetaMask/accounts-engineers diff --git a/.github/scripts/collect-qa-stats.mjs b/.github/scripts/collect-qa-stats.mjs new file mode 100644 index 000000000000..ced016211bc6 --- /dev/null +++ b/.github/scripts/collect-qa-stats.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/** + * + * Downloads pre-aggregated QA stats artifacts from the triggering CI run via the + * GitHub API and writes a qa-stats.json file for consumption by downstream workflows. + * + * Required env vars: + * GITHUB_TOKEN — GitHub Actions token for API access + * WORKFLOW_RUN_ID — ID of the CI run that produced the artifacts + * + * Example of output format of qa-stats.json: + * { + * "component_view_tests_count": 34, + * "unit_test_count": 679, + * } + * + * How to add a new metric: + * 1. Add a collector function below (see existing example) + * 2. Call it in main() and assign the result to stats + */ + +import { readFile, writeFile, mkdir } from 'fs/promises'; +import { execSync } from 'child_process'; +import { join } from 'path'; + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const WORKFLOW_RUN_ID = process.env.WORKFLOW_RUN_ID; + +if (!WORKFLOW_RUN_ID) throw new Error('Missing required WORKFLOW_RUN_ID env var'); +if (!GITHUB_TOKEN) throw new Error('Missing required GITHUB_TOKEN env var'); + + +// --------------------------------------------------------------------------- +// GitHub artifact helpers +// --------------------------------------------------------------------------- + +let _artifactList = null; + +/** + * Fetches (and caches) the list of artifacts for the triggering CI run. + * First call fetches and stores, every subsequent call returns the cached value. + * + * @returns {Promise} + */ +async function getArtifactList() { + if (_artifactList) return _artifactList; + + const artifacts = []; + let page = 1; + + while (true) { + const url = `https://api.github.com/repos/MetaMask/metamask-mobile/actions/runs/${WORKFLOW_RUN_ID}/artifacts?per_page=100&page=${page}`; + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${GITHUB_TOKEN}`, + Accept: 'application/vnd.github+json', + }, + }); + + if (!res.ok) { + throw new Error(`Failed to list artifacts (page ${page}): ${res.status} ${res.statusText}`); + } + + const data = await res.json(); + artifacts.push(...data.artifacts); + + if (data.artifacts.length < 100) break; + page++; + } + + _artifactList = artifacts; + return _artifactList; +} + +/** + * Downloads a named artifact from the triggering CI run, extracts it into a + * local directory named after the artifact, and returns that directory path. + * + * @param {string} artifactName + * @returns {Promise} Path to the directory containing the extracted files + */ +async function downloadArtifact(artifactName) { + const artifacts = await getArtifactList(); + const artifact = artifacts.find((a) => a.name === artifactName); + + if (!artifact) { + throw new Error( + `Artifact "${artifactName}" not found in run ${WORKFLOW_RUN_ID}`, + ); + } + + // GitHub redirects to a pre-signed S3 URL. Follow manually so the + // Authorization header is not forwarded to S3. + const redirectRes = await fetch(artifact.archive_download_url, { + headers: { + Authorization: `Bearer ${GITHUB_TOKEN}`, + Accept: 'application/vnd.github+json', + }, + redirect: 'manual', + }); + + const downloadUrl = redirectRes.headers.get('location'); + if (!downloadUrl) { + throw new Error(`No redirect URL returned for artifact "${artifactName}"`); + } + + const zipRes = await fetch(downloadUrl); + if (!zipRes.ok) { + throw new Error( + `Failed to download artifact "${artifactName}": ${zipRes.status} ${zipRes.statusText}`, + ); + } + + const destDir = `./${artifactName}`; + await mkdir(destDir, { recursive: true }); + const zipPath = join(destDir, `${artifactName}.zip`); + await writeFile(zipPath, Buffer.from(await zipRes.arrayBuffer())); + execSync(`unzip -q "${zipPath}" -d "${destDir}"`); + + return destDir; +} + +// --------------------------------------------------------------------------- +// Collectors — one async function per metric source +// --------------------------------------------------------------------------- + +async function collectComponentViewTestCount() { + const destDir = await downloadArtifact('cv-test-stats'); + const raw = await readFile(join(destDir, 'cv-test-stats.json'), 'utf8'); + const data = JSON.parse(raw); + return data.component_view_test_number; +} + +async function collectUnitTestCount() { + const destDir = await downloadArtifact('unit-test-stats'); + const raw = await readFile(join(destDir, 'unit-test-stats.json'), 'utf8'); + const data = JSON.parse(raw); + return data.unit_test_number; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const stats = {}; + + const collectors = [ + { + key: 'component_view_tests_count', + collect: collectComponentViewTestCount, + }, + { + key: 'unit_tests_count', + collect: collectUnitTestCount, + }, + ]; + + for (const { key, collect } of collectors) { + try { + stats[key] = await collect(); + } catch (err) { + // stat will not be present in the output file if the collector fails + console.error(`[${key}] collector failed, skipping stat:`, err.message); + } + } + + const outputPath = './qa-stats.json'; + await writeFile(outputPath, JSON.stringify(stats, null, 2), 'utf8'); + console.log(`✅ QA stats written to ${outputPath}:`, stats); +} + +main().catch((err) => { + console.error('\n❌ Unexpected error:', err); + process.exit(1); +}); diff --git a/.github/scripts/e2e-report-fixture-validation.mjs b/.github/scripts/e2e-report-fixture-validation.mjs new file mode 100644 index 000000000000..af04fa1651e1 --- /dev/null +++ b/.github/scripts/e2e-report-fixture-validation.mjs @@ -0,0 +1,163 @@ +/** + * Reports E2E fixture validation results as a PR comment and GitHub annotation. + * + * Reads fixture-validation-result.json from the downloaded artifact directory, + * posts/updates a PR comment with the results, and emits GitHub annotations. + * + * Environment variables: + * RESULTS_PATH - Path to the downloaded artifact directory + * VALIDATION_RESULT - The upstream job result (success/failure/cancelled) + * GITHUB_TOKEN - GitHub token for API calls + * PR_NUMBER - Pull request number (empty for non-PR events) + * GITHUB_REPOSITORY - owner/repo + * RUN_URL - URL to the workflow run + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const { + RESULTS_PATH = '', + VALIDATION_RESULT = '', + GITHUB_TOKEN = '', + PR_NUMBER = '', + GITHUB_REPOSITORY = '', + RUN_URL = '', +} = process.env; + +const COMMENT_MARKER = '**E2E Fixture Validation'; + +function readResults() { + const jsonPath = path.join(RESULTS_PATH, 'fixture-validation-result.json'); + if (!fs.existsSync(jsonPath)) return null; + return JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); +} + +function buildComment(results) { + if (VALIDATION_RESULT === 'failure' && !results) { + return `❌ ${COMMENT_MARKER} — Failed**\nThe fixture validation job failed. [Review the logs](${RUN_URL})`; + } + + if (!results) { + if (VALIDATION_RESULT === 'success') { + return `✅ ${COMMENT_MARKER} — Passed**\n[View details](${RUN_URL})`; + } + return null; + } + + const { hasStructuralChanges, newKeys, missingKeys, typeMismatches, valueMismatches } = results; + + if (hasStructuralChanges) { + return [ + `⚠️ ${COMMENT_MARKER} — Structural changes detected**`, + '', + '| Category | Count |', + '|----------|-------|', + `| New keys | ${newKeys} |`, + `| Missing keys | ${missingKeys} |`, + `| Type mismatches | ${typeMismatches} |`, + `| Value mismatches | ${valueMismatches} (informational) |`, + '', + 'The committed fixture schema is out of date. To update, comment:', + '```', + '@metamaskbot update-mobile-fixture', + '```', + `[View full details](${RUN_URL}) | [Download diff report](${RUN_URL}#artifacts)`, + ].join('\n'); + } + + if (valueMismatches > 0) { + return `✅ ${COMMENT_MARKER} — Schema is up to date**\n${valueMismatches} value mismatches detected (expected — fixture represents an existing user).\n[View details](${RUN_URL})`; + } + + return `✅ ${COMMENT_MARKER} — No differences found**\nFixture is up to date. [View details](${RUN_URL})`; +} + +function emitAnnotation(results) { + if (!results) { + if (VALIDATION_RESULT === 'failure') { + console.log('::error::Fixture validation job failed.'); + } else if (VALIDATION_RESULT === 'success') { + console.log('::notice::Fixture validation passed.'); + } + return; + } + + const { hasStructuralChanges, newKeys, missingKeys, typeMismatches, valueMismatches } = results; + + if (hasStructuralChanges) { + console.log(`::warning::Fixture schema out of date — New: ${newKeys}, Missing: ${missingKeys}, Type mismatches: ${typeMismatches}. Run @metamaskbot update-mobile-fixture`); + } else if (valueMismatches > 0) { + console.log(`::notice::Fixture schema up to date. ${valueMismatches} value mismatches (expected).`); + } else { + console.log('::notice::Fixture validation passed — no differences found.'); + } +} + +async function ghApi(endpoint, options = {}) { + const { headers: extraHeaders, ...restOptions } = options; + const res = await fetch(`https://api.github.com${endpoint}`, { + headers: { + Authorization: `Bearer ${GITHUB_TOKEN}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json', + ...extraHeaders, + }, + ...restOptions, + }); + if (!res.ok && restOptions.method !== 'DELETE') { + const text = await res.text().catch(() => ''); + throw new Error(`GitHub API ${res.status}: ${text}`); + } + if (res.status === 204) return null; + if (!res.ok) return null; // DELETE with error — skip silently + return res.json(); +} + +async function deletePreviousComments() { + let page = 1; + // eslint-disable-next-line no-constant-condition + while (true) { + const comments = await ghApi( + `/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments?per_page=100&page=${page}`, + ); + if (!comments || comments.length === 0) break; + for (const comment of comments) { + if (comment.body && comment.body.includes(COMMENT_MARKER)) { + await ghApi(`/repos/${GITHUB_REPOSITORY}/issues/comments/${comment.id}`, { method: 'DELETE' }); + } + } + if (comments.length < 100) break; + page++; + } +} + +async function postComment(body) { + await ghApi(`/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments`, { + method: 'POST', + body: JSON.stringify({ body }), + }); +} + +async function main() { + const results = readResults(); + + // Always emit annotation + emitAnnotation(results); + + // Post PR comment if this is a PR + if (PR_NUMBER) { + const comment = buildComment(results); + if (comment) { + await deletePreviousComments(); + await postComment(comment); + console.log(`Posted fixture validation comment on PR #${PR_NUMBER}`); + } + } +} + +main().catch((err) => { + console.error('Failed to report fixture validation:', err); + process.exit(1); +}); diff --git a/.github/scripts/shared/template.ts b/.github/scripts/shared/template.ts index a21aece584ff..ce0aba012a62 100644 --- a/.github/scripts/shared/template.ts +++ b/.github/scripts/shared/template.ts @@ -27,7 +27,6 @@ const bugReportIssueTemplateTitles = [ '### Expected behavior', '### Screenshots', // TODO: replace '### Screenshots' by '### Screenshots/Recordings' in January 2024 (as most issues will meet this criteria by then) '### Steps to reproduce', - '### Error messages or log output', '### Version', '### Build type', '### Device', diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 4c3047c68c46..07cdc4cb274a 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -53,13 +53,23 @@ jobs: - name: Setup Android Build Environment timeout-minutes: 15 - uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1 + uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1.7.0 with: platform: android setup-simulator: false configure-keystores: true target: ${{ inputs.keystore_target }} # qa for taget=main and flask for target=flask + + - name: Restore .metamask folder + id: restore-metamask + uses: actions/cache@v4 + with: + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} + - name: Install Foundry if cache missed + if: steps.restore-metamask.outputs.cache-hit != 'true' + run: yarn install:foundryup - name: Setup project dependencies with retry uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index b15e262b24bd..f5cd550a5f9e 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -100,7 +100,7 @@ jobs: # Install Node.js, Xcode tools, and other iOS development dependencies - name: Installing iOS Environment Setup timeout-minutes: 15 - uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1 + uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1.7.0 with: platform: ios setup-simulator: false @@ -123,6 +123,16 @@ jobs: - name: Clean iOS plist files run: find ios -name "*.plist" -exec xattr -c {} \; + - name: Restore .metamask folder + id: restore-metamask + uses: actions/cache@v4 + with: + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} + + - name: Install Foundry if cache missed + if: steps.restore-metamask.outputs.cache-hit != 'true' + run: yarn install:foundryup # Run project setup with retry for better resilience - name: Setup project dependencies with retry uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 74b68cfd9624..a1d3334047f0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,10 @@ on: platform: required: true type: string # android, ios, or both + skip_version_bump: + required: false + type: boolean + default: false workflow_dispatch: inputs: build_name: @@ -32,14 +36,20 @@ on: required: true type: choice options: [android, ios, both] + skip_version_bump: + required: false + type: boolean + default: false permissions: contents: read id-token: write jobs: - # Bump version in repo (bitrise.yml, package.json, ios/android) before building + # Bump version in repo (bitrise.yml, package.json, ios/android) before building. + # Skipped when skip_version_bump=true (e.g. nightly builds where Bitrise owns the bump). update-build-version: + if: ${{ !inputs.skip_version_bump }} uses: ./.github/workflows/update-latest-build-version.yml permissions: contents: write @@ -52,6 +62,7 @@ jobs: # Load config prepare: needs: [update-build-version] + if: ${{ always() && !failure() && !cancelled() }} runs-on: ubuntu-latest outputs: github_environment: ${{ steps.config.outputs.github_environment }} @@ -61,9 +72,10 @@ jobs: signing_android_keystore_path: ${{ steps.config.outputs.signing_android_keystore_path }} steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 with: - node-version: '20' + node-version-file: '.nvmrc' - run: yarn install --immutable - run: node scripts/validate-build-config.js @@ -83,9 +95,28 @@ jobs: fs.appendFileSync(process.env.GITHUB_OUTPUT, 'signing_android_keystore_path=' + (signing && signing.android_keystore_path ? signing.android_keystore_path : '') + '\n'); " + # Setup dependencies (no secrets) - uses reusable workflow on platform-specific runner + setup-dependencies: + name: Setup Dependencies (${{ matrix.platform }}) + needs: [prepare] + strategy: + matrix: + platform: ${{ inputs.platform == 'both' && fromJSON('["android", "ios"]') || fromJSON(format('["{0}"]', inputs.platform)) }} + uses: ./.github/workflows/setup-node-modules.yml + with: + checkout-submodules: true + platform: ${{ matrix.platform }} + build_name: ${{ inputs.build_name }} + use-tarball: true + upload-artifact: true + artifact-name: node-modules-${{ matrix.platform }} + artifact-retention-days: 1 + # Build build: - needs: [prepare, update-build-version] + needs: [prepare, setup-dependencies, update-build-version] + if: ${{ always() && !failure() && !cancelled() }} + timeout-minutes: 120 # Must allow build step (115min) + setup; comment at Build step documents this strategy: matrix: platform: ${{ inputs.platform == 'both' && fromJSON('["android", "ios"]') || fromJSON(format('["{0}"]', inputs.platform)) }} @@ -94,6 +125,7 @@ jobs: environment: ${{ needs.prepare.outputs.github_environment }} steps: - name: Validate version-bump commit + if: ${{ !inputs.skip_version_bump }} run: | COMMIT_HASH="${{ needs.update-build-version.outputs.commit-hash }}" if [ -z "$COMMIT_HASH" ]; then @@ -102,20 +134,89 @@ jobs: fi echo "Building at version-bump commit: $COMMIT_HASH" - uses: actions/checkout@v4 + if: ${{ !inputs.skip_version_bump }} with: ref: ${{ needs.update-build-version.outputs.commit-hash }} - - uses: actions/setup-node@v4 + submodules: recursive + + - uses: actions/checkout@v4 + if: ${{ inputs.skip_version_bump }} with: - node-version: '20' - cache: 'yarn' - - run: yarn install --immutable + submodules: recursive + + # Node first so we can download/extract/verify; no yarn install here (we use the tarball to avoid running install in this job). + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Download node_modules tarball + uses: actions/download-artifact@v4 + with: + name: node-modules-${{ matrix.platform }} + + - name: Extract tarball (preserves symlinks) + run: | + echo "📦 Extracting tarball..." + tar -xzf node-modules-${{ matrix.platform }}.tar.gz + rm node-modules-${{ matrix.platform }}.tar.gz + echo "✅ Tarball extracted" + + - name: Verify artifacts and symlinks + run: | + echo "✅ Verifying artifacts..." + if [ ! -d "node_modules" ]; then + echo "❌ node_modules not found" + exit 1 + fi + if [ ! -f "app/core/InpageBridgeWeb3.js" ]; then + echo "❌ InpageBridgeWeb3.js not found" + exit 1 + fi + echo "📦 node_modules size: $(du -sh node_modules | cut -f1)" + + # Verify symlinks in .bin directory + echo "🔍 Checking .bin symlinks..." + if [ -e "node_modules/.bin/react-native" ]; then + echo "✅ react-native symlink exists and target is valid" + find node_modules/.bin -maxdepth 1 -name 'react-native' -ls + else + echo "❌ react-native symlink missing or broken" + find node_modules/.bin -maxdepth 1 -ls | head -10 + exit 1 + fi + + # Check React Native scripts + if [ -d "node_modules/react-native/scripts" ]; then + echo "✅ React Native scripts found" + else + echo "❌ React Native scripts missing" + exit 1 + fi + + echo "✅ All artifacts verified with working symlinks!" + + # iOS only: Ruby + CocoaPods + Xcode (no yarn install; node_modules from tarball). Signing is in Configure signing certificates below. + - name: Setup Ruby (iOS) + if: matrix.platform == 'ios' + uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb #v1 + with: + ruby-version: '3.2.9' + working-directory: ios + bundler-cache: true + + - name: Select Xcode (iOS) + if: matrix.platform == 'ios' + env: + XCODE_VERSION: '16.3' + run: sudo xcode-select -s "/Applications/Xcode_$XCODE_VERSION.app" - name: Apply build config run: | # Load env vars from builds.yml (this step only) - eval "$(node scripts/apply-build-config.js "${{ inputs.build_name }}" --export)" + eval "$(node scripts/apply-build-config.js ${{ inputs.build_name }} --export)" # Persist to GITHUB_ENV so later steps (e.g. Build) see CONFIGURATION, IS_SIM_BUILD, etc. - node scripts/apply-build-config.js "${{ inputs.build_name }}" --export-github-env >> "$GITHUB_ENV" + node scripts/apply-build-config.js ${{ inputs.build_name }} --export-github-env >> "$GITHUB_ENV" - name: Validate secrets env: @@ -123,6 +224,20 @@ jobs: SECRETS_JSON: ${{ toJSON(secrets) }} run: node scripts/validate-secrets-from-config.js + # iOS: Install Pods here so generated paths match this runner (setup-node-modules skips pod install with --no-install-pods). + - name: Install CocoaPods dependencies (iOS) + if: matrix.platform == 'ios' + env: + BUNDLE_GEMFILE: ios/Gemfile + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: | + node -e "require('fs').writeFileSync('ios/debug.xcconfig',''); require('fs').writeFileSync('ios/release.xcconfig','');" + yarn pod:install + - name: Set secrets env: CONFIG_SECRETS: ${{ needs.prepare.outputs.secrets_json }} @@ -143,6 +258,16 @@ jobs: java-version: '17' distribution: 'temurin' + # iOS: Clean up any existing keychains from previous runs + - name: Clean up existing keychains + if: matrix.platform == 'ios' && needs.prepare.outputs.signing_aws_role != '' + run: | + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + if [ -f "$KEYCHAIN_PATH" ]; then + echo "🧹 Removing existing keychain..." + security delete-keychain "$KEYCHAIN_PATH" || true + fi + # Signing: uses role + secret from builds.yml (skip for dev builds - main-dev, flask-dev, qa-dev use debug/simulator) - name: Configure signing certificates if: needs.prepare.outputs.signing_aws_role != '' @@ -154,31 +279,18 @@ jobs: aws-secret-name: ${{ needs.prepare.outputs.signing_aws_secret }} android-keystore-path: ${{ needs.prepare.outputs.signing_android_keystore_path }} - # iOS: install Ruby, Bundler, and CocoaPods so setup:github-ci has a working Ruby (fixes self-hosted runners) - - name: Installing iOS Environment Setup + # iOS: Configure Node path for Xcode build scripts (React Native Codegen) + - name: Configure Node path for Xcode if: matrix.platform == 'ios' - timeout-minutes: 15 - uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1 - with: - platform: ios - configure-keystores: false - setup-simulator: false - ruby-version: '3.1.6' - - - name: Setup project dependencies with retry - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 - with: - timeout_minutes: 10 - max_attempts: 3 - retry_wait_seconds: 30 - command: | - echo "🚀 Setting up project..." - if [ "${{ matrix.platform }}" = "ios" ]; then - yarn setup:github-ci --build-ios --no-build-android - else - yarn setup:github-ci --no-build-ios - fi + run: | + NODE_BINARY=$(command -v node) + echo "Node binary: $NODE_BINARY" + echo "export NODE_BINARY=\"$NODE_BINARY\"" > ios/.xcode.env.local + echo "✅ Created ios/.xcode.env.local" + cat ios/.xcode.env.local + node --version + # Build with retry logic. Timeouts: 55min per attempt, 115min total for step, 120min job - name: Build ${{ matrix.platform }} timeout-minutes: 115 uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 @@ -195,6 +307,7 @@ jobs: # Rename build artifacts (also zips simulator .app and writes ios_deploy_path / ios_archive_path outputs) - name: Rename ${{ matrix.platform }} artifacts + if: success() id: rename run: node scripts/rename-artifacts.js ${{ matrix.platform }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 487f68b47d38..ac06c4d9074a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,5 @@ name: ci + on: push: branches: [main] @@ -25,7 +26,7 @@ jobs: cache: yarn - uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb #v1 with: - ruby-version: '3.1.6' + ruby-version: '3.2.9' env: BUNDLE_GEMFILE: ios/Gemfile - name: Install Yarn dependencies with retry @@ -35,6 +36,16 @@ jobs: max_attempts: 3 retry_wait_seconds: 30 command: yarn install --immutable + - name: Restore .metamask folder + id: restore-metamask + uses: actions/cache@v4 + with: + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} + + - name: Install Foundry if cache missed + if: steps.restore-metamask.outputs.cache-hit != 'true' + run: yarn install:foundryup - name: Clean state and following up dependencies installation with retry uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: @@ -51,6 +62,7 @@ jobs: else echo "No changes detected" fi + dedupe: runs-on: ubuntu-latest steps: @@ -82,6 +94,7 @@ jobs: echo "Duplicate dependencies detected; run 'yarn deduplicate' to remove them" exit 1 fi + git-safe-dependencies: runs-on: ubuntu-latest steps: @@ -106,6 +119,7 @@ jobs: max_attempts: 3 retry_wait_seconds: 30 command: yarn git-safe-dependencies + scripts: runs-on: ubuntu-latest strategy: @@ -144,6 +158,71 @@ jobs: else echo "No changes detected" fi + + js-bundle-size-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: yarn + - name: Install Yarn dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn install --immutable + - name: Clean state and following up dependencies installation + run: yarn setup:github-ci --no-build-android + + - name: Generate iOS bundle + run: yarn gen-bundle:ios + env: + NODE_OPTIONS: --max_old_space_size=12288 + + - name: Check bundle size + run: ./scripts/js-bundle-stats.sh ios/main.jsbundle 53 + + - name: Upload iOS bundle + uses: actions/upload-artifact@v4 + with: + name: ios-bundle + path: ios/main.jsbundle + + ship-js-bundle-size-check: + runs-on: ubuntu-latest + needs: [js-bundle-size-check] + if: ${{ github.ref == 'refs/heads/main' }} + steps: + - uses: actions/checkout@v4 + + - name: Download iOS bundle + uses: actions/download-artifact@v4 + with: + name: ios-bundle + path: ios/main.jsbundle + + - name: Push bundle size to mobile_bundlesize_stats repo + run: ./scripts/push-bundle-size.sh + env: + GITHUB_ACTOR: metamaskbot + GITHUB_TOKEN: ${{ secrets.MOBILE_BUNDLESIZE_TOKEN }} + + check-workflows: + name: Check workflows + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Download actionlint + id: download-actionlint + run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/62dc61a45fc95efe8c800af7a557ab0b9165d63b/scripts/download-actionlint.bash) 1.7.1 + shell: bash + - name: Check workflow files + run: ${{ steps.download-actionlint.outputs.executable }} -color -config-file .github/actionlint.yaml + shell: bash + unit-tests: runs-on: ubuntu-latest strategy: @@ -164,19 +243,23 @@ jobs: command: yarn install --immutable - name: Clean state and following up dependencies installation run: yarn setup:github-ci --node + - name: Prepare results directory + run: mkdir -p tests/results # The "10" in this command is the total number of shards. It must be kept # in sync with the length of matrix.shard - - run: yarn test:unit --shard=${{ matrix.shard }}/10 --forceExit --silent --coverageReporters=json + - run: yarn test:unit --shard=${{ matrix.shard }}/10 --forceExit --silent --coverageReporters=json --json --outputFile=tests/results/unit-test-results-${{ matrix.shard }}.json env: NODE_OPTIONS: --max_old_space_size=20480 - - name: Rename coverage report to include shard number + - name: Rename coverage report and extract test count for this shard shell: bash run: | - mv ./tests/coverage/coverage-final.json ./tests/coverage/coverage-${{ matrix.shard }}.json + mv ./tests/coverage/coverage-final.json ./tests/coverage/coverage-unit-${{ matrix.shard }}.json + count=$(jq '(.numPassedTests // 0) + (.numFailedTests // 0)' tests/results/unit-test-results-${{ matrix.shard }}.json) + echo "{\"count\": $count}" > ./tests/coverage/count.json - uses: actions/upload-artifact@v4 with: - name: coverage-${{ matrix.shard }} - path: ./tests/coverage/coverage-${{ matrix.shard }}.json + name: coverage-unit-${{ matrix.shard }} + path: ./tests/coverage/ if-no-files-found: error - name: Require clean working directory shell: bash @@ -187,9 +270,10 @@ jobs: else echo "No changes detected" fi + merge-unit-and-component-view-tests: runs-on: ubuntu-latest - needs: [unit-tests, cv-test] + needs: [unit-tests, component-view-tests] if: ${{ !cancelled() && github.event_name != 'merge_group' }} steps: - uses: actions/checkout@v3 @@ -208,18 +292,59 @@ jobs: run: yarn setup:github-ci --node - uses: actions/download-artifact@v4 with: + pattern: coverage-* path: tests/coverage/ - - name: Gather partial coverage reports into one directory + - name: Aggregate test counts and gather coverage reports shell: bash run: | - mv ./tests/coverage/coverage-*/* ./tests/coverage + unit_total=0 + for file in ./tests/coverage/coverage-unit-*/count.json; do + [ -f "$file" ] || continue + count=$(jq '.count // 0' "$file") + unit_total=$((unit_total + count)) + done + echo "{\"unit_test_number\": $unit_total}" > unit-test-stats.json + echo "Unit test count: $unit_total" + + cv_total=0 + for file in ./tests/coverage/coverage-cv-*/count.json; do + [ -f "$file" ] || continue + count=$(jq '.count // 0' "$file") + cv_total=$((cv_total + count)) + done + echo "{\"component_view_test_number\": $cv_total}" > cv-test-stats.json + echo "CV test count: $cv_total" + + mkdir -p tests/coverage-cv-merged + for file in ./tests/coverage/coverage-cv-*/coverage-cv-*.json; do + [ -f "$file" ] && cp "$file" ./tests/coverage-cv-merged/ + done + + find ./tests/coverage/coverage-* -name 'coverage-*.json' -exec mv {} ./tests/coverage/ \; - run: yarn test:merge-coverage - run: yarn test:validate-coverage - uses: actions/upload-artifact@v4 with: - name: coverage + name: lcov.info path: ./tests/merged-coverage/lcov.info if-no-files-found: error + - uses: actions/upload-artifact@v4 + with: + name: cv-test-stats + path: ./cv-test-stats.json + if-no-files-found: error + - uses: actions/upload-artifact@v4 + with: + name: unit-test-stats + path: ./unit-test-stats.json + if-no-files-found: error + - name: Generate CV test HTML coverage report + run: yarn nyc report --temp-dir ./tests/coverage-cv-merged --report-dir ./tests/coverage-cv-lcov --reporter html + - uses: actions/upload-artifact@v4 + with: + name: cv-test-coverage-html + path: ./tests/coverage-cv-lcov/ + if-no-files-found: error - name: Require clean working directory shell: bash run: | @@ -229,9 +354,8 @@ jobs: else echo "No changes detected" fi - needs_e2e_build: - uses: ./.github/workflows/needs-e2e-build.yml - cv-test: + + component-view-tests: runs-on: ubuntu-latest strategy: matrix: @@ -251,58 +375,34 @@ jobs: command: yarn install --immutable - name: Clean state and following up dependencies installation run: yarn setup:github-ci --node - - run: yarn test:view:ci --shard=${{ matrix.shard }}/2 + - name: Prepare results directory + run: mkdir -p tests/results + - run: | + yarn test:view:ci \ + --shard=${{ matrix.shard }}/2 \ + --json \ + --outputFile=tests/results/cv-test-results-${{ matrix.shard }}.json env: NODE_OPTIONS: --max-old-space-size=20480 - - name: Rename coverage report for this shard + - name: Rename coverage report and extract test count for this shard shell: bash run: | - mv ./tests/coverage/coverage-final.json ./tests/coverage/coverage-cv-test-${{ matrix.shard }}.json + mv ./tests/coverage/coverage-final.json ./tests/coverage/coverage-cv-${{ matrix.shard }}.json + count=$(jq '(.numPassedTests // 0) + (.numFailedTests // 0)' tests/results/cv-test-results-${{ matrix.shard }}.json) + echo "{\"count\": $count}" > ./tests/coverage/count.json - uses: actions/upload-artifact@v4 with: - name: coverage-cv-test-${{ matrix.shard }} + name: coverage-cv-${{ matrix.shard }} path: ./tests/coverage/ if-no-files-found: error - merge-cv-test-coverage: - runs-on: ubuntu-latest - needs: [cv-test] - if: ${{ !cancelled() && github.event_name != 'merge_group' }} - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version-file: '.nvmrc' - cache: yarn - - name: Install Yarn dependencies with retry - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 - with: - timeout_minutes: 10 - max_attempts: 3 - retry_wait_seconds: 30 - command: yarn install --immutable - - uses: actions/download-artifact@v4 - with: - pattern: coverage-cv-test-* - path: tests/coverage-cv-test/ - - name: Gather CV test shard coverage into one directory - shell: bash - run: | - mkdir -p tests/coverage-cv-test-merged - for d in tests/coverage-cv-test/coverage-cv-test-*; do - if [ -d "$d" ]; then - cp "$d"/*.json tests/coverage-cv-test-merged/ 2>/dev/null || true - fi - done - - name: Generate merged CV test HTML coverage report - run: yarn nyc report --temp-dir ./tests/coverage-cv-test-merged --report-dir ./tests/coverage-cv-test-lcov --reporter html - - uses: actions/upload-artifact@v4 - with: - name: html-coverage-cv-test - path: ./tests/coverage-cv-test-lcov/ - if-no-files-found: error + + needs_e2e_build: + uses: ./.github/workflows/needs-e2e-build.yml + smart-e2e-selection: name: 'Smart E2E Selection' runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.head.repo.fork }} continue-on-error: true permissions: contents: read @@ -341,7 +441,8 @@ jobs: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.android_changed == 'true' && - !(needs.smart-e2e-selection.outputs.ai_confidence >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags == '[]') + !(needs.smart-e2e-selection.outputs.ai_confidence >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags == '[]') && + !github.event.pull_request.head.repo.fork }} permissions: contents: read @@ -356,7 +457,7 @@ jobs: e2e-smoke-tests-android: name: 'Android E2E Smoke Tests' - if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.android_changed == 'true' }} + if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.android_changed == 'true' && !github.event.pull_request.head.repo.fork }} permissions: contents: read id-token: write @@ -377,7 +478,8 @@ jobs: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.ios_changed == 'true' && - !(needs.smart-e2e-selection.outputs.ai_confidence >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags == '[]') + !(needs.smart-e2e-selection.outputs.ai_confidence >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags == '[]') && + !github.event.pull_request.head.repo.fork }} permissions: contents: read @@ -397,7 +499,7 @@ jobs: e2e-smoke-tests-ios: name: 'iOS E2E Smoke Tests' - if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.ios_changed == 'true' }} + if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.ios_changed == 'true' && !github.event.pull_request.head.repo.fork }} permissions: contents: read id-token: write @@ -416,7 +518,7 @@ jobs: e2e-smoke-tests-android-flask: name: 'Android Flask E2E Smoke Tests' - if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.android_changed == 'true' }} + if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.android_changed == 'true' && !github.event.pull_request.head.repo.fork }} permissions: contents: read id-token: write @@ -433,7 +535,7 @@ jobs: e2e-smoke-tests-ios-flask: name: 'iOS Flask E2E Smoke Tests' - if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.ios_changed == 'true' }} + if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.ios_changed == 'true' && !github.event.pull_request.head.repo.fork }} permissions: contents: read id-token: write @@ -448,62 +550,57 @@ jobs: }} secrets: inherit - js-bundle-size-check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - cache: yarn - - name: Install Yarn dependencies with retry - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 - with: - timeout_minutes: 10 - max_attempts: 3 - retry_wait_seconds: 30 - command: yarn install --immutable - - name: Clean state and following up dependencies installation - run: yarn setup:github-ci --no-build-android - - - name: Generate iOS bundle - run: yarn gen-bundle:ios - env: - NODE_OPTIONS: --max_old_space_size=12288 - - - name: Check bundle size - run: ./scripts/js-bundle-stats.sh ios/main.jsbundle 52 - - - name: Upload iOS bundle - uses: actions/upload-artifact@v4 - with: - name: ios-bundle - path: ios/main.jsbundle + # Fixture validation — ensures committed E2E fixtures match the live app state schema + # TODO: Remove continue-on-error once fixture validation is stable + validate-e2e-fixtures: + name: 'Validate E2E Fixtures' + if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.ios_changed == 'true' && !github.event.pull_request.head.repo.fork }} + permissions: + contents: read + id-token: write + needs: [needs_e2e_build, ios-tests-ready] + uses: ./.github/workflows/run-e2e-workflow.yml + with: + test-suite-name: validate-e2e-fixtures + platform: ios + test_suite_tag: 'FixtureValidation' + split_number: 1 + total_splits: 1 + build_type: 'main' + metamask_environment: 'qa' + secrets: inherit - ship-js-bundle-size-check: + report-fixture-validation: + name: 'Report Fixture Validation' runs-on: ubuntu-latest - needs: [js-bundle-size-check] - if: ${{ github.ref == 'refs/heads/main' }} + if: ${{ !cancelled() && needs.validate-e2e-fixtures.result != 'skipped' }} + needs: [validate-e2e-fixtures] + permissions: + pull-requests: write steps: - uses: actions/checkout@v4 - - name: Download iOS bundle + - name: Download fixture validation results + continue-on-error: true uses: actions/download-artifact@v4 with: - name: ios-bundle - path: ios/main.jsbundle + name: test-e2e-validate-e2e-fixtures-junit-results + path: fixture-results/ - - name: Push bundle size to mobile_bundlesize_stats repo - run: ./scripts/push-bundle-size.sh + - name: Report results env: - GITHUB_ACTOR: metamaskbot - GITHUB_TOKEN: ${{ secrets.MOBILE_BUNDLESIZE_TOKEN }} + RESULTS_PATH: fixture-results + VALIDATION_RESULT: ${{ needs.validate-e2e-fixtures.result }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: node .github/scripts/e2e-report-fixture-validation.mjs sonar-cloud: runs-on: ubuntu-latest needs: merge-unit-and-component-view-tests - # Temporarily skipped until SonarCloud is fixed - if: false + if: ${{ !cancelled() && github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork }} steps: - uses: actions/checkout@v3 with: @@ -513,7 +610,7 @@ jobs: node-version-file: '.nvmrc' - uses: actions/download-artifact@v4 with: - name: coverage + name: lcov.info path: coverage/ - name: Upload coverage reports to Codecov if: ${{ always() }} @@ -543,11 +640,11 @@ jobs: echo "$path" git update-index --no-assume-unchanged "$path" done + sonar-cloud-quality-gate-status: runs-on: ubuntu-latest needs: sonar-cloud - # Temporarily skipped until SonarCloud is fixed - if: false + if: ${{ !cancelled() && github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork }} steps: - name: Checkout code uses: actions/checkout@v3 @@ -599,18 +696,7 @@ jobs: exit 1 fi fi - check-workflows: - name: Check workflows - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Download actionlint - id: download-actionlint - run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/62dc61a45fc95efe8c800af7a557ab0b9165d63b/scripts/download-actionlint.bash) 1.7.1 - shell: bash - - name: Check workflow files - run: ${{ steps.download-actionlint.outputs.executable }} -color -config-file .github/actionlint.yaml - shell: bash + all-jobs-pass: name: All jobs pass runs-on: ubuntu-latest @@ -621,9 +707,10 @@ jobs: dedupe, scripts, unit-tests, - cv-test, + component-view-tests, check-workflows, js-bundle-size-check, + sonar-cloud-quality-gate-status, ] outputs: ALL_JOBS_PASSED: ${{ steps.jobs-passed-status.outputs.ALL_JOBS_PASSED }} @@ -633,10 +720,12 @@ jobs: env: NEEDS_CONTEXT: ${{ toJSON(needs) }} EVENT_NAME: ${{ github.event_name }} + IS_FORK: ${{ github.event.pull_request.head.repo.fork }} run: | # Check results of all required jobs dynamically # On merge_group events, "skipped" is acceptable (some jobs intentionally skip) - # On other events (PR, push), all jobs must succeed + # On fork PRs, "skipped" is acceptable (secret-dependent jobs are intentionally skipped) + # On other events (push to main), all jobs must succeed FAILED="false" @@ -645,8 +734,8 @@ jobs: echo "::error::Job '$job_name' failed with result: $result" FAILED="true" elif [[ "$result" == "skipped" ]]; then - if [[ "$EVENT_NAME" == "merge_group" ]]; then - echo "Job '$job_name' was skipped (OK for merge_group events)" + if [[ "$EVENT_NAME" == "merge_group" ]] || [[ "$IS_FORK" == "true" ]]; then + echo "Job '$job_name' was skipped (OK for merge_group events and fork PRs)" else echo "::error::Job '$job_name' was unexpectedly skipped on $EVENT_NAME event" FAILED="true" @@ -662,6 +751,7 @@ jobs: fi echo "ALL_JOBS_PASSED=true" >> "$GITHUB_OUTPUT" + check-all-jobs-pass: name: Check all jobs pass if: ${{ !cancelled() }} @@ -717,7 +807,7 @@ jobs: name: Log merge group failure runs-on: ubuntu-latest # Only run this job if the merge group event fails, skip on forks - if: ${{ github.event_name == 'merge_group' && failure() && !github.event.repository.fork }} + if: ${{ github.event_name == 'merge_group' && failure() }} needs: - check-all-jobs-pass steps: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 005b957f4ed9..47d765d4deb8 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,8 +1,6 @@ name: docker on: - push: - branches: main - pull_request: + workflow_dispatch: jobs: docker: diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml new file mode 100644 index 000000000000..0f4c33157b28 --- /dev/null +++ b/.github/workflows/nightly-build.yml @@ -0,0 +1,42 @@ +name: Nightly Build + +# Triggered by every push to chore/temp-nightly (which nightly-temp-branch-sync.yml +# force-pushes daily at 4 AM UTC to match main). +# +# [skip ci] commits (e.g. version bumps pushed by Bitrise's bump_version_code job via +# update-latest-build-version.yml) are automatically skipped by GitHub Actions, so +# this workflow will NOT double-trigger on those commits. +# +# skip_version_bump=true is passed to build.yml because Bitrise already owns the +# version bump for chore/temp-nightly during the parallel transition period. +# When Bitrise is deprecated, remove skip_version_bump: true and the version bump +# will be handled by build.yml as normal. + +on: + push: + branches: + - chore/temp-nightly + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + build-exp: + name: Nightly exp build (main-exp) + uses: ./.github/workflows/build.yml + with: + build_name: main-exp + platform: both + skip_version_bump: true + secrets: inherit + + build-rc: + name: Nightly RC build (main-rc) + uses: ./.github/workflows/build.yml + with: + build_name: main-rc + platform: both + skip_version_bump: true + secrets: inherit diff --git a/.github/workflows/performance-test-runner.yml b/.github/workflows/performance-test-runner.yml index 763ab5ccdc27..57cd0d327b4f 100644 --- a/.github/workflows/performance-test-runner.yml +++ b/.github/workflows/performance-test-runner.yml @@ -31,6 +31,11 @@ on: required: true type: string description: 'Unified BrowserStack build name for all sessions' + sentry_target: + required: false + type: string + default: test + description: 'Sentry DSN target: test or real' secrets: BROWSERSTACK_USERNAME: required: true @@ -46,6 +51,10 @@ on: required: true E2E_PASSWORD: required: true + MM_SENTRY_DSN_TEST: + required: false + MM_SENTRY_DSN: + required: false jobs: run-tests: @@ -58,7 +67,7 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Restore node_modules cache id: cache uses: actions/cache@v4 @@ -70,13 +79,12 @@ jobs: key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-yarn- - + - name: Set up Node.js uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' cache: 'yarn' - - name: Install dependencies uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 @@ -85,13 +93,22 @@ jobs: max_attempts: 3 retry_wait_seconds: 30 command: yarn --immutable - ## This installs dependencies and creates the node_modules state file - + ## This installs dependencies and creates the node_modules state file + - name: Restore .metamask folder + id: restore-metamask + uses: actions/cache@v4 + with: + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} + + - name: Install Foundry if cache missed + if: steps.restore-metamask.outputs.cache-hit != 'true' + run: yarn install:foundryup - name: Setup project run: yarn setup:github-ci working-directory: '.' ## This will apply the patches for appwright - + - name: BrowserStack Env Setup uses: browserstack/github-actions/setup-env@4478e16186f38e5be07721931642e65a028713c3 with: @@ -123,26 +140,52 @@ jobs: # Use same args for all build types. Do not use --include-hosts for mm-connect: # bs-local.com requests must be forwarded to localhost; --include-hosts can block them. local-args: '--force-local --verbose' - + - name: Wait for BrowserStack Local run: | echo "Waiting for BrowserStack Local to be ready..." # mm-connect needs the tunnel ready so the device can reach bs-local.com:8090; allow extra time sleep ${{ inputs.build_type == 'mm-connect' && 15 || 10 }} echo "BrowserStack Local should be ready now" - + - name: Set Test Environment run: | echo "Setting ${{ inputs.build_type }} test environment for device: ${{ matrix.device.name }} (${{ matrix.device.category }} Class)" echo "OS Version: ${{ matrix.device.os_version }}" echo "Platform: ${{ inputs.platform }}" - + + SENTRY_TARGET="${{ inputs.sentry_target }}" + SENTRY_TEST_DSN="${{ secrets.MM_SENTRY_DSN_TEST }}" + SENTRY_REAL_DSN="${{ secrets.MM_SENTRY_DSN }}" + SELECTED_SENTRY_DSN="" + SENTRY_ENVIRONMENT="github-actions-performance-e2e" + # Validate that we have a BrowserStack URL if [ -z "${{ inputs.browserstack_app_url }}" ]; then echo "❌ Error: No ${{ inputs.platform }} BrowserStack URL available" exit 1 fi - + + case "$SENTRY_TARGET" in + real) + SELECTED_SENTRY_DSN="$SENTRY_REAL_DSN" + SENTRY_ENVIRONMENT="github-actions-performance-e2e-real" + if [ -z "$SELECTED_SENTRY_DSN" ]; then + echo "⚠️ MM_SENTRY_DSN is empty. Sentry uploads will be skipped." + fi + ;; + test) + SELECTED_SENTRY_DSN="$SENTRY_TEST_DSN" + if [ -z "$SELECTED_SENTRY_DSN" ]; then + echo "⚠️ MM_SENTRY_DSN_TEST is empty. Sentry uploads will be skipped." + fi + ;; + *) + echo "❌ Invalid sentry_target '$SENTRY_TARGET'. Expected 'test' or 'real'." + exit 1 + ;; + esac + { echo "BROWSERSTACK_DEVICE=${{ matrix.device.name }}" echo "BROWSERSTACK_OS_VERSION=${{ matrix.device.os_version }}" @@ -157,9 +200,12 @@ jobs: echo "TEST_SRP_2=${{ secrets.TEST_SRP_2 }}" echo "TEST_SRP_3=${{ secrets.TEST_SRP_3 }}" echo "E2E_PASSWORD=${{ secrets.E2E_PASSWORD }}" + echo "E2E_PERFORMANCE_SENTRY_DSN=$SELECTED_SENTRY_DSN" + echo "E2E_PERFORMANCE_SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT" + echo "E2E_PERFORMANCE_SENTRY_RELEASE=${{ github.sha }}" echo "DISABLE_VIDEO_DOWNLOAD=true" } >> "$GITHUB_ENV" - + - name: Run Tests env: BROWSERSTACK_LOCAL: 'true' @@ -173,7 +219,7 @@ jobs: echo "BrowserStack App URL: ${{ inputs.browserstack_app_url }}" echo "QA App Version: ${{ inputs.app_version }}" echo "BrowserStack Build Name: $BROWSERSTACK_BUILD_NAME" - + # Run the appropriate test command based on build_type flag if [ "${{ inputs.build_type }}" = "onboarding" ]; then yarn run-appwright:${{ inputs.platform }}-onboarding-bs @@ -182,9 +228,9 @@ jobs: else yarn run-appwright:${{ inputs.platform }}-bs fi - + echo "✅ ${{ inputs.build_type }} tests completed for ${{ inputs.platform }} on ${{ matrix.device.name }}" - + - name: Upload Test Results uses: actions/upload-artifact@v4 if: always() diff --git a/.github/workflows/push-eas-update.yml b/.github/workflows/push-eas-update.yml index ab37ef9b9aa7..efac957c8c45 100644 --- a/.github/workflows/push-eas-update.yml +++ b/.github/workflows/push-eas-update.yml @@ -51,6 +51,23 @@ env: OTA_PUSH_PLATFORM: ${{ inputs.platform }} jobs: + ota-summary: + name: OTA Update Summary + runs-on: ubuntu-latest + steps: + - name: Display OTA Summary + run: | + { + echo "### OTA Update Summary" + echo "" + echo "| Field | Value |" + echo "| --- | --- |" + echo "| **Update version** | ${UPDATE_MESSAGE} |" + echo "| **Target version** | ${BASE_BRANCH_REF} |" + echo "| **Environment** | ${TARGET_CHANNEL} |" + echo "| **Target commit** | ${TARGET_COMMIT_HASH} |" + } >> "$GITHUB_STEP_SUMMARY" + setup-dependencies: name: Setup Dependencies (PR) needs: @@ -105,7 +122,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version-file: '.nvmrc' - name: Validate artifact compatibility (PR commit) uses: ./.github/actions/validate-artifact-compatibility @@ -310,8 +327,11 @@ jobs: QUICKNODE_BASE_URL: ${{ secrets.QUICKNODE_BASE_URL }} QUICKNODE_LINEA_MAINNET_URL: ${{ secrets.QUICKNODE_LINEA_MAINNET_URL }} QUICKNODE_MONAD_URL: ${{ secrets.QUICKNODE_MONAD_URL }} + QUICKNODE_HYPEREVM_URL: ${{ secrets.QUICKNODE_HYPEREVM_URL }} QUICKNODE_OPTIMISM_URL: ${{ secrets.QUICKNODE_OPTIMISM_URL }} QUICKNODE_POLYGON_URL: ${{ secrets.QUICKNODE_POLYGON_URL }} + QUICKNODE_BSC_URL: ${{ secrets.QUICKNODE_BSC_URL }} + QUICKNODE_SEI_URL: ${{ secrets.QUICKNODE_SEI_URL }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -322,7 +342,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version-file: '.nvmrc' - name: Validate artifact compatibility uses: ./.github/actions/validate-artifact-compatibility diff --git a/.github/workflows/qa-stats.yml b/.github/workflows/qa-stats.yml new file mode 100644 index 000000000000..7eeb9b21dc1a --- /dev/null +++ b/.github/workflows/qa-stats.yml @@ -0,0 +1,36 @@ +name: QA Stats + +on: + workflow_run: + workflows: + - ci + types: + - completed + branches: + - main + +jobs: + collect-qa-stats: + name: Collect QA Stats + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_repository.full_name == github.repository }} + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Collect QA stats + run: node .github/scripts/collect-qa-stats.mjs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }} + + - name: Upload QA stats artifact + uses: actions/upload-artifact@v6 + with: + name: qa-stats + path: ./qa-stats.json + if-no-files-found: error diff --git a/.github/workflows/run-e2e-smoke-tests-android-flask.yml b/.github/workflows/run-e2e-smoke-tests-android-flask.yml index 53b85f72075a..27302a850bb1 100644 --- a/.github/workflows/run-e2e-smoke-tests-android-flask.yml +++ b/.github/workflows/run-e2e-smoke-tests-android-flask.yml @@ -44,8 +44,23 @@ jobs: retry_wait_seconds: 30 command: yarn install --immutable + - name: Restore .metamask folder + id: restore-metamask + uses: actions/cache@v4 + with: + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} + + - name: Install Foundry if cache missed + if: steps.restore-metamask.outputs.cache-hit != 'true' + run: yarn install:foundryup - name: Setup project - run: yarn setup:github-ci --no-build-ios + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn setup:github-ci --no-build-ios - name: Configure Keystore uses: MetaMask/github-tools/.github/actions/configure-keystore@v1 diff --git a/.github/workflows/run-e2e-smoke-tests-ios-flask.yml b/.github/workflows/run-e2e-smoke-tests-ios-flask.yml index 96fbb2cd1ed2..c2a32ebdf098 100644 --- a/.github/workflows/run-e2e-smoke-tests-ios-flask.yml +++ b/.github/workflows/run-e2e-smoke-tests-ios-flask.yml @@ -32,7 +32,7 @@ jobs: - name: Setup iOS Environment timeout-minutes: 15 - uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1 + uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1.7.0 with: platform: ios setup-simulator: false @@ -45,8 +45,23 @@ jobs: retry_wait_seconds: 30 command: yarn install --immutable + - name: Restore .metamask folder + id: restore-metamask + uses: actions/cache@v4 + with: + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} + + - name: Install Foundry if cache missed + if: steps.restore-metamask.outputs.cache-hit != 'true' + run: yarn install:foundryup - name: Setup project - run: yarn setup:github-ci --no-build-android + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn setup:github-ci --no-build-android - name: Download Main iOS App artifacts uses: actions/download-artifact@v4 diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml index c316956b0374..dfb5bf77c399 100644 --- a/.github/workflows/run-e2e-workflow.yml +++ b/.github/workflows/run-e2e-workflow.yml @@ -96,9 +96,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - ref: ${{ github.event_name == 'merge_group' && github.event.merge_group.head_sha || github.event.pull_request.head.sha || github.sha }} - clean: true - name: Install Android System Images if: ${{ inputs.platform == 'android' }} @@ -115,7 +112,7 @@ jobs: - name: Set up E2E environment timeout-minutes: 15 - uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1 + uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1.7.0 with: platform: ${{ inputs.platform }} setup-simulator: ${{ inputs.platform == 'ios' }} diff --git a/.github/workflows/run-performance-e2e-experimental.yml b/.github/workflows/run-performance-e2e-experimental.yml index a5793626bb02..2b81ff57aac7 100644 --- a/.github/workflows/run-performance-e2e-experimental.yml +++ b/.github/workflows/run-performance-e2e-experimental.yml @@ -3,8 +3,17 @@ name: Performance E2E Tests for Experimental Builds on: schedule: - - cron: '0 */3 * * 1-6' # Every 3 hours, Mon–Fri (aligned with main performance workflow) + - cron: '0 */3 * * 1-6' # Every 3 hours, Mon–Fri (aligned with main performance workflow) workflow_dispatch: + inputs: + sentry_target: + description: 'Sentry target for performance events (test or real)' + required: false + type: choice + options: + - test + - real + default: test push: branches: - main @@ -53,4 +62,5 @@ jobs: with: branch_name: main build_variant: exp + sentry_target: ${{ inputs.sentry_target || 'test' }} secrets: inherit diff --git a/.github/workflows/run-performance-e2e-release.yml b/.github/workflows/run-performance-e2e-release.yml index 2e22c533306c..27c0b1c68f2d 100644 --- a/.github/workflows/run-performance-e2e-release.yml +++ b/.github/workflows/run-performance-e2e-release.yml @@ -1,8 +1,17 @@ name: Performance E2E Tests for Release Builds on: schedule: - - cron: '*/30 * * * *' # Every 30 minutes to check for metamaskbot commits + - cron: '*/30 * * * *' # Every 30 minutes to check for metamaskbot commits workflow_dispatch: + inputs: + sentry_target: + description: 'Sentry target for performance events (test or real)' + required: false + type: choice + options: + - test + - real + default: test push: branches: - 'release/*' @@ -24,7 +33,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - + - name: Check release trigger conditions id: check run: | @@ -79,4 +88,6 @@ jobs: uses: ./.github/workflows/run-performance-e2e.yml needs: [check-release-trigger] if: needs.check-release-trigger.outputs.should-run == 'true' + with: + sentry_target: ${{ inputs.sentry_target || 'test' }} secrets: inherit diff --git a/.github/workflows/run-performance-e2e.yml b/.github/workflows/run-performance-e2e.yml index 30f8421080d8..c1dc40509bd6 100644 --- a/.github/workflows/run-performance-e2e.yml +++ b/.github/workflows/run-performance-e2e.yml @@ -15,6 +15,14 @@ on: description: 'Optional description for this test run' required: false type: string + sentry_target: + description: 'Sentry target for performance events (test or real)' + required: false + type: choice + options: + - test + - real + default: test browserstack_app_url_android_onboarding: description: 'BrowserStack Android Onboarding App URL (bs://...)' @@ -38,6 +46,11 @@ on: description: 'Optional description for this test run' required: false type: string + sentry_target: + description: 'Sentry target for performance events (test or real)' + required: false + type: string + default: test browserstack_app_url_android_onboarding: description: 'BrowserStack Android Onboarding App URL (bs://...)' @@ -210,11 +223,18 @@ jobs: run-android-onboarding-tests: name: Run Android Onboarding Tests uses: ./.github/workflows/performance-test-runner.yml - needs: [read-device-matrix, trigger-android-dual-versions, set-build-names, determine-branch-name] + needs: + [ + read-device-matrix, + trigger-android-dual-versions, + set-build-names, + determine-branch-name, + ] if: always() && !failure() && !cancelled() && (needs.trigger-android-dual-versions.result == 'skipped' || needs.trigger-android-dual-versions.result == 'success') && (inputs.browserstack_app_url_android_onboarding != '' || needs.trigger-android-dual-versions.outputs.without-srp-browserstack-url != '') with: platform: android build_type: onboarding + sentry_target: ${{ inputs.sentry_target || 'test' }} device_matrix: ${{ needs.read-device-matrix.outputs.android_matrix }} browserstack_app_url: ${{ needs.trigger-android-dual-versions.outputs.without-srp-browserstack-url || inputs.browserstack_app_url_android_onboarding }} app_version: ${{ needs.trigger-android-dual-versions.outputs.without-srp-version || 'Manual-Input' }} @@ -225,11 +245,18 @@ jobs: run-ios-onboarding-tests: name: Run iOS Onboarding Tests uses: ./.github/workflows/performance-test-runner.yml - needs: [read-device-matrix, trigger-ios-dual-versions, set-build-names, determine-branch-name] + needs: + [ + read-device-matrix, + trigger-ios-dual-versions, + set-build-names, + determine-branch-name, + ] if: always() && !failure() && !cancelled() && (needs.trigger-ios-dual-versions.result == 'skipped' || needs.trigger-ios-dual-versions.result == 'success') && (inputs.browserstack_app_url_ios_onboarding != '' || needs.trigger-ios-dual-versions.outputs.without-srp-browserstack-url != '') with: platform: ios build_type: onboarding + sentry_target: ${{ inputs.sentry_target || 'test' }} device_matrix: ${{ needs.read-device-matrix.outputs.ios_matrix }} browserstack_app_url: ${{ needs.trigger-ios-dual-versions.outputs.without-srp-browserstack-url || inputs.browserstack_app_url_ios_onboarding }} app_version: ${{ needs.trigger-ios-dual-versions.outputs.without-srp-version || 'Manual-Input' }} @@ -267,6 +294,7 @@ jobs: with: platform: android build_type: imported-wallet + sentry_target: ${{ inputs.sentry_target || 'test' }} device_matrix: ${{ needs.read-device-matrix.outputs.android_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' }} @@ -289,6 +317,7 @@ jobs: with: platform: ios build_type: imported-wallet + sentry_target: ${{ inputs.sentry_target || 'test' }} device_matrix: ${{ needs.read-device-matrix.outputs.ios_matrix }} browserstack_app_url: ${{ needs.trigger-ios-dual-versions.outputs.with-srp-browserstack-url || inputs.browserstack_app_url_ios_imported_wallet }} app_version: ${{ needs.trigger-ios-dual-versions.outputs.with-srp-version || 'Manual-Input' }} @@ -311,6 +340,7 @@ jobs: with: platform: android build_type: mm-connect + sentry_target: ${{ inputs.sentry_target || 'test' }} device_matrix: ${{ needs.read-device-matrix.outputs.android_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' }} @@ -406,4 +436,4 @@ jobs: payload: | { "text": "${{ steps.summary.outputs.summary }}" - } \ No newline at end of file + } diff --git a/.github/workflows/setup-node-modules.yml b/.github/workflows/setup-node-modules.yml index 32dc7f704673..0a655a6b276f 100644 --- a/.github/workflows/setup-node-modules.yml +++ b/.github/workflows/setup-node-modules.yml @@ -1,6 +1,12 @@ # Reusable workflow for setting up node_modules # This workflow installs dependencies and runs the project setup, then uploads # the prepared node_modules as an artifact for consumption by other workflows. +# +# Use cases: +# - EAS update: platform='', use-tarball=false, one ubuntu-latest runner. +# - Build: platform=ios|android, use-tarball=true, build_name set; runs on +# platform-specific runner (macOS for iOS, Ubuntu for Android) and uploads +# a tarball to preserve symlinks. name: Setup Node Modules @@ -17,13 +23,33 @@ on: required: false type: number default: 1 + checkout-submodules: + description: 'Checkout with submodules recursive (required for build)' + required: false + type: boolean + default: false + platform: + description: "Platform for native setup: '' (generic/ubuntu), 'ios', or 'android'. When set, runs on platform-specific runner and uses platform-specific setup." + required: false + type: string + default: '' + build_name: + description: 'Build config name (e.g. main-prod). When set, applies build config before setup so METAMASK_BUILD_TYPE is correct for InpageBridgeWeb3.js.' + required: false + type: string + default: '' + use-tarball: + description: 'Upload a tar.gz artifact (preserves symlinks). Use for build.yml so the build job can extract on the same OS.' + required: false + type: boolean + default: false upload-artifact: description: 'Whether to upload node_modules as an artifact' required: false type: boolean default: true artifact-name: - description: 'Name of the artifact to upload' + description: 'Name of the artifact to upload. When platform is set, used as-is; otherwise suffix with node version and OS.' required: false type: string default: 'node-modules' @@ -34,16 +60,21 @@ on: default: 1 outputs: artifact-name: - description: 'The actual artifact name used (includes node version and OS)' + description: 'The actual artifact name used' value: ${{ jobs.setup.outputs.artifact-name }} permissions: contents: read + id-token: write jobs: setup: - name: Setup Node Modules - runs-on: ubuntu-latest + name: Setup Node Modules ${{ inputs.platform && format('({0})', inputs.platform) || '' }} + # Platform-specific runner to match consumer (build needs same OS for native deps/symlinks) + runs-on: ${{ inputs.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:sequoia-xl' || (inputs.platform == 'android' && 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' || 'ubuntu-latest') }} + permissions: + contents: read + id-token: write outputs: artifact-name: ${{ steps.set-artifact-name.outputs.artifact-name }} steps: @@ -52,27 +83,76 @@ jobs: with: ref: ${{ inputs.ref }} fetch-depth: ${{ inputs.fetch-depth }} + submodules: ${{ inputs.checkout-submodules && 'recursive' || false }} + # iOS: Only Node + Ruby needed (setup runs gem/bundle install, no pods/Xcode). Full iOS env is in build job. - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version-file: '.nvmrc' + cache: ${{ inputs.platform == 'android' && 'yarn' || (inputs.platform == 'ios' && 'yarn') || '' }} - - name: Set artifact name with node version and OS + - name: Setup Ruby (iOS) + if: inputs.platform == 'ios' + uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb #v1 + with: + ruby-version: '3.2.9' + working-directory: ios + bundler-cache: true + + - name: Set artifact name id: set-artifact-name run: | - NODE_VERSION=$(node --version | sed 's/v//') - OS_NAME=$(echo "$RUNNER_OS" | tr '[:upper:]' '[:lower:]') - ARTIFACT_NAME="${{ inputs.artifact-name }}-node${NODE_VERSION}-${OS_NAME}" - echo "artifact-name=$ARTIFACT_NAME" >> "$GITHUB_OUTPUT" - echo "📦 Artifact name: $ARTIFACT_NAME" + if [ -n "${{ inputs.platform }}" ]; then + echo "artifact-name=${{ inputs.artifact-name }}" >> "$GITHUB_OUTPUT" + echo "📦 Artifact name: ${{ inputs.artifact-name }}" + else + NODE_VERSION=$(node --version | sed 's/v//') + OS_NAME=$(echo "$RUNNER_OS" | tr '[:upper:]' '[:lower:]') + ARTIFACT_NAME="${{ inputs.artifact-name }}-node${NODE_VERSION}-${OS_NAME}" + echo "artifact-name=$ARTIFACT_NAME" >> "$GITHUB_OUTPUT" + echo "📦 Artifact name: $ARTIFACT_NAME" + fi - name: Install dependencies run: | echo "📦 Installing dependencies..." yarn install --immutable + - name: Apply build config (for setup) + if: inputs.build_name != '' + run: | + node scripts/apply-build-config.js ${{ inputs.build_name }} --export-github-env >> "$GITHUB_ENV" + + - name: Restore .metamask folder + id: restore-metamask + if: inputs.platform != '' + uses: actions/cache@v4 + with: + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} + + - name: Install Foundry if cache missed + if: inputs.platform != '' && steps.restore-metamask.outputs.cache-hit != 'true' + run: yarn install:foundryup + - name: Setup project + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + if: inputs.platform != '' + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: | + echo "🔧 Running setup for ${{ inputs.platform }}..." + if [ "${{ inputs.platform }}" = "ios" ]; then + yarn setup:github-ci --build-ios --no-build-android --no-install-pods + else + yarn setup:github-ci --no-build-ios + fi + + - name: Setup project (generic) + if: inputs.platform == '' run: | echo "🔧 Running setup for GitHub CI..." yarn setup:github-ci @@ -80,7 +160,6 @@ jobs: - name: Verify setup completed run: | echo "✅ Verifying setup artifacts..." - # Check that critical setup artifacts exist if [ ! -d "node_modules" ]; then echo "❌ node_modules directory not found" exit 1 @@ -100,6 +179,7 @@ jobs: echo "✅ Setup verification passed" - name: Debug - List .yarn contents + if: inputs.platform == '' run: | echo "📂 Contents of .yarn directory:" ls -la .yarn/ || echo "No .yarn directory" @@ -107,8 +187,29 @@ jobs: echo "📄 install-state.gz exists: $(test -f .yarn/install-state.gz && echo 'yes' || echo 'no')" echo "📁 cache dir exists: $(test -d .yarn/cache && echo 'yes' || echo 'no')" - - name: Upload node_modules artifact - if: inputs.upload-artifact + - name: Create tarball (preserves symlinks) + if: inputs.upload-artifact && inputs.use-tarball + run: | + echo "📦 Creating tarball to preserve symlinks..." + tar -czf node-modules-${{ inputs.platform }}.tar.gz \ + node_modules \ + app/util/termsOfUse/termsOfUseContent.ts \ + app/core/InpageBridgeWeb3.js \ + scripts/inpage-bridge/dist + echo "✅ Tarball created: $(du -h node-modules-${{ inputs.platform }}.tar.gz)" + + - name: Upload node_modules tarball + if: inputs.upload-artifact && inputs.use-tarball + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.set-artifact-name.outputs.artifact-name }} + path: node-modules-${{ inputs.platform }}.tar.gz + retention-days: ${{ inputs.artifact-retention-days }} + compression-level: 0 + if-no-files-found: error + + - name: Upload node_modules artifact (zip) + if: inputs.upload-artifact && !inputs.use-tarball uses: actions/upload-artifact@v4.5.0 with: name: ${{ steps.set-artifact-name.outputs.artifact-name }} diff --git a/.github/workflows/update-e2e-fixtures.yml b/.github/workflows/update-e2e-fixtures.yml new file mode 100644 index 000000000000..85a347e46976 --- /dev/null +++ b/.github/workflows/update-e2e-fixtures.yml @@ -0,0 +1,365 @@ +# Updates committed E2E fixture files by running the app and exporting live state. +# Only merges structural changes (new/missing keys, type mismatches). +# Value mismatches are reported but not auto-merged since the fixture represents +# an existing user, not fresh post-onboarding state. +# +# Trigger via bot command on a PR: @metamaskbot update-mobile-fixture +# Or manually via Actions tab with workflow_dispatch. +# +# NOTE: issue_comment always runs from the default branch (main). +# To use the workflow version from the PR branch, the issue_comment handler +# dispatches a workflow_dispatch event on the PR branch, then exits. + +name: Update E2E Fixtures + +on: + issue_comment: + types: + - created + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to update fixtures for' + required: true + type: string + +jobs: + # ── issue_comment dispatcher ────────────────────────────────────────── + # Validates the comment, then re-dispatches this workflow on the PR branch. + dispatch: + name: Dispatch to PR branch + if: >- + ${{ + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + startsWith(github.event.comment.body, '@metamaskbot update-mobile-fixture') + }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - name: Get PR details + id: pr + run: | + PR_NUMBER="${{ github.event.issue.number }}" + IS_FORK=$(gh pr view "${PR_NUMBER}" --json isCrossRepository --jq '.isCrossRepository') + BRANCH=$(gh pr view "${PR_NUMBER}" --json headRefName --jq '.headRefName') + { + echo "PR_NUMBER=${PR_NUMBER}" + echo "IS_FORK=${IS_FORK}" + echo "BRANCH=${BRANCH}" + } >> "$GITHUB_OUTPUT" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Dispatch workflow on PR branch + if: steps.pr.outputs.IS_FORK == 'false' + run: | + gh workflow run "Update E2E Fixtures" \ + --ref "${{ steps.pr.outputs.BRANCH }}" \ + -f pr_number="${{ steps.pr.outputs.PR_NUMBER }}" + + gh pr comment "${PR_NUMBER}" --body "🔄 **Fixture update started.** Running workflow from branch \`${{ steps.pr.outputs.BRANCH }}\`. [View workflow runs](${ACTIONS_URL})" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ steps.pr.outputs.PR_NUMBER }} + ACTIONS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/workflows/update-e2e-fixtures.yml' + + # ── workflow_dispatch: actual fixture update ────────────────────────── + # Everything below only runs on workflow_dispatch (from PR branch). + + is-fork-pull-request: + name: Validate PR + if: ${{ github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + IS_FORK: ${{ steps.is-fork.outputs.IS_FORK }} + PR_NUMBER: ${{ inputs.pr_number }} + steps: + - uses: actions/checkout@v4 + + - name: Determine whether this PR is from a fork + id: is-fork + run: echo "IS_FORK=$(gh pr view --json isCrossRepository --jq '.isCrossRepository' "${PR_NUMBER}" )" >> "$GITHUB_OUTPUT" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ inputs.pr_number }} + + prepare: + name: Prepare build artifacts + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: is-fork-pull-request + if: ${{ needs.is-fork-pull-request.outputs.IS_FORK == 'false' }} + outputs: + COMMIT_SHA: ${{ steps.commit-sha.outputs.COMMIT_SHA }} + BRANCH: ${{ steps.branch.outputs.BRANCH }} + PR_NUMBER: ${{ needs.is-fork-pull-request.outputs.PR_NUMBER }} + steps: + - uses: actions/checkout@v4 + + - name: Checkout pull request + run: gh pr checkout "${PR_NUMBER}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ needs.is-fork-pull-request.outputs.PR_NUMBER }} + + - name: Get commit SHA + id: commit-sha + run: echo "COMMIT_SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Get branch name + id: branch + run: echo "BRANCH=$(gh pr view "${PR_NUMBER}" --json headRefName --jq '.headRefName')" >> "$GITHUB_OUTPUT" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ needs.is-fork-pull-request.outputs.PR_NUMBER }} + + - name: Download iOS build artifact from CI + run: | + COMMIT_SHA_FULL=$(git rev-parse HEAD) + BRANCH="${{ steps.branch.outputs.BRANCH }}" + echo "::group::Checking CI workflow status" + + # Get the most recent ci workflow run for this branch + RUN_INFO=$(gh run list --workflow "ci" --branch "${BRANCH}" --json databaseId,status,conclusion,headSha --limit 1 --jq 'first') + + if [ -z "$RUN_INFO" ] || [ "$RUN_INFO" = "null" ]; then + echo "::error title=No CI workflow found::No 'ci' workflow run found for branch ${BRANCH}. Push a commit to trigger CI first." + exit 1 + fi + + RUN_ID=$(echo "$RUN_INFO" | jq -r '.databaseId') + RUN_STATUS=$(echo "$RUN_INFO" | jq -r '.status') + RUN_CONCLUSION=$(echo "$RUN_INFO" | jq -r '.conclusion') + RUN_HEAD_SHA=$(echo "$RUN_INFO" | jq -r '.headSha') + RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${RUN_ID}" + + echo "CI workflow run: ${RUN_URL}" + echo "Status: ${RUN_STATUS}, Conclusion: ${RUN_CONCLUSION}" + echo "CI commit: ${RUN_HEAD_SHA}" + echo "PR HEAD commit: ${COMMIT_SHA_FULL}" + echo "::endgroup::" + + if [ "${RUN_HEAD_SHA}" != "${COMMIT_SHA_FULL}" ]; then + echo "::error title=CI not up to date::The latest CI workflow (commit ${RUN_HEAD_SHA:0:7}) does not match the PR HEAD (commit ${COMMIT_SHA_FULL:0:7}). Wait for CI to run on the latest commit, then try again." + exit 1 + fi + + if [ "${RUN_STATUS}" != "completed" ]; then + echo "::error title=CI still running::The CI workflow is still in progress (status: ${RUN_STATUS}). Wait for the 'Build iOS Apps' job to complete, then run @metamaskbot update-mobile-fixture again. View CI: ${RUN_URL}" + exit 1 + fi + + if [ "${RUN_CONCLUSION}" != "success" ]; then + echo "::warning title=CI did not succeed::The CI workflow concluded with '${RUN_CONCLUSION}'. The iOS build artifact may not be available. View CI: ${RUN_URL}" + fi + + echo "::group::Downloading iOS app artifact" + echo "Attempting to download iOS app artifact from CI run ${RUN_ID}..." + + if ! gh run download "${RUN_ID}" -n main-qa-MetaMask.app -D ios-app-artifact; then + echo "::endgroup::" + echo "::error title=iOS artifact not found::Failed to download 'main-qa-MetaMask.app' artifact. Possible causes:%0A- The 'Build iOS Apps' job has not completed%0A- The iOS build was skipped (no iOS-impacting changes)%0A- The iOS build failed%0A%0ACheck the CI workflow: ${RUN_URL}" + exit 1 + fi + + echo "::endgroup::" + echo "✅ Successfully downloaded iOS app artifact." + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Cache iOS app artifact + uses: actions/cache/save@v4 + with: + path: ios-app-artifact + key: ios-app-${{ steps.commit-sha.outputs.COMMIT_SHA }} + + update-fixtures: + name: Export & update fixtures + needs: [prepare] + runs-on: ghcr.io/cirruslabs/macos-runner:sequoia + timeout-minutes: 30 + + env: + METAMASK_ENVIRONMENT: qa + METAMASK_BUILD_TYPE: main + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLATFORM: ios + GITHUB_CI: 'true' + MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} + RAMP_INTERNAL_BUILD: 'true' + MM_SECURITY_ALERTS_API_ENABLED: 'true' + MM_NOTIFICATIONS_UI_ENABLED: 'true' + FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN: ${{ secrets.FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN }} + FEATURES_ANNOUNCEMENTS_SPACE_ID: ${{ secrets.FEATURES_ANNOUNCEMENTS_SPACE_ID }} + SEEDLESS_ONBOARDING_ENABLED: 'true' + 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 }} + GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} + GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} + YARN_ENABLE_GLOBAL_CACHE: 'true' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.BRANCH }} + + - name: Set up E2E environment + timeout-minutes: 15 + uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1 + with: + platform: ios + setup-simulator: true + configure-keystores: false + + - name: Build Detox framework cache + run: | + yarn detox clean-framework-cache + yarn detox build-framework-cache + + - name: Restore iOS app artifact + uses: actions/cache/restore@v4 + with: + path: ios-app-artifact + key: ios-app-${{ needs.prepare.outputs.COMMIT_SHA }} + fail-on-cache-miss: true + + - name: Place iOS app artifact + run: | + mkdir -p artifacts/main-qa-MetaMask.app + cp -R ios-app-artifact/* artifacts/main-qa-MetaMask.app/ + env: + PREBUILT_IOS_APP_PATH: artifacts/main-qa-MetaMask.app + + - name: Run fixture validation spec + timeout-minutes: 30 + 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 + env: + PREBUILT_IOS_APP_PATH: artifacts/main-qa-MetaMask.app + + - name: Cache updated fixture + uses: actions/cache/save@v4 + with: + path: tests/framework/fixtures/json/default-fixture.json + key: fixture-${{ needs.prepare.outputs.COMMIT_SHA }} + + commit-updated-fixtures: + name: Commit the updated fixtures + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: + - prepare + - is-fork-pull-request + - update-fixtures + if: ${{ needs.is-fork-pull-request.outputs.IS_FORK == 'false' }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.ACTIONS_WRITE_TOKEN }} + + - name: Checkout pull request + run: gh pr checkout "${PR_NUMBER}" + env: + GITHUB_TOKEN: ${{ secrets.ACTIONS_WRITE_TOKEN }} + PR_NUMBER: ${{ needs.prepare.outputs.PR_NUMBER }} + + - name: Restore updated fixture + uses: actions/cache/restore@v4 + with: + path: tests/framework/fixtures/json/default-fixture.json + key: fixture-${{ needs.prepare.outputs.COMMIT_SHA }} + fail-on-cache-miss: true + + - name: Check for fixture changes + id: fixture-changes + run: | + if git diff --exit-code tests/framework/fixtures/json/default-fixture.json; then + echo "HAS_CHANGES=false" >> "$GITHUB_OUTPUT" + else + echo "HAS_CHANGES=true" >> "$GITHUB_OUTPUT" + fi + + # Note: Commits pushed with the default GITHUB_TOKEN do not trigger + # downstream CI workflows (GitHub anti-loop protection). This is + # intentional — a follow-up push or manual CI re-run will validate + # the updated fixture. Use a PAT or GitHub App token if auto-triggered + # CI is needed in the future. + - name: Commit the updated fixture + if: steps.fixture-changes.outputs.HAS_CHANGES == 'true' + run: | + git config --global user.name 'MetaMask Bot' + git config --global user.email 'metamaskbot@users.noreply.github.com' + git commit -am "chore: update E2E default fixture" + git push + + - name: Post comment + run: | + if [[ $HAS_CHANGES == 'true' ]]; then + gh pr comment "${PR_NUMBER}" --body 'E2E fixtures updated.' + else + gh pr comment "${PR_NUMBER}" --body 'No E2E fixture changes detected.' + fi + env: + HAS_CHANGES: ${{ steps.fixture-changes.outputs.HAS_CHANGES }} + GITHUB_TOKEN: ${{ secrets.ACTIONS_WRITE_TOKEN }} + PR_NUMBER: ${{ needs.prepare.outputs.PR_NUMBER }} + + check-status: + name: Check whether the fixture update succeeded + runs-on: ubuntu-latest + timeout-minutes: 5 + if: ${{ !cancelled() && needs.is-fork-pull-request.outputs.IS_FORK == 'false' }} + needs: + - is-fork-pull-request + - commit-updated-fixtures + outputs: + PASSED: ${{ steps.set-output.outputs.PASSED }} + steps: + - name: Set PASSED output + id: set-output + run: | + if [[ "${{ needs.commit-updated-fixtures.result }}" == "success" ]]; then + echo "PASSED=true" >> "$GITHUB_OUTPUT" + else + echo "PASSED=false" >> "$GITHUB_OUTPUT" + fi + + failure-comment: + name: Comment about the fixture update failure + if: ${{ !cancelled() && needs.is-fork-pull-request.outputs.IS_FORK == 'false' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: + - is-fork-pull-request + - check-status + steps: + - uses: actions/checkout@v4 + + - name: Post comment if the update failed + run: | + passed="${{ needs.check-status.outputs.PASSED }}" + if [[ $passed != "true" ]]; then + body="❌ **E2E fixture update failed.**\n\n**Common causes:**\n- CI workflow is still running — wait for 'Build iOS Apps' to complete\n- CI workflow was skipped — ensure your PR has iOS-impacting changes or use \`skip-smart-e2e-selection\` label\n- iOS build failed — check the CI workflow for errors\n\n[View logs and retry](${ACTION_RUN_URL})" + gh pr comment "${PR_NUMBER}" --body "$body" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ needs.is-fork-pull-request.outputs.PR_NUMBER }} + ACTION_RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' diff --git a/.gitignore b/.gitignore index c86a26c3a310..1c5d326b37f9 100644 --- a/.gitignore +++ b/.gitignore @@ -115,7 +115,7 @@ tests/artifacts # E2E Reports generated by tests tests/reports # Legacy e2e reports -e2e/reports +e2e/reports # Unit Coverage Reports generated by tests tests/coverage @@ -136,6 +136,7 @@ appwright/test-reports/ test-results/ test-reports/ appwright/reporters/reports/* +tests/reporters/reports/ *.apk # anvil binaries @@ -180,4 +181,4 @@ temp/ tests/coverage-systems/ -runway-artifacts/ \ No newline at end of file +runway-artifacts/ diff --git a/.js.env.example b/.js.env.example index a15cddcf5c8b..4b384bcdcd6e 100644 --- a/.js.env.example +++ b/.js.env.example @@ -172,6 +172,12 @@ export ENABLE_WHY_DID_YOU_RENDER="false" # Rewards API URL export REWARDS_API_URL="" +## Advanced Charts (TradingView charting library CDN) +# Production: CloudFront distribution URL (trailing slash required) +# Development: local http-server, e.g. http://localhost:8000/ +# Leave empty to use the default S3 origin fallback +export MM_CHARTING_LIBRARY_URL="" + ## Perps export MM_PERPS_ENABLED="true" @@ -182,6 +188,12 @@ export MM_PERPS_GTM_MODAL_ENABLED="true" export MM_PERPS_ORDER_BOOK_ENABLED="true" export MM_PERPS_FEEDBACK_ENABLED="true" export MM_PERPS_MYX_PROVIDER_ENABLED="true" +export MM_PERPS_MYX_APP_ID_TESTNET="" +export MM_PERPS_MYX_API_SECRET_TESTNET="" +export MM_PERPS_MYX_BROKER_ADDRESS_TESTNET="" +export MM_PERPS_MYX_APP_ID_MAINNET="" +export MM_PERPS_MYX_API_SECRET_MAINNET="" +export MM_PERPS_MYX_BROKER_ADDRESS_MAINNET="" # HIP-3 Feature Flags (remote override with local fallback) export MM_PERPS_HIP3_ENABLED="true" export MM_PERPS_HIP3_ALLOWLIST_MARKETS="" # Allowlist: Empty = enable all markets. Examples: "xyz:XYZ100,xyz:TSLA" or "xyz:*,abc:TSLA" diff --git a/.ruby-version b/.ruby-version index 9cec7165ab0a..e650c01d92f3 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.1.6 +3.2.9 diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js index ef983aba2799..a9d2975e3d7d 100644 --- a/.storybook/storybook.requires.js +++ b/.storybook/storybook.requires.js @@ -47,13 +47,10 @@ const getStories = () => { "./app/component-library/components-temp/Buttons/ButtonSemantic/ButtonSemantic.stories.tsx": require("../app/component-library/components-temp/Buttons/ButtonSemantic/ButtonSemantic.stories.tsx"), "./app/component-library/components-temp/Buttons/ButtonToggle/ButtonToggle.stories.tsx": require("../app/component-library/components-temp/Buttons/ButtonToggle/ButtonToggle.stories.tsx"), "./app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.stories.tsx": require("../app/component-library/components-temp/CellSelectWithMenu/CellSelectWithMenu.stories.tsx"), - "./app/component-library/components-temp/HeaderCollapsible/HeaderCollapsible.stories.tsx": require("../app/component-library/components-temp/HeaderCollapsible/HeaderCollapsible.stories.tsx"), - "./app/component-library/components-temp/HeaderCollapsibleStandard/HeaderCollapsibleStandard.stories.tsx": require("../app/component-library/components-temp/HeaderCollapsibleStandard/HeaderCollapsibleStandard.stories.tsx"), - "./app/component-library/components-temp/HeaderCollapsibleSubpage/HeaderCollapsibleSubpage.stories.tsx": require("../app/component-library/components-temp/HeaderCollapsibleSubpage/HeaderCollapsibleSubpage.stories.tsx"), - "./app/component-library/components-temp/HeaderCompactSearch/HeaderCompactSearch.stories.tsx": require("../app/component-library/components-temp/HeaderCompactSearch/HeaderCompactSearch.stories.tsx"), "./app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.stories.tsx": require("../app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.stories.tsx"), - "./app/component-library/components-temp/HeaderStackedStandard/HeaderStackedStandard.stories.tsx": require("../app/component-library/components-temp/HeaderStackedStandard/HeaderStackedStandard.stories.tsx"), - "./app/component-library/components-temp/HeaderStackedSubpage/HeaderStackedSubpage.stories.tsx": require("../app/component-library/components-temp/HeaderStackedSubpage/HeaderStackedSubpage.stories.tsx"), + "./app/component-library/components-temp/HeaderRoot/HeaderRoot.stories.tsx": require("../app/component-library/components-temp/HeaderRoot/HeaderRoot.stories.tsx"), + "./app/component-library/components-temp/HeaderSearch/HeaderSearch.stories.tsx": require("../app/component-library/components-temp/HeaderSearch/HeaderSearch.stories.tsx"), + "./app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.stories.tsx": require("../app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.stories.tsx"), "./app/component-library/components-temp/KeyValueRow/KeyValueRow.stories.tsx": require("../app/component-library/components-temp/KeyValueRow/KeyValueRow.stories.tsx"), "./app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.stories.tsx": require("../app/component-library/components-temp/ListItemMultiSelectButton/ListItemMultiSelectButton.stories.tsx"), "./app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.stories.tsx": require("../app/component-library/components-temp/ListItemMultiSelectWithMenuButton/ListItemMultiSelectWithMenuButton.stories.tsx"), diff --git a/.yarn/patches/@metamask-assets-controllers-npm-100.0.3-ea172b88c9.patch b/.yarn/patches/@metamask-assets-controllers-npm-100.0.3-ea172b88c9.patch deleted file mode 100644 index dc19783ed9ab..000000000000 --- a/.yarn/patches/@metamask-assets-controllers-npm-100.0.3-ea172b88c9.patch +++ /dev/null @@ -1,28 +0,0 @@ -diff --git a/dist/multi-chain-accounts-service/api-balance-fetcher.cjs b/dist/multi-chain-accounts-service/api-balance-fetcher.cjs -index 6ae7034bd87dfdaa49324fd36a340689df45960e..4719739bf194f5fb8669f382b021b54952294508 100644 ---- a/dist/multi-chain-accounts-service/api-balance-fetcher.cjs -+++ b/dist/multi-chain-accounts-service/api-balance-fetcher.cjs -@@ -150,7 +150,10 @@ class AccountsApiBalanceFetcher { - chains.forEach((chainId) => { - const key = `${address}-${chainId}`; - const existingBalance = nativeBalancesFromAPI.get(key); -- if (!existingBalance) { -+ const isChainIncludedInRequest = chainIds.includes(chainId); -+ const isChainSupported = this.supports(chainId); -+ const shouldZeroOutBalance = !existingBalance && isChainIncludedInRequest && isChainSupported; -+ if (shouldZeroOutBalance) { - // Add zero native balance entry if API succeeded but didn't return one - results.push({ - success: true, -@@ -172,7 +175,10 @@ class AccountsApiBalanceFetcher { - const key = `${account.toLowerCase()}-${tokenLowerCase}-${chainId}`; - const isERC = tokenAddress !== ZERO_ADDRESS; - const existingBalance = nonNativeBalancesFromAPI.get(key); -- if (isERC && !existingBalance) { -+ const isChainIncludedInRequest = chainIds.includes(chainId); -+ const isChainSupported = this.supports(chainId); -+ const shouldZeroOutBalance = !existingBalance && isChainIncludedInRequest && isChainSupported; -+ if (isERC && shouldZeroOutBalance) { - results.push({ - success: true, - value: new bn_js_1.default('0'), diff --git a/.yarn/patches/@metamask-bridge-controller-npm-67.2.0-32d3aafe1f.patch b/.yarn/patches/@metamask-bridge-controller-npm-67.2.0-32d3aafe1f.patch new file mode 100644 index 000000000000..a44ffb3f1311 --- /dev/null +++ b/.yarn/patches/@metamask-bridge-controller-npm-67.2.0-32d3aafe1f.patch @@ -0,0 +1,20 @@ +diff --git a/dist/utils/metrics/types.d.cts b/dist/utils/metrics/types.d.cts +index b8f94476d4447cb5895a0f39e7beb1eb5e3b4e16..90cbc588e9ebbc84a761bbeaf3770572aad4d6f8 100644 +--- a/dist/utils/metrics/types.d.cts ++++ b/dist/utils/metrics/types.d.cts +@@ -153,6 +153,7 @@ type RequiredEventContextFromClientBase = { + export type RequiredEventContextFromClient = { + [K in keyof RequiredEventContextFromClientBase]: RequiredEventContextFromClientBase[K] & { + location?: MetaMetricsSwapsEventSource; ++ ab_tests?: Record; + }; + }; + /** +@@ -198,6 +199,7 @@ export type EventPropertiesFromControllerState = { + export type CrossChainSwapsEventProperties = { + action_type: MetricsActionType; + location: MetaMetricsSwapsEventSource; ++ ab_tests?: Record; + } | Pick[T] | Pick[T]; + export {}; + //# sourceMappingURL=types.d.cts.map diff --git a/.yarn/patches/@metamask-bridge-status-controller-npm-67.0.1-d8a41d9c02.patch b/.yarn/patches/@metamask-bridge-status-controller-npm-67.0.1-d8a41d9c02.patch new file mode 100644 index 000000000000..c2335cc19b03 --- /dev/null +++ b/.yarn/patches/@metamask-bridge-status-controller-npm-67.0.1-d8a41d9c02.patch @@ -0,0 +1,177 @@ +diff --git a/dist/bridge-status-controller.cjs b/dist/bridge-status-controller.cjs +index c89f7e4c600ea6e710a532fc6887ed525b80f333..c4c20566a27b77dfa8b88ff56a038ad5344e0efb 100644 +--- a/dist/bridge-status-controller.cjs ++++ b/dist/bridge-status-controller.cjs +@@ -207,7 +207,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll + }); + }); + _BridgeStatusController_addTxToHistory.set(this, (startPollingForBridgeTxStatusArgs, actionId) => { +- const { bridgeTxMeta, statusRequest, quoteResponse, startTime, slippagePercentage, initialDestAssetBalance, targetContractAddress, approvalTxId, isStxEnabled, location, accountAddress: selectedAddress, } = startPollingForBridgeTxStatusArgs; ++ const { bridgeTxMeta, statusRequest, quoteResponse, startTime, slippagePercentage, initialDestAssetBalance, targetContractAddress, approvalTxId, isStxEnabled, location, abTests, accountAddress: selectedAddress, } = startPollingForBridgeTxStatusArgs; + // Determine the key for this history item: + // - For pre-submission (non-batch EVM): use actionId + // - For post-submission or other cases: use bridgeTxMeta.id +@@ -248,6 +248,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll + isStxEnabled: isStxEnabled ?? false, + featureId: quoteResponse.featureId, + location, ++ ...(abTests && { abTests }), + }; + this.update((state) => { + // Use actionId as key for pre-submission, or txMeta.id for post-submission +@@ -716,8 +717,8 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll + * @param location - The entry point from which the user initiated the swap or bridge (e.g. Main View, Token View, Trending Explore) + * @returns The transaction meta + */ +- this.submitTx = async (accountAddress, quoteResponse, isStxEnabledOnClient, quotesReceivedContext, location = bridge_controller_1.MetaMetricsSwapsEventSource.MainView) => { +- this.messenger.call('BridgeController:stopPollingForQuotes', bridge_controller_1.AbortReason.TransactionSubmitted, ++ this.submitTx = async (accountAddress, quoteResponse, isStxEnabledOnClient, quotesReceivedContext, location = bridge_controller_1.MetaMetricsSwapsEventSource.MainView, abTests) => { ++ this.messenger.call('BridgeController:stopPollingForQuotes', bridge_controller_1.AbortReason.TransactionSubmitted, + // If trade is submitted before all quotes are loaded, the QuotesReceived event is published + // If the trade has a featureId, it means it was submitted outside of the Unified Swap and Bridge experience, so no QuotesReceived event is published + quoteResponse.featureId ? undefined : quotesReceivedContext); +@@ -726,7 +727,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll + throw new Error('Failed to submit cross-chain swap transaction: undefined multichain account'); + } + const isHardwareAccount = (0, bridge_controller_1.isHardwareWallet)(selectedAccount); +- const preConfirmationProperties = (0, metrics_1.getPreConfirmationPropertiesFromQuote)(quoteResponse, isStxEnabledOnClient, isHardwareAccount, location); ++ const preConfirmationProperties = (0, metrics_1.getPreConfirmationPropertiesFromQuote)(quoteResponse, isStxEnabledOnClient, isHardwareAccount, location, abTests); + // Emit Submitted event after submit button is clicked + !quoteResponse.featureId && + __classPrivateFieldGet(this, _BridgeStatusController_trackUnifiedSwapBridgeEvent, "f").call(this, bridge_controller_1.UnifiedSwapBridgeEventName.Submitted, undefined, preConfirmationProperties); +@@ -848,6 +849,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll + startTime, + approvalTxId, + location, ++ abTests, + }, actionId); + // Pass txFee when gasIncluded is true to use the quote's gas fees + // instead of re-estimating (which would fail for max native token swaps) +@@ -888,6 +890,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll + startTime, + approvalTxId, + location, ++ abTests, + }); + } + if ((0, bridge_controller_1.isNonEvmChainId)(quoteResponse.quote.srcChainId)) { +@@ -916,12 +919,12 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll + * @returns A lightweight TransactionMeta-like object for history linking + */ + this.submitIntent = async (params) => { +- const { quoteResponse, signature, accountAddress, location } = params; ++ const { quoteResponse, signature, accountAddress, location, abTests } = params; + this.messenger.call('BridgeController:stopPollingForQuotes', bridge_controller_1.AbortReason.TransactionSubmitted); + // Build pre-confirmation properties for error tracking parity with submitTx + const account = __classPrivateFieldGet(this, _BridgeStatusController_instances, "m", _BridgeStatusController_getMultichainSelectedAccount).call(this, accountAddress); + const isHardwareAccount = Boolean(account) && (0, bridge_controller_1.isHardwareWallet)(account); +- const preConfirmationProperties = (0, metrics_1.getPreConfirmationPropertiesFromQuote)(quoteResponse, false, isHardwareAccount, location); ++ const preConfirmationProperties = (0, metrics_1.getPreConfirmationPropertiesFromQuote)(quoteResponse, false, isHardwareAccount, location, abTests); + try { + const intent = (0, transaction_1.getIntentFromQuote)(quoteResponse); + // If backend provided an approval tx for this intent quote, submit it first (on-chain), +@@ -932,7 +935,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll + // Handle approval silently for better UX in intent flows + const approvalTxMeta = await __classPrivateFieldGet(this, _BridgeStatusController_handleApprovalTx, "f").call(this, isBridgeTx, quoteResponse.quote.srcChainId, quoteResponse.approval && (0, bridge_controller_1.isEvmTxData)(quoteResponse.approval) + ? quoteResponse.approval +- : undefined, quoteResponse.resetApproval, ++ : undefined, quoteResponse.resetApproval, + /* requireApproval */ false); + approvalTxId = approvalTxMeta?.id; + if (approvalTxId) { +@@ -1017,6 +1020,7 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll + approvalTxId, + startTime, + location, ++ abTests, + }); + // Start polling using the orderId key to route to intent manager + __classPrivateFieldGet(this, _BridgeStatusController_startPollingForTxId, "f").call(this, bridgeHistoryKey); +@@ -1043,12 +1047,21 @@ class BridgeStatusController extends (0, polling_controller_1.StaticIntervalPoll + * @param eventProperties - The properties for the event + */ + _BridgeStatusController_trackUnifiedSwapBridgeEvent.set(this, (eventName, txMetaId, eventProperties) => { ++ const historyAbTests = txMetaId ++ ? this.state.txHistory?.[txMetaId]?.abTests ++ : undefined; ++ const resolvedAbTests = eventProperties?.ab_tests ?? historyAbTests ?? undefined; ++ + const baseProperties = { + action_type: bridge_controller_1.MetricsActionType.SWAPBRIDGE_V1, + location: eventProperties?.location ?? + (txMetaId ? this.state.txHistory?.[txMetaId]?.location : undefined) ?? + bridge_controller_1.MetaMetricsSwapsEventSource.MainView, + ...(eventProperties ?? {}), ++ ...(resolvedAbTests && ++ Object.keys(resolvedAbTests).length > 0 && { ++ ab_tests: resolvedAbTests, ++ }), + }; + // This will publish events for PERPS dropped tx failures as well + if (!txMetaId) { +diff --git a/dist/bridge-status-controller.d.cts b/dist/bridge-status-controller.d.cts +index a71266a9c15070d9fd9242148b16ad0e454184e9..f5dc880b383ecef194a44539e6c570fbc0fa7b7a 100644 +--- a/dist/bridge-status-controller.d.cts ++++ b/dist/bridge-status-controller.d.cts +@@ -88,7 +88,7 @@ export declare class BridgeStatusController extends BridgeStatusController_base< + * @param location - The entry point from which the user initiated the swap or bridge (e.g. Main View, Token View, Trending Explore) + * @returns The transaction meta + */ +- submitTx: (accountAddress: string, quoteResponse: QuoteResponse & QuoteMetadata, isStxEnabledOnClient: boolean, quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], location?: MetaMetricsSwapsEventSource) => Promise>; ++ submitTx: (accountAddress: string, quoteResponse: QuoteResponse & QuoteMetadata, isStxEnabledOnClient: boolean, quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], location?: MetaMetricsSwapsEventSource, abTests?: Record) => Promise>; + /** + * UI-signed intent submission (fast path): the UI generates the EIP-712 signature and calls this with the raw signature. + * Here we submit the order to the intent provider and create a synthetic history entry for UX. +@@ -105,6 +105,7 @@ export declare class BridgeStatusController extends BridgeStatusController_base< + signature: string; + accountAddress: string; + location?: MetaMetricsSwapsEventSource; ++ abTests?: Record; + }) => Promise>; + } + export {}; +diff --git a/dist/types.d.cts b/dist/types.d.cts +index d0492cd8b8159bc63121174e6395f53c49aba59b..c5402866e8f13e37e535d1196d697756d2330023 100644 +--- a/dist/types.d.cts ++++ b/dist/types.d.cts +@@ -96,6 +96,11 @@ export type BridgeHistoryItem = { + * Used to attribute swaps to specific flows (e.g. Trending Explore). + */ + location?: MetaMetricsSwapsEventSource; ++ /** ++ * A/B test context to attribute swap/bridge events to specific experiments. ++ * Keys are test names, values are variant names (e.g. { token_details_layout: 'treatment' }). ++ */ ++ abTests?: Record; + /** + * Attempts tracking for exponential backoff on failed fetches. + * We track the number of attempts and the last attempt time for each txMetaId that has failed at least once +@@ -161,6 +166,7 @@ export type StartPollingForBridgeTxStatusArgs = { + approvalTxId?: BridgeHistoryItem['approvalTxId']; + isStxEnabled?: BridgeHistoryItem['isStxEnabled']; + location?: BridgeHistoryItem['location']; ++ abTests?: BridgeHistoryItem['abTests']; + accountAddress: string; + }; + /** +diff --git a/dist/utils/metrics.cjs b/dist/utils/metrics.cjs +index 775367bc08c8a46c19a78913903573a295d1f677..ef00bf331d866e60a23204475f18a017614fdd57 100644 +--- a/dist/utils/metrics.cjs ++++ b/dist/utils/metrics.cjs +@@ -109,7 +109,7 @@ exports.getPriceImpactFromQuote = getPriceImpactFromQuote; + * @param location - The entry point from which the user initiated the swap or bridge (e.g. Main View, Token View, Trending Explore) + * @returns The properties for the pre-confirmation event + */ +-const getPreConfirmationPropertiesFromQuote = (quoteResponse, isStxEnabledOnClient, isHardwareAccount, location = bridge_controller_1.MetaMetricsSwapsEventSource.MainView) => { ++const getPreConfirmationPropertiesFromQuote = (quoteResponse, isStxEnabledOnClient, isHardwareAccount, location = bridge_controller_1.MetaMetricsSwapsEventSource.MainView, abTests) => { + const { quote } = quoteResponse; + return { + ...(0, exports.getPriceImpactFromQuote)(quote), +@@ -125,6 +125,7 @@ const getPreConfirmationPropertiesFromQuote = (quoteResponse, isStxEnabledOnClie + action_type: bridge_controller_1.MetricsActionType.SWAPBRIDGE_V1, + custom_slippage: false, // TODO detect whether the user changed the default slippage + location, ++ ...(abTests && Object.keys(abTests).length > 0 && { ab_tests: abTests }), + }; + }; + exports.getPreConfirmationPropertiesFromQuote = getPreConfirmationPropertiesFromQuote; diff --git a/.yarn/patches/appwright-patch-9fcd858d19.patch b/.yarn/patches/appwright-patch-9fcd858d19.patch new file mode 100644 index 000000000000..46d3c5253a49 --- /dev/null +++ b/.yarn/patches/appwright-patch-9fcd858d19.patch @@ -0,0 +1,13 @@ +diff --git a/dist/providers/browserstack/index.js b/dist/providers/browserstack/index.js +index c84d31cb938f284c04051bcca7245ccbe42fa684..58a8bc4f521fd46a34d6caba7b0a88170e7ac6a1 100644 +--- a/dist/providers/browserstack/index.js ++++ b/dist/providers/browserstack/index.js +@@ -248,7 +248,7 @@ class BrowserStackDeviceProvider { + capabilities: { + "bstack:options": { + debug: true, +- local: true, ++ local: process.env.BROWSERSTACK_LOCAL?.toLowerCase() !== 'false', + ...(process.env.BROWSERSTACK_LOCAL_IDENTIFIER ? { localIdentifier: process.env.BROWSERSTACK_LOCAL_IDENTIFIER } : {}), + networkProfile : '4g-lte-advanced-good', + interactiveDebugging: true, diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d8790eb2fe..c69c2b55406d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.69.0] + +### Added + +- Add network logo for Tempo mainnet (#26904) +- Added the "refundTo" param for postQuote transactions, so refunds from Relay will be refunded back to Predict balance (#27065) +- Added MYX Finance as a second perpetuals provider with provider+network switching UI and validated market data (#26553) +- Added redesigned Speed up and Cancel transaction modal (#26209) +- Added max convert bottom sheet for mUSD Quick Convert (#26638) +- Allow users to select a payment method if their amount is 0 (#26717) +- Added watchlist tokens to the top of the Perpetuals trending carousel on the homepage with a star badge indicator (#26763) +- Added a bottom fade overlay to the homepage wallet view indicating scrollable content (#26470) +- Sets Infura RPC for HyperEVM with Quicknode failover (#25367) +- Added new toast-style notifications for buy order status updates (processing, completed, failed, cancelled) behind the Unified Buy V2 feature flag (#26670) +- Added blue claim button to the homepage Predictions section for users with claimable winnings (#26650) + +### Changed + +- Updated mUSD conversion flow copy to replace boost with bonus (#26453) +- Removed showing OTA modal when users are on onboarding screen (#26839) +- Inlined perps "Add funds" flow in the pay-with token filter so the Pay With modal no longer depends on a deposit callback from the parent (#26543) +- Improved keyboard UX for buy feature (#26776) +- Improved Ramps buy flow by preventing auto-lock during checkout (#26723) +- Updated prediction market card design with smaller cards, plain-text percentages, and consistent heights (#26795) +- Updated ramp provider selection modal UI (#26726) +- UI updates for the Reveal SRP feature, specifically the Quiz Component and Reveal SRP views (#25388) +- Disabled the Max button for Predict Withdraw (#27006) +- Updated mUSD conversion tertiary CTA copy (#26805) +- Removed "Reset notifications" button from notifications list (#26641) +- Removed territory field for unified formatting (#26623) + +### Fixed + +- Refactored Ramp buyability to add batched useTokensBuyability with keyed results and keep useTokenBuyability as a backward-compatible wrapper, reducing redundant legacy token-cache fetches for multi-token checks (#25539) +- Fixed incorrect error message shown when there is not enough native token to cover gas fees during a withdrawal (#27001) +- Fixed missing Ramps Button Clicked analytics event when tapping Buy on the homepage token empty state (#27058) +- Fixed spurious offline/reconnecting toasts on mount and during intentional reconnects (#27034) +- Fixed the occasional Input placeholder misalignments (#26835) +- Fixed a quote expired element showing outside of the bridge page (#26729) +- Fixed missing NFT detection on homepage focus, restoring auto-detection when the user navigates to the homepage (#26919) +- Fixed cashback option visibility for US users on Card home screen and updated cashback description for Metal Card holders (#26993) +- Fixed camera permission "allow once" not showing the camera popup on Android (#26415) +- Fixed a bug where the Receive address in EVM token details showed a Non-EVM address after switching from a Non-EVM network (#26965) +- Fixed a bug where some swap deeplink token icons did not load because ERC-20 icon URLs used checksummed addresses (#26914) +- Fixed perps ROE percentage display to show 2 decimal places for improved precision (#26600) +- Fixed token price display on the token details page to show up to 4 decimal places for precision, and subscript notation for very small prices, consistent with the trending token list (#26894) +- Fixed Perps section on homepage showing a full-screen error instead of the compact inline error state (#26831) +- Fixed Buy token selection screen showing "No tokens match" with an empty list on first app load after install or update; screen now shows loading until tokens are ready (#26852) +- Fixed date of birth formatting on Card onboarding so the displayed date matches the user's selected date in all timezones (#26893) +- Fixed Unified Buy Build Quote header to use correct typography and icon sizing per design spec (#26713) +- Fixed Google login crash on Android devices without credential provider dependencies by falling back to browser-based authentication (#26677) +- Fixed saving network details for custom networks (e.g. added from Popular list) when the Save button had no effect (#27053) +- Fixed off-center "Buy/Get mUSD" button for primary mUSD conversion CTA (#27015) +- Fixed network details screen showing a delete (trash) icon for networks that cannot be removed (e.g. Ethereum mainnet, Linea, Goerli, testnets) (#26983) +- Fixed token hiding not working on the redesigned homepage (#26649) +- Fixed an issue that could cause repeated Bridge RPC balance calls and improved how quickly source balances appear after token selection (#25952) + ## [7.68.2] ### Fixed @@ -10860,7 +10917,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.68.2...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.69.0...HEAD +[7.69.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.68.2...v7.69.0 [7.68.2]: https://github.com/MetaMask/metamask-mobile/compare/v7.68.1...v7.68.2 [7.68.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.68.0...v7.68.1 [7.68.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.67.3...v7.68.0 diff --git a/README.md b/README.md index 76e257c003bc..021cf3379730 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ To learn how to contribute to the MetaMask codebase, visit our [Contributor Docs - [Build Troubleshooting](./docs/readme/troubleshooting.md) - [Component View Testing](./docs/readme/component-view-testing.md) - [E2E Testing](./docs/readme/e2e-testing.md) +- [On-Ramp Provider Manual Testing](./tests/docs/ONRAMP-PROVIDER-TESTING.md) - [Debugging](./docs/readme/debugging.md) - [Performance](./docs/readme/performance.md) - [Release Build Profiling](./docs/readme/release-build-profiler.md) diff --git a/android/app/build.gradle b/android/app/build.gradle index d82c6660106a..0e6881cc0419 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,8 +187,8 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.68.1" - versionCode 4009 + versionName "7.69.0" + versionCode 4015 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" @@ -197,6 +197,7 @@ android { packagingOptions { exclude 'META-INF/DEPENDENCIES' + exclude 'META-INF/versions/9/OSGI-INF/MANIFEST.MF' pickFirst 'lib/x86/libc++_shared.so' pickFirst 'lib/x86_64/libc++_shared.so' pickFirst 'lib/armeabi-v7a/libc++_shared.so' diff --git a/app/__mocks__/@myx-trade/sdk.js b/app/__mocks__/@myx-trade/sdk.js index b382945bd517..75ea74354c60 100644 --- a/app/__mocks__/@myx-trade/sdk.js +++ b/app/__mocks__/@myx-trade/sdk.js @@ -13,6 +13,54 @@ class MyxClient { } } +// SDK enums (mirrored from the real SDK to support adapter tests) +const Direction = { LONG: 0, SHORT: 1 }; +const DirectionEnum = { Long: 0, Short: 1 }; +const OrderTypeEnum = { Market: 0, Limit: 1, Stop: 2, Conditional: 3 }; +const OperationEnum = { Increase: 0, Decrease: 1 }; +const OrderStatusEnum = { Cancelled: 1, Expired: 2, Successful: 9 }; +const ExecTypeEnum = { + Market: 1, + Limit: 2, + TP: 3, + SL: 4, + ADL: 5, + Liquidation: 6, +}; +const TradeFlowTypeEnum = { + Increase: 0, + Decrease: 1, + AddMargin: 2, + RemoveMargin: 3, + CancelOrder: 4, + ADL: 5, + Liquidation: 6, + MarketClose: 7, + EarlyClose: 8, + AddTPSL: 9, + SecurityDeposit: 10, + TransferToWallet: 11, + MarginAccountDeposit: 12, + ReferralReward: 13, +}; +const TriggerType = { None: 0, TP: 1, SL: 2 }; +const OrderType = { Market: 0, Limit: 1 }; +const OperationType = { Increase: 0, Decrease: 1 }; +const OrderStatus = { Pending: 0, Cancelled: 1, Expired: 2, Filled: 9 }; +const TimeInForce = { IOC: 0 }; + module.exports = { MyxClient, + Direction, + DirectionEnum, + OrderTypeEnum, + OperationEnum, + OrderStatusEnum, + ExecTypeEnum, + TradeFlowTypeEnum, + TriggerType, + OrderType, + OperationType, + OrderStatus, + TimeInForce, }; diff --git a/app/component-library/base-components/TagBase/__snapshots__/TagBase.test.tsx.snap b/app/component-library/base-components/TagBase/__snapshots__/TagBase.test.tsx.snap index 7700e374528c..398a8f6620a8 100644 --- a/app/component-library/base-components/TagBase/__snapshots__/TagBase.test.tsx.snap +++ b/app/component-library/base-components/TagBase/__snapshots__/TagBase.test.tsx.snap @@ -9,10 +9,10 @@ exports[`TagBase should render TagBase 1`] = ` { "alignSelf": "flex-start", "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", + "borderColor": "#949596", "borderRadius": 999, "borderWidth": 0, - "color": "#121314", + "color": "#131416", "padding": 16, "paddingHorizontal": 8, "paddingVertical": 2, @@ -29,7 +29,7 @@ exports[`TagBase should render TagBase 1`] = ` } > diff --git a/app/component-library/components-temp/CustomSpendCap/CustomInput/__snapshots__/CustomInput.test.tsx.snap b/app/component-library/components-temp/CustomSpendCap/CustomInput/__snapshots__/CustomInput.test.tsx.snap index b0a1ba1ff21d..311908a5edfe 100644 --- a/app/component-library/components-temp/CustomSpendCap/CustomInput/__snapshots__/CustomInput.test.tsx.snap +++ b/app/component-library/components-temp/CustomSpendCap/CustomInput/__snapshots__/CustomInput.test.tsx.snap @@ -33,7 +33,7 @@ exports[`CustomInput should render correctly 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "flexGrow": 1, "fontFamily": "Geist-Regular", "fontSize": 16, @@ -55,7 +55,7 @@ exports[`CustomInput should render correctly 1`] = ` onPress={[Function]} style={ { - "color": "#686e7d", + "color": "#66676a", } } testID="custom-spend-cap-max-test-id" diff --git a/app/component-library/components-temp/CustomSpendCap/__snapshots__/CustomSpendCap.test.tsx.snap b/app/component-library/components-temp/CustomSpendCap/__snapshots__/CustomSpendCap.test.tsx.snap index 4a34a506a265..9ea9cb8f3eae 100644 --- a/app/component-library/components-temp/CustomSpendCap/__snapshots__/CustomSpendCap.test.tsx.snap +++ b/app/component-library/components-temp/CustomSpendCap/__snapshots__/CustomSpendCap.test.tsx.snap @@ -4,7 +4,7 @@ exports[`CustomSpendCap should match snapshot 1`] = ` ( - <> - {Array.from({ length: itemCount }).map((_, index) => ( - - Item {index + 1} - - This is sample content to demonstrate scrolling behavior. - - - ))} - -); - -interface ScrollableStoryContainerProps { - children: (props: { - scrollYValue: SharedValue; - setExpandedHeight: (h: number) => void; - }) => React.ReactNode; -} - -const ScrollableStoryContainer = ({ - children, -}: ScrollableStoryContainerProps) => { - const tw = useTailwind(); - const scrollYValue = useSharedValue(0); - const [expandedHeight, setExpandedHeight] = useState(140); - - const onScroll = useCallback( - (event: NativeSyntheticEvent) => { - scrollYValue.value = event.nativeEvent.contentOffset.y; - }, - [scrollYValue], - ); - - return ( - - {children({ scrollYValue, setExpandedHeight })} - - - - - ); -}; - -export const Default = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - expandedContent={ - - } - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const WithBottomLabel = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - expandedContent={ - - } - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const WithSubtitle = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - expandedContent={ - - } - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const OnClose = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Close pressed')} - expandedContent={ - - } - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const BackAndClose = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - onClose={() => console.log('Close pressed')} - expandedContent={ - - } - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const EndButtonIconProps = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - endButtonIconProps={[ - { - iconName: IconName.Close, - onPress: () => console.log('Close pressed'), - }, - ]} - expandedContent={ - - } - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const BackButtonProps = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Custom back pressed'), - }} - expandedContent={ - - } - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const CustomExpandedContent = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - expandedContent={ - - Custom Title Section - - This is a completely custom expanded content section - - - } - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; diff --git a/app/component-library/components-temp/HeaderCollapsible/HeaderCollapsible.test.tsx b/app/component-library/components-temp/HeaderCollapsible/HeaderCollapsible.test.tsx deleted file mode 100644 index 6217e658c688..000000000000 --- a/app/component-library/components-temp/HeaderCollapsible/HeaderCollapsible.test.tsx +++ /dev/null @@ -1,332 +0,0 @@ -// Third party dependencies. -import React from 'react'; -import { render } from '@testing-library/react-native'; -import { useSharedValue, SharedValue } from 'react-native-reanimated'; -import { Text } from 'react-native'; - -// External dependencies. -import { IconName } from '@metamask/design-system-react-native'; - -// Internal dependencies. -import HeaderCollapsible from './HeaderCollapsible'; - -// Mock react-native-reanimated -jest.mock('react-native-reanimated', () => { - const Reanimated = jest.requireActual('react-native-reanimated/mock'); - Reanimated.useSharedValue = jest.fn((initial) => ({ - value: initial, - })); - Reanimated.useAnimatedStyle = jest.fn((fn) => fn()); - Reanimated.interpolate = jest.fn( - (_value, _inputRange, outputRange) => outputRange[0], - ); - Reanimated.Extrapolation = { CLAMP: 'clamp' }; - return Reanimated; -}); - -// Mock react-native-safe-area-context -jest.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }), -})); - -// Test wrapper component that provides scrollY -const TestWrapper: React.FC<{ - children: (scrollYValue: SharedValue) => React.ReactNode; -}> = ({ children }) => { - const scrollYValue = useSharedValue(0); - return <>{children(scrollYValue)}; -}; - -describe('HeaderCollapsible', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('rendering', () => { - it('renders with title', () => { - const { getAllByText } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getAllByText('Test Title').length).toBeGreaterThan(0); - }); - - it('renders container with testID when provided', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId('test-container')).toBeOnTheScreen(); - }); - }); - - describe('back button', () => { - it('renders back button when onBack provided', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId('test-back-button')).toBeOnTheScreen(); - }); - - it('renders back button when backButtonProps provided', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId('test-back-button')).toBeOnTheScreen(); - }); - }); - - describe('close button', () => { - it('renders close button when onClose provided', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId('test-close-button')).toBeOnTheScreen(); - }); - - it('renders close button when closeButtonProps provided', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId('test-close-button')).toBeOnTheScreen(); - }); - }); - - describe('endButtonIconProps', () => { - it('renders endButtonIconProps', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId('end-button')).toBeOnTheScreen(); - }); - }); - - describe('isInsideSafeAreaView', () => { - it('positions header at top 0 when isInsideSafeAreaView is false', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - const container = getByTestId('test-container'); - const flattenedStyle = Array.isArray(container.props.style) - ? Object.assign({}, ...container.props.style) - : container.props.style; - - expect(flattenedStyle.top).toBe(0); - }); - - it('positions header at insets.top when isInsideSafeAreaView is true', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - const container = getByTestId('test-container'); - const flattenedStyle = Array.isArray(container.props.style) - ? Object.assign({}, ...container.props.style) - : container.props.style; - - expect(flattenedStyle.top).toBe(44); - }); - }); - - describe('expandedContent', () => { - it('renders custom expandedContent node', () => { - const { getByText } = render( - - {(scrollYValue) => ( - Custom Expanded Content} - /> - )} - , - ); - - expect(getByText('Custom Expanded Content')).toBeOnTheScreen(); - }); - - it('does not render expanded content when not provided', () => { - const { queryByText } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(queryByText('Custom Expanded Content')).toBeNull(); - }); - }); - - describe('subtitle', () => { - it('renders subtitle in compact header when provided', () => { - const { getByText } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByText('Test Subtitle')).toBeOnTheScreen(); - }); - - it('does not render subtitle when not provided', () => { - const { queryByText } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(queryByText('Test Subtitle')).toBeNull(); - }); - - it('renders subtitle with testID when provided via subtitleProps', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId('subtitle-test-id')).toBeOnTheScreen(); - }); - }); - - describe('children', () => { - it('renders custom children in compact header section', () => { - const { getByText } = render( - - {(scrollYValue) => ( - - Custom Compact Content - - )} - , - ); - - expect(getByText('Custom Compact Content')).toBeOnTheScreen(); - }); - - it('does not render subtitle when children provided', () => { - const { getByText, queryByText } = render( - - {(scrollYValue) => ( - - Custom Content - - )} - , - ); - - expect(getByText('Custom Content')).toBeOnTheScreen(); - expect(queryByText('Test Subtitle')).toBeNull(); - }); - }); -}); diff --git a/app/component-library/components-temp/HeaderCollapsible/HeaderCollapsible.tsx b/app/component-library/components-temp/HeaderCollapsible/HeaderCollapsible.tsx deleted file mode 100644 index da54710e7263..000000000000 --- a/app/component-library/components-temp/HeaderCollapsible/HeaderCollapsible.tsx +++ /dev/null @@ -1,269 +0,0 @@ -// Third party dependencies. -import React, { useMemo, useState, useCallback } from 'react'; -import { View, LayoutChangeEvent } from 'react-native'; -import Animated, { - useAnimatedStyle, - useDerivedValue, - interpolate, - Extrapolation, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; - -// External dependencies. -import { - Box, - BoxAlignItems, - Text, - TextVariant, - TextColor, - FontWeight, - IconName, - ButtonIconProps, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -// Internal dependencies. -import HeaderBase from '../../components/HeaderBase'; -import { HeaderCollapsibleProps } from './HeaderCollapsible.types'; - -const DEFAULT_EXPANDED_HEIGHT = 140; -const DEFAULT_COLLAPSED_HEIGHT = 56; - -/** - * HeaderCollapsible is a collapsing header component that transitions - * between an expanded state (with custom content) and a compact sticky state (with HeaderBase) - * based on scroll position. - * - * Uses Reanimated for performant scroll-linked animations. - * - * @example - * ```tsx - * return ( - * - * - * } - * scrollY={scrollY} - * /> - * - * - * - * - * ); - * ``` - */ -const HeaderCollapsible: React.FC = ({ - title, - titleProps, - subtitle, - subtitleProps, - children, - onBack, - backButtonProps, - onClose, - closeButtonProps, - expandedContent, - scrollTriggerPosition, - scrollY, - startButtonIconProps, - endButtonIconProps, - twClassName = '', - onExpandedHeightChange, - testID, - isInsideSafeAreaView = false, - ...headerBaseProps -}) => { - const tw = useTailwind(); - const insets = useSafeAreaInsets(); - - // Measure actual content height for dynamic sizing - const [measuredHeight, setMeasuredHeight] = useState(DEFAULT_EXPANDED_HEIGHT); - const animatedMeasuredHeight = useSharedValue(DEFAULT_EXPANDED_HEIGHT); - - const handleLayout = useCallback( - (e: LayoutChangeEvent) => { - const { height } = e.nativeEvent.layout; - if (height > 0 && height !== measuredHeight) { - setMeasuredHeight(height); - animatedMeasuredHeight.value = height; - onExpandedHeightChange?.(height); - } - }, - [measuredHeight, animatedMeasuredHeight, onExpandedHeightChange], - ); - - // Use scrollTriggerPosition if provided, otherwise use measured height - const effectiveScrollTriggerPosition = - scrollTriggerPosition ?? measuredHeight; - - // Build startButtonIconProps with back button if onBack or backButtonProps is provided - const resolvedStartButtonIconProps = useMemo(() => { - if (startButtonIconProps) { - return startButtonIconProps; - } - - if (onBack || backButtonProps) { - const backProps: ButtonIconProps = { - iconName: IconName.ArrowLeft, - ...(backButtonProps || {}), - onPress: backButtonProps?.onPress ?? onBack, - }; - return backProps; - } - - return undefined; - }, [startButtonIconProps, onBack, backButtonProps]); - - // Build endButtonIconProps with close button if onClose or closeButtonProps is provided - const resolvedEndButtonIconProps = useMemo(() => { - const props: ButtonIconProps[] = []; - - if (onClose || closeButtonProps) { - const closeProps: ButtonIconProps = { - iconName: IconName.Close, - ...(closeButtonProps || {}), - onPress: closeButtonProps?.onPress ?? onClose, - }; - props.push(closeProps); - } - - if (endButtonIconProps) { - props.push(...endButtonIconProps); - } - - return props.length > 0 ? props : undefined; - }, [endButtonIconProps, onClose, closeButtonProps]); - - // Animated style for the header container height (uses measured content height) - const headerAnimatedStyle = useAnimatedStyle(() => { - const height = interpolate( - scrollY.value, - [0, effectiveScrollTriggerPosition], - [animatedMeasuredHeight.value, DEFAULT_COLLAPSED_HEIGHT], - Extrapolation.CLAMP, - ); - - return { - height, - }; - }); - - // Derived value: triggers timed animation when large content is fully hidden - const compactTitleProgress = useDerivedValue(() => { - // Use effectiveScrollTriggerPosition to sync with header collapse animation - const triggerPosition = - effectiveScrollTriggerPosition - DEFAULT_COLLAPSED_HEIGHT; - const isFullyHidden = scrollY.value >= triggerPosition; - - // Animate to 1 when hidden, 0 when visible (with timing) - return withTiming(isFullyHidden ? 1 : 0, { duration: 150 }); - }); - - // Animated style for the compact title (timed fade up when large content is fully hidden) - const compactTitleAnimatedStyle = useAnimatedStyle(() => { - const progress = compactTitleProgress.value; - - return { - opacity: progress, - transform: [{ translateY: (1 - progress) * 8 }], // Fade up from 8px below - }; - }); - - // Animated style for the expanded content (moves up behind header, synced 1:1 with scroll) - const expandedContentAnimatedStyle = useAnimatedStyle(() => { - const expandedContentHeight = - animatedMeasuredHeight.value - DEFAULT_COLLAPSED_HEIGHT; - // Move up 1:1 with scroll, clamped between 0 and -expandedContentHeight - // Math.min(..., 0) prevents moving down on overscroll - // Math.max(..., -expandedContentHeight) prevents moving up too far - const translateY = Math.min( - Math.max(-scrollY.value, -expandedContentHeight), - 0, - ); - - return { - transform: [{ translateY }], - }; - }); - - // Render compact title content - // If children is provided, use it; otherwise render default title + subtitle - const renderCompactContent = () => { - if (children) { - return children; - } - return ( - - - {title} - - {subtitle && ( - - {subtitle} - - )} - - ); - }; - - const containerStyle = useMemo( - () => [ - tw.style('absolute left-0 right-0 z-10'), - { top: isInsideSafeAreaView ? insets.top : 0 }, - headerAnimatedStyle, - ], - [tw, isInsideSafeAreaView, insets.top, headerAnimatedStyle], - ); - - return ( - - {/* Header content - measured for dynamic height */} - - {/* HeaderBase with compact title */} - - {/* Compact title - fades in when collapsed */} - - {renderCompactContent()} - - - - {/* Expanded content - clips as it moves up behind header */} - - - {expandedContent} - - - - - ); -}; - -export default HeaderCollapsible; diff --git a/app/component-library/components-temp/HeaderCollapsible/HeaderCollapsible.types.ts b/app/component-library/components-temp/HeaderCollapsible/HeaderCollapsible.types.ts deleted file mode 100644 index 1f0f07c8cf48..000000000000 --- a/app/component-library/components-temp/HeaderCollapsible/HeaderCollapsible.types.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Third party dependencies. -import { ReactNode } from 'react'; -import { SharedValue } from 'react-native-reanimated'; - -// External dependencies. -import { - ButtonIconProps, - TextProps, -} from '@metamask/design-system-react-native'; - -// Internal dependencies. -import { HeaderBaseProps } from '../../components/HeaderBase'; - -/** - * Props for the HeaderCollapsible component. - */ -export interface HeaderCollapsibleProps extends HeaderBaseProps { - /** - * Title text displayed in the compact header state. - */ - title: string; - /** - * Additional props to pass to the title Text component in the compact header. - * Props are spread to the Text component and can override default values. - */ - titleProps?: Partial; - /** - * Subtitle text to display below the title in the compact header. - * Rendered with TextVariant.BodySm and TextColor.TextAlternative by default. - */ - subtitle?: string; - /** - * Additional props to pass to the subtitle Text component. - * Props are spread to the Text component and can override default values. - */ - subtitleProps?: Partial; - /** - * Callback when the back button is pressed. - * If provided, a back button will be rendered as startAccessory. - */ - onBack?: () => void; - /** - * Additional props to pass to the back ButtonIcon. - * If provided, a back button will be rendered with these props spread. - */ - backButtonProps?: Omit; - /** - * Callback when the close button is pressed. - * If provided, a close button will be added to endButtonIconProps. - */ - onClose?: () => void; - /** - * Additional props to pass to the close ButtonIcon. - * If provided, a close button will be added to endButtonIconProps with these props spread. - */ - closeButtonProps?: Omit; - /** - * Custom node to render in the expanded content section. - * This content is displayed when the header is expanded and collapses as the user scrolls. - */ - expandedContent?: ReactNode; - /** - * Scroll position (in pixels) at which the header fully collapses. - * @default measured content height - */ - scrollTriggerPosition?: number; - /** - * Reanimated shared value tracking scroll position. - */ - scrollY: SharedValue; - /** - * Callback fired when the expanded height is measured. - * Use this to update ScrollView's contentContainerStyle paddingTop. - */ - onExpandedHeightChange?: (height: number) => void; - /** - * Whether the header is inside a SafeAreaView. - * When true, positions the header at the safe area boundary instead of top-0. - * @default false - */ - isInsideSafeAreaView?: boolean; -} - -/** - * Options for the useHeaderCollapsible hook. - */ -export interface UseHeaderCollapsibleOptions { - /** - * Height of the header in its expanded (large) state. - * @default 140 - */ - expandedHeight?: number; - /** - * Scroll position at which the header fully collapses. - * @default expandedHeight - */ - scrollTriggerPosition?: number; -} - -/** - * Return type for the useHeaderCollapsible hook. - */ -export interface UseHeaderCollapsibleReturn { - /** - * Scroll handler to attach to ScrollView's onScroll prop. - */ - onScroll: ( - event: import('react-native').NativeSyntheticEvent< - import('react-native').NativeScrollEvent - >, - ) => void; - /** - * Shared value tracking current scroll position. - */ - scrollY: SharedValue; - /** - * Expanded header height for initial padding. - * Use this for ScrollView's contentContainerStyle paddingTop. - */ - expandedHeight: number; - /** - * Function to update the expanded height when content is measured. - * Pass this to HeaderCollapsible's onExpandedHeightChange prop. - */ - setExpandedHeight: (height: number) => void; - /** - * The scroll position at which the header fully collapses. - * Defaults to expandedHeight if not provided. - */ - scrollTriggerPosition: number; -} diff --git a/app/component-library/components-temp/HeaderCollapsible/index.ts b/app/component-library/components-temp/HeaderCollapsible/index.ts deleted file mode 100644 index 98b53796f121..000000000000 --- a/app/component-library/components-temp/HeaderCollapsible/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { default } from './HeaderCollapsible'; -export { default as useHeaderCollapsible } from './useHeaderCollapsible'; -export type { - HeaderCollapsibleProps, - UseHeaderCollapsibleOptions, - UseHeaderCollapsibleReturn, -} from './HeaderCollapsible.types'; diff --git a/app/component-library/components-temp/HeaderCollapsible/useHeaderCollapsible.ts b/app/component-library/components-temp/HeaderCollapsible/useHeaderCollapsible.ts deleted file mode 100644 index 6672a9463a28..000000000000 --- a/app/component-library/components-temp/HeaderCollapsible/useHeaderCollapsible.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Third party dependencies. -import { useCallback, useState } from 'react'; -import { NativeSyntheticEvent, NativeScrollEvent } from 'react-native'; -import { useSharedValue } from 'react-native-reanimated'; - -// Internal dependencies. -import { - UseHeaderCollapsibleOptions, - UseHeaderCollapsibleReturn, -} from './HeaderCollapsible.types'; - -/** - * Hook for managing HeaderCollapsible scroll-linked animations. - * - * @param options - Configuration options for the hook. - * @returns Object containing scroll handler, scrollY value, and header heights. - * - * @example - * ```tsx - * const { onScroll, scrollY, expandedHeight, setExpandedHeight } = useHeaderCollapsible(); - * - * return ( - * - * - * - * - * - * - * ); - * ``` - */ -const useHeaderCollapsible = ( - options: UseHeaderCollapsibleOptions = {}, -): UseHeaderCollapsibleReturn => { - const { expandedHeight: initialExpandedHeight = 140, scrollTriggerPosition } = - options; - - // Track expanded height - can be updated by component via onExpandedHeightChange - const [expandedHeight, setExpandedHeight] = useState(initialExpandedHeight); - - // Default scrollTriggerPosition to expandedHeight if not provided - const effectiveScrollTriggerPosition = - scrollTriggerPosition ?? expandedHeight; - - const scrollYValue = useSharedValue(0); - - const onScroll = useCallback( - (scrollEvent: NativeSyntheticEvent) => { - scrollYValue.value = scrollEvent.nativeEvent.contentOffset.y; - }, - [scrollYValue], - ); - - return { - onScroll, - scrollY: scrollYValue, - expandedHeight, - setExpandedHeight, - scrollTriggerPosition: effectiveScrollTriggerPosition, - }; -}; - -export default useHeaderCollapsible; diff --git a/app/component-library/components-temp/HeaderCollapsibleStandard/HeaderCollapsibleStandard.stories.tsx b/app/component-library/components-temp/HeaderCollapsibleStandard/HeaderCollapsibleStandard.stories.tsx deleted file mode 100644 index da0e0b7c4212..000000000000 --- a/app/component-library/components-temp/HeaderCollapsibleStandard/HeaderCollapsibleStandard.stories.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/* eslint-disable no-console */ -import React, { useState, useCallback } from 'react'; -import { - View, - ScrollView, - NativeSyntheticEvent, - NativeScrollEvent, -} from 'react-native'; - -import { - Box, - Text, - TextVariant, - IconName, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { useSharedValue, SharedValue } from 'react-native-reanimated'; - -import HeaderCollapsibleStandard from './HeaderCollapsibleStandard'; - -const HeaderCollapsibleStandardMeta = { - title: 'Components Temp / HeaderCollapsibleStandard', - component: HeaderCollapsibleStandard, - argTypes: { - title: { - control: 'text', - }, - subtitle: { - control: 'text', - }, - twClassName: { - control: 'text', - }, - }, -}; - -export default HeaderCollapsibleStandardMeta; - -const SampleContent = ({ itemCount = 20 }: { itemCount?: number }) => ( - <> - {Array.from({ length: itemCount }).map((_, index) => ( - - Item {index + 1} - - This is sample content to demonstrate scrolling behavior. - - - ))} - -); - -interface ScrollableStoryContainerProps { - children: (props: { - scrollYValue: SharedValue; - setExpandedHeight: (h: number) => void; - }) => React.ReactNode; -} - -const ScrollableStoryContainer = ({ - children, -}: ScrollableStoryContainerProps) => { - const tw = useTailwind(); - const scrollYValue = useSharedValue(0); - const [expandedHeight, setExpandedHeight] = useState(140); - - const onScroll = useCallback( - (event: NativeSyntheticEvent) => { - scrollYValue.value = event.nativeEvent.contentOffset.y; - }, - [scrollYValue], - ); - - return ( - - {children({ scrollYValue, setExpandedHeight })} - - - - - ); -}; - -export const Default = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - titleStandardProps={{ - topLabel: 'Send', - title: '$4.42', - }} - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const WithBottomLabel = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - titleStandardProps={{ - topLabel: 'Send', - title: '$4.42', - bottomLabel: '0.002 ETH', - }} - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const WithSubtitle = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - titleStandardProps={{ - topLabel: 'Send', - title: '$4.42', - }} - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const OnClose = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Close pressed')} - titleStandardProps={{ - topLabel: 'Send', - title: '$4.42', - }} - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const BackAndClose = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - onClose={() => console.log('Close pressed')} - titleStandardProps={{ - topLabel: 'Send', - title: '$4.42', - }} - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const EndButtonIconProps = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - endButtonIconProps={[ - { - iconName: IconName.Close, - onPress: () => console.log('Close pressed'), - }, - ]} - titleStandardProps={{ - topLabel: 'Send', - title: '$4.42', - }} - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const CustomTitleStandard = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - titleStandard={ - - Custom Title Section - - This is a completely custom expanded content section - - - } - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; diff --git a/app/component-library/components-temp/HeaderCollapsibleStandard/HeaderCollapsibleStandard.test.tsx b/app/component-library/components-temp/HeaderCollapsibleStandard/HeaderCollapsibleStandard.test.tsx deleted file mode 100644 index 67c61c3f3256..000000000000 --- a/app/component-library/components-temp/HeaderCollapsibleStandard/HeaderCollapsibleStandard.test.tsx +++ /dev/null @@ -1,339 +0,0 @@ -// Third party dependencies. -import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; -import { useSharedValue, SharedValue } from 'react-native-reanimated'; -import { Text, IconName } from '@metamask/design-system-react-native'; - -// Internal dependencies. -import HeaderCollapsibleStandard from './HeaderCollapsibleStandard'; - -// Mock react-native-reanimated -jest.mock('react-native-reanimated', () => { - const Reanimated = jest.requireActual('react-native-reanimated/mock'); - Reanimated.useSharedValue = jest.fn((initial) => ({ - value: initial, - })); - Reanimated.useAnimatedStyle = jest.fn((fn) => fn()); - Reanimated.interpolate = jest.fn( - (_value, _inputRange, outputRange) => outputRange[0], - ); - Reanimated.Extrapolation = { CLAMP: 'clamp' }; - return Reanimated; -}); - -// Mock react-native-safe-area-context -jest.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }), -})); - -const TEST_IDS = { - CONTAINER: 'header-collapsible-standard-container', - TITLE_SECTION: 'header-collapsible-standard-title-section', - BACK_BUTTON: 'header-collapsible-standard-back-button', - CLOSE_BUTTON: 'header-collapsible-standard-close-button', - HEADER_BASE_END_ACCESSORY: 'header-base-end-accessory', -}; - -// Test wrapper component that provides scrollY -const TestWrapper: React.FC<{ - children: (scrollYValue: SharedValue) => React.ReactNode; -}> = ({ children }) => { - const scrollYValue = useSharedValue(0); - return <>{children(scrollYValue)}; -}; - -describe('HeaderCollapsibleStandard', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('rendering', () => { - it('renders container with correct testID', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId(TEST_IDS.CONTAINER)).toBeOnTheScreen(); - }); - - it('renders TitleStandard with props when titleStandardProps provided', () => { - const { getByText, getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByText('Send')).toBeOnTheScreen(); - expect(getByText('$4.42')).toBeOnTheScreen(); - expect(getByTestId(TEST_IDS.TITLE_SECTION)).toBeOnTheScreen(); - }); - - it('renders custom titleStandard node when provided', () => { - const { getByText } = render( - - {(scrollYValue) => ( - Custom Title Section} - /> - )} - , - ); - - expect(getByText('Custom Title Section')).toBeOnTheScreen(); - }); - - it('titleStandard takes priority over titleStandardProps', () => { - const { getByText, queryByText } = render( - - {(scrollYValue) => ( - Custom Node} - titleStandardProps={{ title: 'Props Title' }} - /> - )} - , - ); - - expect(getByText('Custom Node')).toBeOnTheScreen(); - expect(queryByText('Props Title')).toBeNull(); - }); - - it('passes testID from titleStandardProps to TitleStandard', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId(TEST_IDS.TITLE_SECTION)).toBeOnTheScreen(); - }); - }); - - describe('back button', () => { - it('renders back button when onBack provided', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId(TEST_IDS.BACK_BUTTON)).toBeOnTheScreen(); - }); - - it('renders back button when backButtonProps provided', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId(TEST_IDS.BACK_BUTTON)).toBeOnTheScreen(); - }); - - it('calls onBack when back button pressed', () => { - const onBack = jest.fn(); - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON)); - - expect(onBack).toHaveBeenCalledTimes(1); - }); - - it('backButtonProps.onPress takes priority over onBack', () => { - const onBack = jest.fn(); - const onPress = jest.fn(); - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON)); - - expect(onPress).toHaveBeenCalledTimes(1); - expect(onBack).not.toHaveBeenCalled(); - }); - }); - - describe('close button', () => { - it('renders close button when onClose provided', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId(TEST_IDS.CLOSE_BUTTON)).toBeOnTheScreen(); - }); - - it('calls onClose when close button pressed', () => { - const onClose = jest.fn(); - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - fireEvent.press(getByTestId(TEST_IDS.CLOSE_BUTTON)); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('closeButtonProps.onPress takes priority over onClose', () => { - const onClose = jest.fn(); - const onPress = jest.fn(); - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - fireEvent.press(getByTestId(TEST_IDS.CLOSE_BUTTON)); - - expect(onPress).toHaveBeenCalledTimes(1); - expect(onClose).not.toHaveBeenCalled(); - }); - }); - - describe('props forwarding', () => { - it('forwards endButtonIconProps to HeaderBase', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId(TEST_IDS.HEADER_BASE_END_ACCESSORY)).toBeOnTheScreen(); - }); - - it('forwards startButtonIconProps directly when provided', () => { - const onPress = jest.fn(); - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId('custom-start-button')).toBeOnTheScreen(); - }); - }); -}); diff --git a/app/component-library/components-temp/HeaderCollapsibleStandard/HeaderCollapsibleStandard.tsx b/app/component-library/components-temp/HeaderCollapsibleStandard/HeaderCollapsibleStandard.tsx deleted file mode 100644 index 523a2701eb5c..000000000000 --- a/app/component-library/components-temp/HeaderCollapsibleStandard/HeaderCollapsibleStandard.tsx +++ /dev/null @@ -1,66 +0,0 @@ -// Third party dependencies. -import React from 'react'; - -// Internal dependencies. -import HeaderCollapsible from '../HeaderCollapsible'; -import TitleStandard from '../TitleStandard'; -import { HeaderCollapsibleStandardProps } from './HeaderCollapsibleStandard.types'; - -/** - * HeaderCollapsibleStandard is a collapsing header component that combines - * HeaderCollapsible with TitleStandard as the expanded content. - * - * @example - * ```tsx - * const { onScroll, scrollY, expandedHeight, setExpandedHeight } = useHeaderCollapsible(); - * - * return ( - * - * - * - * - * - * - * ); - * ``` - */ -const HeaderCollapsibleStandard: React.FC = ({ - titleStandard, - titleStandardProps, - ...props -}) => { - // Render title section content - const renderExpandedContent = () => { - if (titleStandard) { - return titleStandard; - } - if (titleStandardProps) { - return ( - - ); - } - return null; - }; - - return ( - - ); -}; - -export default HeaderCollapsibleStandard; diff --git a/app/component-library/components-temp/HeaderCollapsibleStandard/HeaderCollapsibleStandard.types.ts b/app/component-library/components-temp/HeaderCollapsibleStandard/HeaderCollapsibleStandard.types.ts deleted file mode 100644 index a51cf70ea3b9..000000000000 --- a/app/component-library/components-temp/HeaderCollapsibleStandard/HeaderCollapsibleStandard.types.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Third party dependencies. -import { ReactNode } from 'react'; - -// Internal dependencies. -import { HeaderCollapsibleProps } from '../HeaderCollapsible/HeaderCollapsible.types'; -import { TitleStandardProps } from '../TitleStandard/TitleStandard.types'; - -/** - * HeaderCollapsibleStandard component props. - */ -export interface HeaderCollapsibleStandardProps - extends Omit { - /** - * Custom node to render in the expanded content section. - * If provided, takes priority over titleStandardProps. - */ - titleStandard?: ReactNode; - /** - * Props to pass to the TitleStandard component. - * Only used if titleStandard is not provided. - */ - titleStandardProps?: TitleStandardProps; -} diff --git a/app/component-library/components-temp/HeaderCollapsibleStandard/index.ts b/app/component-library/components-temp/HeaderCollapsibleStandard/index.ts deleted file mode 100644 index 30f840763f82..000000000000 --- a/app/component-library/components-temp/HeaderCollapsibleStandard/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { default } from './HeaderCollapsibleStandard'; -export type { HeaderCollapsibleStandardProps } from './HeaderCollapsibleStandard.types'; - -// Re-export hook and types from HeaderCollapsible for convenience -export { - useHeaderCollapsible, - type UseHeaderCollapsibleOptions, - type UseHeaderCollapsibleReturn, -} from '../HeaderCollapsible'; diff --git a/app/component-library/components-temp/HeaderCollapsibleSubpage/HeaderCollapsibleSubpage.stories.tsx b/app/component-library/components-temp/HeaderCollapsibleSubpage/HeaderCollapsibleSubpage.stories.tsx deleted file mode 100644 index f73de83dc322..000000000000 --- a/app/component-library/components-temp/HeaderCollapsibleSubpage/HeaderCollapsibleSubpage.stories.tsx +++ /dev/null @@ -1,244 +0,0 @@ -/* eslint-disable no-console */ -import React, { useState, useCallback } from 'react'; -import { - View, - ScrollView, - NativeSyntheticEvent, - NativeScrollEvent, -} from 'react-native'; - -import { - Box, - Text, - TextVariant, - IconName, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { useSharedValue, SharedValue } from 'react-native-reanimated'; - -import { AvatarSize } from '../../components/Avatars/Avatar/Avatar.types'; -import AvatarToken from '../../components/Avatars/Avatar/variants/AvatarToken'; -import { SAMPLE_AVATARTOKEN_PROPS } from '../../components/Avatars/Avatar/variants/AvatarToken/AvatarToken.constants'; - -import HeaderCollapsibleSubpage from './HeaderCollapsibleSubpage'; - -const HeaderCollapsibleSubpageMeta = { - title: 'Components Temp / HeaderCollapsibleSubpage', - component: HeaderCollapsibleSubpage, - argTypes: { - title: { - control: 'text', - }, - subtitle: { - control: 'text', - }, - twClassName: { - control: 'text', - }, - }, -}; - -export default HeaderCollapsibleSubpageMeta; - -const SampleContent = ({ itemCount = 20 }: { itemCount?: number }) => ( - <> - {Array.from({ length: itemCount }).map((_, index) => ( - - Item {index + 1} - - This is sample content to demonstrate scrolling behavior. - - - ))} - -); - -interface ScrollableStoryContainerProps { - children: (props: { - scrollYValue: SharedValue; - setExpandedHeight: (h: number) => void; - }) => React.ReactNode; -} - -const ScrollableStoryContainer = ({ - children, -}: ScrollableStoryContainerProps) => { - const tw = useTailwind(); - const scrollYValue = useSharedValue(0); - const [expandedHeight, setExpandedHeight] = useState(140); - - const onScroll = useCallback( - (event: NativeSyntheticEvent) => { - scrollYValue.value = event.nativeEvent.contentOffset.y; - }, - [scrollYValue], - ); - - return ( - - {children({ scrollYValue, setExpandedHeight })} - - - - - ); -}; - -export const Default = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - titleSubpageProps={{ - title: 'Token Name', - bottomLabel: '$1,234.56', - }} - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const WithStartAccessory = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - titleSubpageProps={{ - startAccessory: ( - - ), - title: 'Wrapped Ethereum', - bottomLabel: '$3,456.78', - }} - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const WithSubtitle = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - titleSubpageProps={{ - startAccessory: ( - - ), - title: 'Wrapped Ethereum', - bottomLabel: '$3,456.78', - }} - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const OnClose = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Close pressed')} - titleSubpageProps={{ - title: 'Token Name', - bottomLabel: '$1,234.56', - }} - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const BackAndClose = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - onClose={() => console.log('Close pressed')} - titleSubpageProps={{ - startAccessory: ( - - ), - title: 'Wrapped Ethereum', - bottomLabel: '$3,456.78', - }} - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const EndButtonIconProps = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - endButtonIconProps={[ - { - iconName: IconName.Close, - onPress: () => console.log('Close pressed'), - }, - ]} - titleSubpageProps={{ - title: 'Token Name', - bottomLabel: '$1,234.56', - }} - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; - -export const CustomTitleSubpage = { - render: () => ( - - {({ scrollYValue, setExpandedHeight }) => ( - console.log('Back pressed')} - titleSubpage={ - - Custom Title Section - - This is a completely custom expanded content section - - - } - scrollY={scrollYValue} - onExpandedHeightChange={setExpandedHeight} - /> - )} - - ), -}; diff --git a/app/component-library/components-temp/HeaderCollapsibleSubpage/HeaderCollapsibleSubpage.test.tsx b/app/component-library/components-temp/HeaderCollapsibleSubpage/HeaderCollapsibleSubpage.test.tsx deleted file mode 100644 index 29ee27f3d6c1..000000000000 --- a/app/component-library/components-temp/HeaderCollapsibleSubpage/HeaderCollapsibleSubpage.test.tsx +++ /dev/null @@ -1,339 +0,0 @@ -// Third party dependencies. -import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; -import { useSharedValue, SharedValue } from 'react-native-reanimated'; -import { Text, IconName } from '@metamask/design-system-react-native'; - -// Internal dependencies. -import HeaderCollapsibleSubpage from './HeaderCollapsibleSubpage'; - -// Mock react-native-reanimated -jest.mock('react-native-reanimated', () => { - const Reanimated = jest.requireActual('react-native-reanimated/mock'); - Reanimated.useSharedValue = jest.fn((initial) => ({ - value: initial, - })); - Reanimated.useAnimatedStyle = jest.fn((fn) => fn()); - Reanimated.interpolate = jest.fn( - (_value, _inputRange, outputRange) => outputRange[0], - ); - Reanimated.Extrapolation = { CLAMP: 'clamp' }; - return Reanimated; -}); - -// Mock react-native-safe-area-context -jest.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }), -})); - -const TEST_IDS = { - CONTAINER: 'header-collapsible-subpage-container', - TITLE_SECTION: 'header-collapsible-subpage-title-section', - BACK_BUTTON: 'header-collapsible-subpage-back-button', - CLOSE_BUTTON: 'header-collapsible-subpage-close-button', - HEADER_BASE_END_ACCESSORY: 'header-base-end-accessory', -}; - -// Test wrapper component that provides scrollY -const TestWrapper: React.FC<{ - children: (scrollYValue: SharedValue) => React.ReactNode; -}> = ({ children }) => { - const scrollYValue = useSharedValue(0); - return <>{children(scrollYValue)}; -}; - -describe('HeaderCollapsibleSubpage', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('rendering', () => { - it('renders container with correct testID', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId(TEST_IDS.CONTAINER)).toBeOnTheScreen(); - }); - - it('renders TitleSubpage with props when titleSubpageProps provided', () => { - const { getByText, getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByText('Token Name')).toBeOnTheScreen(); - expect(getByText('$1,234.56')).toBeOnTheScreen(); - expect(getByTestId(TEST_IDS.TITLE_SECTION)).toBeOnTheScreen(); - }); - - it('renders custom titleSubpage node when provided', () => { - const { getByText } = render( - - {(scrollYValue) => ( - Custom Title Section} - /> - )} - , - ); - - expect(getByText('Custom Title Section')).toBeOnTheScreen(); - }); - - it('titleSubpage takes priority over titleSubpageProps', () => { - const { getByText, queryByText } = render( - - {(scrollYValue) => ( - Custom Node} - titleSubpageProps={{ title: 'Props Title' }} - /> - )} - , - ); - - expect(getByText('Custom Node')).toBeOnTheScreen(); - expect(queryByText('Props Title')).toBeNull(); - }); - - it('passes testID from titleSubpageProps to TitleSubpage', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId(TEST_IDS.TITLE_SECTION)).toBeOnTheScreen(); - }); - }); - - describe('back button', () => { - it('renders back button when onBack provided', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId(TEST_IDS.BACK_BUTTON)).toBeOnTheScreen(); - }); - - it('renders back button when backButtonProps provided', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId(TEST_IDS.BACK_BUTTON)).toBeOnTheScreen(); - }); - - it('calls onBack when back button pressed', () => { - const onBack = jest.fn(); - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON)); - - expect(onBack).toHaveBeenCalledTimes(1); - }); - - it('backButtonProps.onPress takes priority over onBack', () => { - const onBack = jest.fn(); - const onPress = jest.fn(); - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON)); - - expect(onPress).toHaveBeenCalledTimes(1); - expect(onBack).not.toHaveBeenCalled(); - }); - }); - - describe('close button', () => { - it('renders close button when onClose provided', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId(TEST_IDS.CLOSE_BUTTON)).toBeOnTheScreen(); - }); - - it('calls onClose when close button pressed', () => { - const onClose = jest.fn(); - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - fireEvent.press(getByTestId(TEST_IDS.CLOSE_BUTTON)); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('closeButtonProps.onPress takes priority over onClose', () => { - const onClose = jest.fn(); - const onPress = jest.fn(); - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - fireEvent.press(getByTestId(TEST_IDS.CLOSE_BUTTON)); - - expect(onPress).toHaveBeenCalledTimes(1); - expect(onClose).not.toHaveBeenCalled(); - }); - }); - - describe('props forwarding', () => { - it('forwards endButtonIconProps to HeaderBase', () => { - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId(TEST_IDS.HEADER_BASE_END_ACCESSORY)).toBeOnTheScreen(); - }); - - it('forwards startButtonIconProps directly when provided', () => { - const onPress = jest.fn(); - const { getByTestId } = render( - - {(scrollYValue) => ( - - )} - , - ); - - expect(getByTestId('custom-start-button')).toBeOnTheScreen(); - }); - }); -}); diff --git a/app/component-library/components-temp/HeaderCollapsibleSubpage/HeaderCollapsibleSubpage.tsx b/app/component-library/components-temp/HeaderCollapsibleSubpage/HeaderCollapsibleSubpage.tsx deleted file mode 100644 index a7cc9f30dd83..000000000000 --- a/app/component-library/components-temp/HeaderCollapsibleSubpage/HeaderCollapsibleSubpage.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// Third party dependencies. -import React from 'react'; - -// Internal dependencies. -import HeaderCollapsible from '../HeaderCollapsible'; -import TitleSubpage from '../TitleSubpage'; -import { HeaderCollapsibleSubpageProps } from './HeaderCollapsibleSubpage.types'; - -/** - * HeaderCollapsibleSubpage is a collapsing header component that combines - * HeaderCollapsible with TitleSubpage as the expanded content. - * - * @example - * ```tsx - * const { onScroll, scrollY, expandedHeight, setExpandedHeight } = useHeaderCollapsible(); - * - * return ( - * - * , - * title: "Token Name", - * bottomLabel: "$1,234.56", - * }} - * scrollY={scrollY} - * onExpandedHeightChange={setExpandedHeight} - * /> - * - * - * - * - * ); - * ``` - */ -const HeaderCollapsibleSubpage: React.FC = ({ - titleSubpage, - titleSubpageProps, - ...props -}) => { - // Render title section content - const renderExpandedContent = () => { - if (titleSubpage) { - return titleSubpage; - } - if (titleSubpageProps) { - return ( - - ); - } - return null; - }; - - return ( - - ); -}; - -export default HeaderCollapsibleSubpage; diff --git a/app/component-library/components-temp/HeaderCollapsibleSubpage/HeaderCollapsibleSubpage.types.ts b/app/component-library/components-temp/HeaderCollapsibleSubpage/HeaderCollapsibleSubpage.types.ts deleted file mode 100644 index 173b1b104d67..000000000000 --- a/app/component-library/components-temp/HeaderCollapsibleSubpage/HeaderCollapsibleSubpage.types.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Third party dependencies. -import { ReactNode } from 'react'; - -// Internal dependencies. -import { HeaderCollapsibleProps } from '../HeaderCollapsible/HeaderCollapsible.types'; -import { TitleSubpageProps } from '../TitleSubpage/TitleSubpage.types'; - -/** - * HeaderCollapsibleSubpage component props. - */ -export interface HeaderCollapsibleSubpageProps - extends Omit { - /** - * Custom node to render in the expanded content section. - * If provided, takes priority over titleSubpageProps. - */ - titleSubpage?: ReactNode; - /** - * Props to pass to the TitleSubpage component. - * Only used if titleSubpage is not provided. - */ - titleSubpageProps?: TitleSubpageProps; -} diff --git a/app/component-library/components-temp/HeaderCollapsibleSubpage/index.ts b/app/component-library/components-temp/HeaderCollapsibleSubpage/index.ts deleted file mode 100644 index fbd329302eea..000000000000 --- a/app/component-library/components-temp/HeaderCollapsibleSubpage/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { default } from './HeaderCollapsibleSubpage'; -export type { HeaderCollapsibleSubpageProps } from './HeaderCollapsibleSubpage.types'; - -// Re-export hook and types from HeaderCollapsible for convenience -export { - useHeaderCollapsible, - type UseHeaderCollapsibleOptions, - type UseHeaderCollapsibleReturn, -} from '../HeaderCollapsible'; diff --git a/app/component-library/components-temp/HeaderCompactSearch/index.ts b/app/component-library/components-temp/HeaderCompactSearch/index.ts deleted file mode 100644 index 30acb53ffcb8..000000000000 --- a/app/component-library/components-temp/HeaderCompactSearch/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { default } from './HeaderCompactSearch'; -export { - HeaderCompactSearchVariant, - type HeaderCompactSearchProps, - type HeaderCompactSearchScreenProps, - type HeaderCompactSearchInlineProps, -} from './HeaderCompactSearch.types'; diff --git a/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.test.tsx b/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.test.tsx index b85e360c1935..68b47cf89609 100644 --- a/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.test.tsx +++ b/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.test.tsx @@ -107,6 +107,47 @@ describe('HeaderCompactStandard', () => { expect(getByText('Main Title')).toBeOnTheScreen(); expect(getByText('Supporting Text')).toBeOnTheScreen(); }); + + it('renders title when passed as React node', () => { + const TITLE_NODE_TEST_ID = 'custom-title-node'; + const { getByTestId, getByText } = render( + Custom Title Node} + />, + ); + + expect(getByTestId(TITLE_NODE_TEST_ID)).toBeOnTheScreen(); + expect(getByText('Custom Title Node')).toBeOnTheScreen(); + }); + + it('renders subtitle when passed as React node', () => { + const SUBTITLE_NODE_TEST_ID = 'custom-subtitle-node'; + const { getByTestId, getByText } = render( + Custom Subtitle Node + } + />, + ); + + expect(getByTestId(SUBTITLE_NODE_TEST_ID)).toBeOnTheScreen(); + expect(getByText('Custom Subtitle Node')).toBeOnTheScreen(); + }); + + it('renders both title and subtitle as React nodes', () => { + const TITLE_NODE_TEST_ID = 'title-node'; + const SUBTITLE_NODE_TEST_ID = 'subtitle-node'; + const { getByTestId } = render( + Node Title} + subtitle={Node Subtitle} + />, + ); + + expect(getByTestId(TITLE_NODE_TEST_ID)).toBeOnTheScreen(); + expect(getByTestId(SUBTITLE_NODE_TEST_ID)).toBeOnTheScreen(); + }); }); describe('back button', () => { diff --git a/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.tsx b/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.tsx index 5d689273959b..81783d163aae 100644 --- a/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.tsx +++ b/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.tsx @@ -97,22 +97,31 @@ const HeaderCompactStandard: React.FC = ({ if (title) { return ( - - {title} - - {subtitle && ( + {typeof title === 'string' ? ( - {subtitle} + {title} + ) : ( + title + )} + {subtitle && ( + + {typeof subtitle === 'string' ? ( + + {subtitle} + + ) : ( + subtitle + )} + )} ); diff --git a/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.types.ts b/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.types.ts index 04a813f4f8bc..d3393429276a 100644 --- a/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.types.ts +++ b/app/component-library/components-temp/HeaderCompactStandard/HeaderCompactStandard.types.ts @@ -1,4 +1,5 @@ // External dependencies. +import React from 'react'; import { ButtonIconProps, TextProps, @@ -12,24 +13,28 @@ import { HeaderBaseProps } from '../../components/HeaderBase'; */ export interface HeaderCompactStandardProps extends HeaderBaseProps { /** - * Title text to display in the header. + * Title to display in the header. Can be a string or a React node. * Used as children if children prop is not provided. - * Rendered with TextVariant.BodyMd and FontWeight.Bold by default. + * When string: rendered with TextVariant.BodyMd and FontWeight.Bold by default; titleProps apply. + * When node: rendered as-is; titleProps are not applied. */ - title?: string; + title?: string | React.ReactNode; /** * Additional props to pass to the title Text component. * Props are spread to the Text component and can override default values. + * Only applied when title is a string. */ titleProps?: Partial; /** - * Subtitle text to display below the title. - * Rendered with TextVariant.BodySm and TextColor.TextAlternative by default. + * Subtitle to display below the title. Can be a string or a React node. + * When string: rendered with TextVariant.BodySm and TextColor.TextAlternative by default; subtitleProps apply. + * When node: rendered inside the same -mt-0.5 container; subtitleProps are not applied. */ - subtitle?: string; + subtitle?: string | React.ReactNode; /** * Additional props to pass to the subtitle Text component. * Props are spread to the Text component and can override default values. + * Only applied when subtitle is a string. */ subtitleProps?: Partial; /** diff --git a/app/component-library/components-temp/HeaderRoot/HeaderRoot.stories.tsx b/app/component-library/components-temp/HeaderRoot/HeaderRoot.stories.tsx new file mode 100644 index 000000000000..a21eefa20d84 --- /dev/null +++ b/app/component-library/components-temp/HeaderRoot/HeaderRoot.stories.tsx @@ -0,0 +1,108 @@ +/* eslint-disable no-console */ +import React from 'react'; + +import { + Box, + Text, + TextVariant, + Icon, + IconName, + IconColor, +} from '@metamask/design-system-react-native'; + +import HeaderRoot from './HeaderRoot'; + +const HeaderRootMeta = { + title: 'Components Temp / HeaderRoot', + component: HeaderRoot, + argTypes: { + title: { + control: 'text', + }, + twClassName: { + control: 'text', + }, + }, +}; + +export default HeaderRootMeta; + +export const Default = { + args: { + title: 'Header Title', + }, +}; + +export const WithTitleAccessory = { + render: () => ( + + } + /> + ), +}; + +export const WithChildren = { + render: () => ( + console.log('Close pressed'), + }, + ]} + > + + Custom Title + Subtitle text + + + ), +}; + +export const WithEndAccessory = { + render: () => ( + Custom end} + /> + ), +}; + +export const WithEndButtonIconProps = { + render: () => ( + console.log('Close pressed'), + }, + ]} + /> + ), +}; + +export const MultipleEndButtons = { + render: () => ( + console.log('Search pressed'), + }, + { + iconName: IconName.Close, + onPress: () => console.log('Close pressed'), + }, + ]} + /> + ), +}; diff --git a/app/component-library/components-temp/HeaderRoot/HeaderRoot.test.tsx b/app/component-library/components-temp/HeaderRoot/HeaderRoot.test.tsx new file mode 100644 index 000000000000..b196d6232ddd --- /dev/null +++ b/app/component-library/components-temp/HeaderRoot/HeaderRoot.test.tsx @@ -0,0 +1,319 @@ +// Third party dependencies. +import React from 'react'; +import { Text } from 'react-native'; +import { render, fireEvent } from '@testing-library/react-native'; + +// External dependencies. +import { IconName } from '@metamask/design-system-react-native'; + +// Internal dependencies. +import HeaderRoot from './HeaderRoot'; + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }), +})); + +const CONTAINER_TEST_ID = 'header-root-container'; +const LEFT_CHILDREN_TEST_ID = 'header-root-left-children'; +const END_ACCESSORY_TEST_ID = 'header-root-end-accessory'; +const END_BUTTON_TEST_ID = 'header-root-end-button'; + +describe('HeaderRoot', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders container with testID when provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CONTAINER_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders title in left section when title provided and no children', () => { + const { getByText } = render(); + + expect(getByText('Test Title')).toBeOnTheScreen(); + }); + + it('renders title with testID when provided via titleProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('header-root-title')).toBeOnTheScreen(); + }); + + it('renders titleAccessory in title row when no children', () => { + const { getByTestId, getByText } = render( + Accessory} + />, + ); + + expect(getByText('Title')).toBeOnTheScreen(); + expect(getByTestId(LEFT_CHILDREN_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders only titleAccessory when title is empty and children not provided', () => { + const { getByTestId } = render( + Only Accessory + } + />, + ); + + expect(getByTestId(LEFT_CHILDREN_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders only titleAccessory when title is undefined', () => { + const { getByTestId } = render( + Accessory Only + } + />, + ); + + expect(getByTestId(LEFT_CHILDREN_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders children in left section when children provided', () => { + const { getByTestId, queryByText } = render( + + Custom Content + , + ); + + expect(getByTestId(LEFT_CHILDREN_TEST_ID)).toBeOnTheScreen(); + expect(getByTestId(CONTAINER_TEST_ID)).toBeOnTheScreen(); + expect(queryByText('Ignored Title')).not.toBeOnTheScreen(); + }); + + it('prioritizes children over title when both provided', () => { + const { getByText, queryByText } = render( + + Children Text + , + ); + + expect(getByText('Children Text')).toBeOnTheScreen(); + expect(queryByText('Title Text')).not.toBeOnTheScreen(); + }); + + it('renders title row when children is null', () => { + const { getByText } = render( + {null}, + ); + + expect(getByText('Title When Children Null')).toBeOnTheScreen(); + }); + + it('renders nothing in left section when no children and no title or titleAccessory', () => { + const { getByTestId, queryByText } = render( + , + ); + + expect(getByTestId(CONTAINER_TEST_ID)).toBeOnTheScreen(); + expect(queryByText('Title')).not.toBeOnTheScreen(); + }); + }); + + describe('end section', () => { + it('renders endAccessory when provided', () => { + const { getByTestId } = render( + End Content} + />, + ); + + expect(getByTestId(END_ACCESSORY_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders single ButtonIcon when endButtonIconProps has one item', () => { + const onPressMock = jest.fn(); + const { getByTestId } = render( + , + ); + + expect(getByTestId(END_BUTTON_TEST_ID)).toBeOnTheScreen(); + }); + + it('calls onPress when end ButtonIcon is pressed', () => { + const onPressMock = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(END_BUTTON_TEST_ID)); + + expect(onPressMock).toHaveBeenCalledTimes(1); + }); + + it('renders multiple ButtonIcons when endButtonIconProps has multiple items', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('end-button-close')).toBeOnTheScreen(); + expect(getByTestId('end-button-search')).toBeOnTheScreen(); + }); + + it('does not render end section when no endAccessory and no endButtonIconProps', () => { + const { queryByTestId } = render(); + + expect(queryByTestId(END_ACCESSORY_TEST_ID)).toBeNull(); + expect(queryByTestId(END_BUTTON_TEST_ID)).toBeNull(); + }); + + it('does not render end ButtonIcons when endButtonIconProps is empty array', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(END_BUTTON_TEST_ID)).toBeNull(); + }); + + it('prioritizes endAccessory over endButtonIconProps', () => { + const { getByTestId, queryByTestId } = render( + Custom End} + endButtonIconProps={[ + { + iconName: IconName.Close, + onPress: jest.fn(), + testID: END_BUTTON_TEST_ID, + }, + ]} + />, + ); + + expect(getByTestId(END_ACCESSORY_TEST_ID)).toBeOnTheScreen(); + expect(queryByTestId(END_BUTTON_TEST_ID)).toBeNull(); + }); + }); + + describe('includesTopInset', () => { + it('applies marginTop style when includesTopInset is true', () => { + const { getByTestId } = render( + , + ); + + const container = getByTestId(CONTAINER_TEST_ID); + + expect(container.props.style).toEqual( + expect.arrayContaining([ + expect.anything(), + expect.objectContaining({ marginTop: 44 }), + ]), + ); + }); + + it('does not apply marginTop when includesTopInset is false', () => { + const { getByTestId } = render( + , + ); + + const container = getByTestId(CONTAINER_TEST_ID); + const marginTopStyle = container.props.style?.find( + (s: object) => s && typeof s === 'object' && 'marginTop' in s, + ); + + expect(marginTopStyle).toBeUndefined(); + }); + }); + + describe('style and twClassName', () => { + it('applies custom style to container', () => { + const customStyle = { backgroundColor: 'red' }; + const { getByTestId } = render( + , + ); + + const container = getByTestId(CONTAINER_TEST_ID); + + expect(container.props.style).toContainEqual(customStyle); + }); + + it('merges twClassName with base styles', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CONTAINER_TEST_ID)).toBeOnTheScreen(); + }); + }); + + describe('titleProps', () => { + it('spreads titleProps to title Text when title is set', () => { + const { getByTestId } = render( + , + ); + + const title = getByTestId('title-with-props'); + + expect(title).toBeOnTheScreen(); + expect(title.props.accessibilityLabel).toBe('Main title'); + }); + }); +}); diff --git a/app/component-library/components-temp/HeaderRoot/HeaderRoot.tsx b/app/component-library/components-temp/HeaderRoot/HeaderRoot.tsx new file mode 100644 index 000000000000..589b180e1a58 --- /dev/null +++ b/app/component-library/components-temp/HeaderRoot/HeaderRoot.tsx @@ -0,0 +1,101 @@ +// Third party dependencies. +import React from 'react'; +import { View } from 'react-native'; + +// External dependencies. +import { + Box, + Text, + ButtonIcon, + ButtonIconSize, + TextVariant, + BoxFlexDirection, + BoxAlignItems, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +// Internal dependencies. +import { HeaderRootProps } from './HeaderRoot.types'; + +const HeaderRoot: React.FC = ({ + children, + title, + titleProps, + titleAccessory, + endAccessory, + endButtonIconProps, + includesTopInset = false, + style, + testID, + twClassName, + ...viewProps +}) => { + const tw = useTailwind(); + const insets = useSafeAreaInsets(); + + const renderEndContent = () => { + if (endAccessory) { + return endAccessory; + } + if (endButtonIconProps && endButtonIconProps.length > 0) { + const reversedProps = endButtonIconProps + .map((props, originalIndex) => ({ props, originalIndex })) + .reverse(); + return reversedProps.map(({ props, originalIndex }) => ( + + )); + } + return null; + }; + + const hasEndContent = + endAccessory || (endButtonIconProps && endButtonIconProps.length > 0); + + const renderLeftSection = () => { + if (children != null && children !== undefined) { + return children; + } + if (title != null || titleAccessory != null) { + return ( + + {title != null && title !== '' && ( + + {title} + + )} + {titleAccessory} + + ); + } + return null; + }; + + return ( + + {renderLeftSection()} + {hasEndContent && ( + {renderEndContent()} + )} + + ); +}; + +export default HeaderRoot; diff --git a/app/component-library/components-temp/HeaderRoot/HeaderRoot.types.ts b/app/component-library/components-temp/HeaderRoot/HeaderRoot.types.ts new file mode 100644 index 000000000000..968a4199f5df --- /dev/null +++ b/app/component-library/components-temp/HeaderRoot/HeaderRoot.types.ts @@ -0,0 +1,63 @@ +// Third party dependencies. +import { ViewProps, StyleProp, ViewStyle } from 'react-native'; +import { ReactNode } from 'react'; + +// External dependencies. +import { + ButtonIconProps, + TextProps, +} from '@metamask/design-system-react-native'; + +/** + * HeaderRoot component props. + * Left section renders either children or a title row (mutually exclusive). + * End section matches HeaderBase (endAccessory or endButtonIconProps). + */ +export interface HeaderRootProps extends ViewProps { + /** + * Optional custom content for the left section. + * When provided, title/titleAccessory are not rendered (mutually exclusive). + */ + children?: ReactNode; + /** + * Optional content displayed after the title in the title row. + * Only used when children is not provided and title or titleAccessory is set. + */ + titleAccessory?: ReactNode; + /** + * Optional main title text, rendered with TextVariant.HeadingLg. + * Only used when children is not provided. + */ + title?: string; + /** + * Optional props to pass to the title Text component. + */ + titleProps?: Partial; + /** + * Optional content to be displayed in the end section. + * Takes priority over endButtonIconProps if both are provided. + */ + endAccessory?: ReactNode; + /** + * Optional array of ButtonIcon props to render multiple ButtonIcons as end accessories. + * Rendered in reverse order (first item appears rightmost). + * Only used if endAccessory is not provided. + */ + endButtonIconProps?: ButtonIconProps[]; + /** + * Optional prop to include the top inset so the header is visible below the device safe area. + */ + includesTopInset?: boolean; + /** + * Optional style for the header container. + */ + style?: StyleProp; + /** + * Optional test ID for the header container. + */ + testID?: string; + /** + * Optional Tailwind class names for the header container. + */ + twClassName?: string; +} diff --git a/app/component-library/components-temp/HeaderRoot/index.ts b/app/component-library/components-temp/HeaderRoot/index.ts new file mode 100644 index 000000000000..baa9d3f03ed4 --- /dev/null +++ b/app/component-library/components-temp/HeaderRoot/index.ts @@ -0,0 +1,2 @@ +export { default } from './HeaderRoot'; +export type { HeaderRootProps } from './HeaderRoot.types'; diff --git a/app/component-library/components-temp/HeaderCompactSearch/HeaderCompactSearch.stories.tsx b/app/component-library/components-temp/HeaderSearch/HeaderSearch.stories.tsx similarity index 64% rename from app/component-library/components-temp/HeaderCompactSearch/HeaderCompactSearch.stories.tsx rename to app/component-library/components-temp/HeaderSearch/HeaderSearch.stories.tsx index b8aa3823018c..8c598a4fb700 100644 --- a/app/component-library/components-temp/HeaderCompactSearch/HeaderCompactSearch.stories.tsx +++ b/app/component-library/components-temp/HeaderSearch/HeaderSearch.stories.tsx @@ -1,19 +1,16 @@ /* eslint-disable no-console */ import React, { useState } from 'react'; -import HeaderCompactSearch from './HeaderCompactSearch'; -import { HeaderCompactSearchVariant } from './HeaderCompactSearch.types'; +import HeaderSearch from './HeaderSearch'; +import { HeaderSearchVariant } from './HeaderSearch.types'; -const HeaderCompactSearchMeta = { - title: 'Components Temp / HeaderCompactSearch', - component: HeaderCompactSearch, +const HeaderSearchMeta = { + title: 'Components Temp / HeaderSearch', + component: HeaderSearch, argTypes: { variant: { control: 'select', - options: [ - HeaderCompactSearchVariant.Screen, - HeaderCompactSearchVariant.Inline, - ], + options: [HeaderSearchVariant.Screen, HeaderSearchVariant.Inline], }, twClassName: { control: 'text', @@ -21,13 +18,13 @@ const HeaderCompactSearchMeta = { }, }; -export default HeaderCompactSearchMeta; +export default HeaderSearchMeta; const ScreenStory = () => { const [value, setValue] = useState(''); return ( - console.log('Back pressed')} textFieldSearchProps={{ value, @@ -46,8 +43,8 @@ export const Screen = { const InlineStory = () => { const [value, setValue] = useState(''); return ( - console.log('Cancel pressed')} textFieldSearchProps={{ value, diff --git a/app/component-library/components-temp/HeaderCompactSearch/HeaderCompactSearch.test.tsx b/app/component-library/components-temp/HeaderSearch/HeaderSearch.test.tsx similarity index 78% rename from app/component-library/components-temp/HeaderCompactSearch/HeaderCompactSearch.test.tsx rename to app/component-library/components-temp/HeaderSearch/HeaderSearch.test.tsx index 2facc1e8e03d..42ad489e910e 100644 --- a/app/component-library/components-temp/HeaderCompactSearch/HeaderCompactSearch.test.tsx +++ b/app/component-library/components-temp/HeaderSearch/HeaderSearch.test.tsx @@ -6,8 +6,8 @@ import { render, fireEvent } from '@testing-library/react-native'; import { strings } from '../../../../locales/i18n'; // Internal dependencies. -import HeaderCompactSearch from './HeaderCompactSearch'; -import { HeaderCompactSearchVariant } from './HeaderCompactSearch.types'; +import HeaderSearch from './HeaderSearch'; +import { HeaderSearchVariant } from './HeaderSearch.types'; const mockTextFieldSearchProps = { value: '', @@ -16,7 +16,7 @@ const mockTextFieldSearchProps = { placeholder: 'Search...', }; -describe('HeaderCompactSearch', () => { +describe('HeaderSearch', () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -25,8 +25,8 @@ describe('HeaderCompactSearch', () => { it('renders correctly with screen variant', () => { const onPressBackButton = jest.fn(); const { getByTestId } = render( - { it('renders with testID', () => { const onPressBackButton = jest.fn(); const { getByTestId } = render( - , ); - expect(getByTestId('header-compact-search')).toBeOnTheScreen(); + expect(getByTestId('header-search')).toBeOnTheScreen(); }); it('calls onPressBackButton when back button is pressed', () => { const onPressBackButton = jest.fn(); const { getByTestId } = render( - { it('forwards backButtonProps to ButtonIcon', () => { const onPressBackButton = jest.fn(); const { getByTestId } = render( - { it('renders TextFieldSearch with provided props', () => { const onPressBackButton = jest.fn(); const { getByPlaceholderText } = render( - { it('renders correctly with inline variant', () => { const onPressCancelButton = jest.fn(); const { getByText } = render( - , @@ -114,22 +114,22 @@ describe('HeaderCompactSearch', () => { it('renders with testID', () => { const onPressCancelButton = jest.fn(); const { getByTestId } = render( - , ); - expect(getByTestId('header-compact-search-inline')).toBeOnTheScreen(); + expect(getByTestId('header-search-inline')).toBeOnTheScreen(); }); it('calls onPressCancelButton when cancel button is pressed', () => { const onPressCancelButton = jest.fn(); const { getByTestId } = render( - { it('forwards cancelButtonProps to Button', () => { const onPressCancelButton = jest.fn(); const { getByTestId } = render( - { it('renders TextFieldSearch with provided props', () => { const onPressCancelButton = jest.fn(); const { getByPlaceholderText } = render( - { it('forwards twClassName to container for screen variant', () => { const onPressBackButton = jest.fn(); const { getByTestId } = render( - { it('forwards twClassName to container for inline variant', () => { const onPressCancelButton = jest.fn(); const { getByTestId } = render( - */ -const HeaderCompactSearch: React.FC = (props) => { +const HeaderSearch: React.FC = (props) => { const { variant, textFieldSearchProps, @@ -62,11 +62,11 @@ const HeaderCompactSearch: React.FC = (props) => { const baseTwClassName = 'h-14 flex-row items-center'; - if (variant === HeaderCompactSearchVariant.Screen) { + if (variant === HeaderSearchVariant.Screen) { const { onPressBackButton, backButtonProps } = - props as HeaderCompactSearchScreenProps; + props as HeaderSearchScreenProps; const screenBoxProps = boxProps as Omit< - HeaderCompactSearchScreenProps, + HeaderSearchScreenProps, | 'variant' | 'textFieldSearchProps' | 'twClassName' @@ -98,9 +98,9 @@ const HeaderCompactSearch: React.FC = (props) => { // Inline variant const { onPressCancelButton, cancelButtonProps } = - props as HeaderCompactSearchInlineProps; + props as HeaderSearchInlineProps; const inlineBoxProps = boxProps as Omit< - HeaderCompactSearchInlineProps, + HeaderSearchInlineProps, | 'variant' | 'textFieldSearchProps' | 'twClassName' @@ -133,4 +133,4 @@ const HeaderCompactSearch: React.FC = (props) => { ); }; -export default HeaderCompactSearch; +export default HeaderSearch; diff --git a/app/component-library/components-temp/HeaderCompactSearch/HeaderCompactSearch.types.ts b/app/component-library/components-temp/HeaderSearch/HeaderSearch.types.ts similarity index 67% rename from app/component-library/components-temp/HeaderCompactSearch/HeaderCompactSearch.types.ts rename to app/component-library/components-temp/HeaderSearch/HeaderSearch.types.ts index 37d199527d1e..83b94795dbf5 100644 --- a/app/component-library/components-temp/HeaderCompactSearch/HeaderCompactSearch.types.ts +++ b/app/component-library/components-temp/HeaderSearch/HeaderSearch.types.ts @@ -9,9 +9,9 @@ import { import { TextFieldSearchProps } from '../../components/Form/TextFieldSearch/TextFieldSearch.types'; /** - * Variant enum for HeaderCompactSearch component. + * Variant enum for HeaderSearch component. */ -export enum HeaderCompactSearchVariant { +export enum HeaderSearchVariant { Screen = 'screen', Inline = 'inline', } @@ -19,7 +19,7 @@ export enum HeaderCompactSearchVariant { /** * Base props shared by both variants - extends BoxProps. */ -interface HeaderCompactSearchBaseProps extends Omit { +interface HeaderSearchBaseProps extends Omit { /** * Props to pass to the TextFieldSearch component. */ @@ -30,12 +30,11 @@ interface HeaderCompactSearchBaseProps extends Omit { * Screen variant props. * Renders a back button (ArrowLeft) on the left side. */ -export interface HeaderCompactSearchScreenProps - extends HeaderCompactSearchBaseProps { +export interface HeaderSearchScreenProps extends HeaderSearchBaseProps { /** * The variant of the component. */ - variant: HeaderCompactSearchVariant.Screen; + variant: HeaderSearchVariant.Screen; /** * Callback when the back button is pressed. */ @@ -50,12 +49,11 @@ export interface HeaderCompactSearchScreenProps * Inline variant props. * Renders a cancel button on the right side. */ -export interface HeaderCompactSearchInlineProps - extends HeaderCompactSearchBaseProps { +export interface HeaderSearchInlineProps extends HeaderSearchBaseProps { /** * The variant of the component. */ - variant: HeaderCompactSearchVariant.Inline; + variant: HeaderSearchVariant.Inline; /** * Callback when the cancel button is pressed. */ @@ -67,8 +65,8 @@ export interface HeaderCompactSearchInlineProps } /** - * HeaderCompactSearch component props. + * HeaderSearch component props. */ -export type HeaderCompactSearchProps = - | HeaderCompactSearchScreenProps - | HeaderCompactSearchInlineProps; +export type HeaderSearchProps = + | HeaderSearchScreenProps + | HeaderSearchInlineProps; diff --git a/app/component-library/components-temp/HeaderSearch/index.ts b/app/component-library/components-temp/HeaderSearch/index.ts new file mode 100644 index 000000000000..81ba7adaf70c --- /dev/null +++ b/app/component-library/components-temp/HeaderSearch/index.ts @@ -0,0 +1,7 @@ +export { default } from './HeaderSearch'; +export { + HeaderSearchVariant, + type HeaderSearchProps, + type HeaderSearchScreenProps, + type HeaderSearchInlineProps, +} from './HeaderSearch.types'; diff --git a/app/component-library/components-temp/HeaderStackedStandard/HeaderStackedStandard.stories.tsx b/app/component-library/components-temp/HeaderStackedStandard/HeaderStackedStandard.stories.tsx deleted file mode 100644 index 4e761694fec2..000000000000 --- a/app/component-library/components-temp/HeaderStackedStandard/HeaderStackedStandard.stories.tsx +++ /dev/null @@ -1,142 +0,0 @@ -/* eslint-disable no-console */ -import React from 'react'; - -import { - Box, - Text, - TextVariant, - IconName, -} from '@metamask/design-system-react-native'; - -import HeaderStackedStandard from './HeaderStackedStandard'; - -const HeaderStackedStandardMeta = { - title: 'Components Temp / HeaderStackedStandard', - component: HeaderStackedStandard, - argTypes: { - twClassName: { - control: 'text', - }, - }, -}; - -export default HeaderStackedStandardMeta; - -export const Default = { - args: { - titleStandardProps: { - topLabel: 'Send', - title: '$4.42', - }, - }, -}; - -export const OnBack = { - render: () => ( - console.log('Back pressed')} - titleStandardProps={{ - topLabel: 'Send', - title: '$4.42', - }} - /> - ), -}; - -export const WithBottomLabel = { - render: () => ( - console.log('Back pressed')} - titleStandardProps={{ - topLabel: 'Send', - title: '$4.42', - bottomLabel: '0.002 ETH', - }} - /> - ), -}; - -export const OnClose = { - render: () => ( - console.log('Close pressed')} - titleStandardProps={{ - topLabel: 'Send', - title: '$4.42', - }} - /> - ), -}; - -export const BackAndClose = { - render: () => ( - console.log('Back pressed')} - onClose={() => console.log('Close pressed')} - titleStandardProps={{ - topLabel: 'Send', - title: '$4.42', - }} - /> - ), -}; - -export const EndButtonIconProps = { - render: () => ( - console.log('Back pressed')} - endButtonIconProps={[ - { - iconName: IconName.Close, - onPress: () => console.log('Close pressed'), - }, - ]} - titleStandardProps={{ - topLabel: 'Send', - title: '$4.42', - }} - /> - ), -}; - -export const BackButtonProps = { - render: () => ( - console.log('Custom back pressed'), - }} - titleStandardProps={{ - topLabel: 'Receive', - title: '$1,234.56', - }} - /> - ), -}; - -export const TitleStandard = { - render: () => ( - console.log('Back pressed')} - titleStandard={ - - Custom Title Section - - This is a completely custom title section - - - } - /> - ), -}; - -export const NoBackButton = { - render: () => ( - - ), -}; diff --git a/app/component-library/components-temp/HeaderStackedStandard/HeaderStackedStandard.test.tsx b/app/component-library/components-temp/HeaderStackedStandard/HeaderStackedStandard.test.tsx deleted file mode 100644 index fff7569e17f1..000000000000 --- a/app/component-library/components-temp/HeaderStackedStandard/HeaderStackedStandard.test.tsx +++ /dev/null @@ -1,303 +0,0 @@ -// Third party dependencies. -import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; -import { Text, IconName } from '@metamask/design-system-react-native'; - -// Internal dependencies. -import HeaderStackedStandard from './HeaderStackedStandard'; - -const TEST_IDS = { - CONTAINER: 'header-stacked-standard-container', - TITLE_SECTION: 'header-stacked-standard-title-section', - BACK_BUTTON: 'header-stacked-standard-back-button', - CLOSE_BUTTON: 'header-stacked-standard-close-button', - TITLE_STANDARD: 'title-standard', - HEADER_BASE_END_ACCESSORY: 'header-base-end-accessory', -}; - -describe('HeaderStackedStandard', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('rendering', () => { - it('renders container with correct testID', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId(TEST_IDS.CONTAINER)).toBeOnTheScreen(); - }); - - it('renders title section when titleStandardProps provided', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId(TEST_IDS.TITLE_SECTION)).toBeOnTheScreen(); - }); - - it('renders TitleStandard with props when titleStandardProps provided', () => { - const { getByText, getByTestId } = render( - , - ); - - expect(getByText('Send')).toBeOnTheScreen(); - expect(getByText('$4.42')).toBeOnTheScreen(); - expect(getByTestId(TEST_IDS.TITLE_STANDARD)).toBeOnTheScreen(); - }); - - it('renders custom titleStandard node when provided', () => { - const { getByText } = render( - Custom Title Section} - />, - ); - - expect(getByText('Custom Title Section')).toBeOnTheScreen(); - }); - - it('titleStandard takes priority over titleStandardProps', () => { - const { getByText, queryByText } = render( - Custom Node} - titleStandardProps={{ title: 'Props Title' }} - />, - ); - - expect(getByText('Custom Node')).toBeOnTheScreen(); - expect(queryByText('Props Title')).toBeNull(); - }); - - it('does not render title section when neither titleStandard nor titleStandardProps provided', () => { - const { queryByTestId } = render( - , - ); - - expect(queryByTestId(TEST_IDS.TITLE_SECTION)).toBeNull(); - }); - }); - - describe('back button', () => { - it('renders back button when onBack provided', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId(TEST_IDS.BACK_BUTTON)).toBeOnTheScreen(); - }); - - it('renders back button when backButtonProps provided', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId(TEST_IDS.BACK_BUTTON)).toBeOnTheScreen(); - }); - - it('calls onBack when back button pressed', () => { - const onBack = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON)); - - expect(onBack).toHaveBeenCalledTimes(1); - }); - - it('calls backButtonProps.onPress when back button pressed', () => { - const onPress = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON)); - - expect(onPress).toHaveBeenCalledTimes(1); - }); - - it('backButtonProps.onPress takes priority over onBack', () => { - const onBack = jest.fn(); - const onPress = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON)); - - expect(onPress).toHaveBeenCalledTimes(1); - expect(onBack).not.toHaveBeenCalled(); - }); - - it('does not render back button when neither onBack nor backButtonProps provided', () => { - const { queryByLabelText } = render( - , - ); - - expect(queryByLabelText('Arrow Left')).toBeNull(); - }); - }); - - describe('close button', () => { - it('renders close button when onClose provided', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId(TEST_IDS.CLOSE_BUTTON)).toBeOnTheScreen(); - }); - - it('renders close button when closeButtonProps provided', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId(TEST_IDS.CLOSE_BUTTON)).toBeOnTheScreen(); - }); - - it('calls onClose when close button pressed', () => { - const onClose = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId(TEST_IDS.CLOSE_BUTTON)); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('calls closeButtonProps.onPress when close button pressed', () => { - const onPress = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId(TEST_IDS.CLOSE_BUTTON)); - - expect(onPress).toHaveBeenCalledTimes(1); - }); - - it('closeButtonProps.onPress takes priority over onClose', () => { - const onClose = jest.fn(); - const onPress = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId(TEST_IDS.CLOSE_BUTTON)); - - expect(onPress).toHaveBeenCalledTimes(1); - expect(onClose).not.toHaveBeenCalled(); - }); - - it('does not render close button when neither onClose nor closeButtonProps provided', () => { - const { queryByLabelText } = render( - , - ); - - expect(queryByLabelText('Close')).toBeNull(); - }); - }); - - describe('props forwarding', () => { - it('forwards endButtonIconProps to HeaderBase', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId(TEST_IDS.HEADER_BASE_END_ACCESSORY)).toBeOnTheScreen(); - }); - - it('accepts custom testID', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId('custom-header')).toBeOnTheScreen(); - }); - - it('forwards startButtonIconProps directly when provided', () => { - const onPress = jest.fn(); - const { getByTestId } = render( - , - ); - - expect(getByTestId('custom-start-button')).toBeOnTheScreen(); - }); - }); -}); diff --git a/app/component-library/components-temp/HeaderStackedStandard/HeaderStackedStandard.tsx b/app/component-library/components-temp/HeaderStackedStandard/HeaderStackedStandard.tsx deleted file mode 100644 index f07b8b5c8631..000000000000 --- a/app/component-library/components-temp/HeaderStackedStandard/HeaderStackedStandard.tsx +++ /dev/null @@ -1,120 +0,0 @@ -// Third party dependencies. -import React, { useMemo } from 'react'; - -// External dependencies. -import { - Box, - IconName, - ButtonIconProps, -} from '@metamask/design-system-react-native'; - -// Internal dependencies. -import HeaderBase from '../../components/HeaderBase'; -import TitleStandard from '../TitleStandard'; -import { HeaderStackedStandardProps } from './HeaderStackedStandard.types'; - -/** - * HeaderStackedStandard is a header component that combines HeaderBase (with back button) - * on top and a TitleStandard section below it. - * - * @example - * ```tsx - * - * ``` - */ -const HeaderStackedStandard: React.FC = ({ - onBack, - backButtonProps, - onClose, - closeButtonProps, - titleStandard, - titleStandardProps, - startButtonIconProps, - endButtonIconProps, - twClassName = '', - testID, - titleSectionTestID, - ...headerBaseProps -}) => { - // Build startButtonIconProps with back button if onBack or backButtonProps is provided - const resolvedStartButtonIconProps = useMemo(() => { - if (startButtonIconProps) { - // If startButtonIconProps is explicitly provided, use it as-is - return startButtonIconProps; - } - - if (onBack || backButtonProps) { - const backProps: ButtonIconProps = { - iconName: IconName.ArrowLeft, - ...(backButtonProps || {}), - onPress: backButtonProps?.onPress ?? onBack, - }; - return backProps; - } - - return undefined; - }, [startButtonIconProps, onBack, backButtonProps]); - - // Build endButtonIconProps with close button if onClose or closeButtonProps is provided - const resolvedEndButtonIconProps = useMemo(() => { - const props: ButtonIconProps[] = []; - - if (onClose || closeButtonProps) { - const closeProps: ButtonIconProps = { - iconName: IconName.Close, - ...(closeButtonProps || {}), - onPress: closeButtonProps?.onPress ?? onClose, - }; - props.push(closeProps); - } - - if (endButtonIconProps) { - props.push(...endButtonIconProps); - } - - return props.length > 0 ? props : undefined; - }, [endButtonIconProps, onClose, closeButtonProps]); - - // Render title section content - const renderTitleSection = () => { - if (titleStandard) { - return titleStandard; - } - if (titleStandardProps) { - return ( - - ); - } - return null; - }; - - const hasTitleSection = titleStandard || titleStandardProps; - - return ( - - {/* HeaderBase section */} - - - {/* TitleStandard section */} - {hasTitleSection && ( - {renderTitleSection()} - )} - - ); -}; - -export default HeaderStackedStandard; diff --git a/app/component-library/components-temp/HeaderStackedStandard/HeaderStackedStandard.types.ts b/app/component-library/components-temp/HeaderStackedStandard/HeaderStackedStandard.types.ts deleted file mode 100644 index 49079c6aa1c6..000000000000 --- a/app/component-library/components-temp/HeaderStackedStandard/HeaderStackedStandard.types.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Third party dependencies. -import { ReactNode } from 'react'; - -// External dependencies. -import { ButtonIconProps } from '@metamask/design-system-react-native'; - -// Internal dependencies. -import { HeaderBaseProps } from '../../components/HeaderBase'; -import { TitleStandardProps } from '../TitleStandard/TitleStandard.types'; - -/** - * HeaderStackedStandard component props. - */ -export interface HeaderStackedStandardProps - extends Omit { - /** - * Callback when the back button is pressed. - * If provided, a back button will be rendered as startAccessory. - */ - onBack?: () => void; - /** - * Additional props to pass to the back ButtonIcon. - * If provided, a back button will be rendered with these props spread. - */ - backButtonProps?: Omit; - /** - * Callback when the close button is pressed. - * If provided, a close button will be added to endButtonIconProps. - */ - onClose?: () => void; - /** - * Additional props to pass to the close ButtonIcon. - * If provided, a close button will be added to endButtonIconProps with these props spread. - */ - closeButtonProps?: Omit; - /** - * Custom node to render in the title section. - * If provided, takes priority over titleStandardProps. - */ - titleStandard?: ReactNode; - /** - * Props to pass to the TitleStandard component. - * Only used if titleStandard is not provided. - */ - titleStandardProps?: TitleStandardProps; - /** - * Test ID for the title section wrapper. - */ - titleSectionTestID?: string; -} diff --git a/app/component-library/components-temp/HeaderStackedStandard/index.ts b/app/component-library/components-temp/HeaderStackedStandard/index.ts deleted file mode 100644 index 2ba19e3f715f..000000000000 --- a/app/component-library/components-temp/HeaderStackedStandard/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './HeaderStackedStandard'; -export type { HeaderStackedStandardProps } from './HeaderStackedStandard.types'; diff --git a/app/component-library/components-temp/HeaderStackedSubpage/HeaderStackedSubpage.stories.tsx b/app/component-library/components-temp/HeaderStackedSubpage/HeaderStackedSubpage.stories.tsx deleted file mode 100644 index 00d5b8019f60..000000000000 --- a/app/component-library/components-temp/HeaderStackedSubpage/HeaderStackedSubpage.stories.tsx +++ /dev/null @@ -1,147 +0,0 @@ -/* eslint-disable no-console */ -import React from 'react'; - -import { - Box, - Text, - TextVariant, - IconName, -} from '@metamask/design-system-react-native'; - -import { AvatarSize } from '../../components/Avatars/Avatar/Avatar.types'; -import AvatarToken from '../../components/Avatars/Avatar/variants/AvatarToken'; -import { SAMPLE_AVATARTOKEN_PROPS } from '../../components/Avatars/Avatar/variants/AvatarToken/AvatarToken.constants'; - -import HeaderStackedSubpage from './HeaderStackedSubpage'; - -const HeaderStackedSubpageMeta = { - title: 'Components Temp / HeaderStackedSubpage', - component: HeaderStackedSubpage, - argTypes: { - twClassName: { - control: 'text', - }, - }, -}; - -export default HeaderStackedSubpageMeta; - -export const Default = { - args: { - titleSubpageProps: { - title: 'Token Name', - bottomLabel: '$1,234.56', - }, - }, -}; - -export const OnBack = { - render: () => ( - console.log('Back pressed')} - titleSubpageProps={{ - title: 'Token Name', - bottomLabel: '$1,234.56', - }} - /> - ), -}; - -export const WithStartAccessory = { - render: () => ( - console.log('Back pressed')} - titleSubpageProps={{ - startAccessory: ( - - ), - title: 'Wrapped Ethereum', - bottomLabel: '$3,456.78', - }} - /> - ), -}; - -export const OnClose = { - render: () => ( - console.log('Close pressed')} - titleSubpageProps={{ - title: 'Token Name', - bottomLabel: '$1,234.56', - }} - /> - ), -}; - -export const BackAndClose = { - render: () => ( - console.log('Back pressed')} - onClose={() => console.log('Close pressed')} - titleSubpageProps={{ - title: 'Token Name', - bottomLabel: '$1,234.56', - }} - /> - ), -}; - -export const EndButtonIconProps = { - render: () => ( - console.log('Back pressed')} - endButtonIconProps={[ - { - iconName: IconName.Close, - onPress: () => console.log('Close pressed'), - }, - ]} - titleSubpageProps={{ - title: 'Token Name', - bottomLabel: '$1,234.56', - }} - /> - ), -}; - -export const BackButtonProps = { - render: () => ( - console.log('Custom back pressed'), - }} - titleSubpageProps={{ - title: 'Token Name', - bottomLabel: '$1,234.56', - }} - /> - ), -}; - -export const TitleSubpage = { - render: () => ( - console.log('Back pressed')} - titleSubpage={ - - Custom Title Section - - This is a completely custom title section - - - } - /> - ), -}; - -export const NoBackButton = { - render: () => ( - - ), -}; diff --git a/app/component-library/components-temp/HeaderStackedSubpage/HeaderStackedSubpage.test.tsx b/app/component-library/components-temp/HeaderStackedSubpage/HeaderStackedSubpage.test.tsx deleted file mode 100644 index 25f3475d4fa2..000000000000 --- a/app/component-library/components-temp/HeaderStackedSubpage/HeaderStackedSubpage.test.tsx +++ /dev/null @@ -1,303 +0,0 @@ -// Third party dependencies. -import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; -import { Text, IconName } from '@metamask/design-system-react-native'; - -// Internal dependencies. -import HeaderStackedSubpage from './HeaderStackedSubpage'; - -const TEST_IDS = { - CONTAINER: 'header-stacked-subpage-container', - TITLE_SECTION: 'header-stacked-subpage-title-section', - BACK_BUTTON: 'header-stacked-subpage-back-button', - CLOSE_BUTTON: 'header-stacked-subpage-close-button', - TITLE_SUBPAGE: 'title-subpage', - HEADER_BASE_END_ACCESSORY: 'header-base-end-accessory', -}; - -describe('HeaderStackedSubpage', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('rendering', () => { - it('renders container with correct testID', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId(TEST_IDS.CONTAINER)).toBeOnTheScreen(); - }); - - it('renders title section when titleSubpageProps provided', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId(TEST_IDS.TITLE_SECTION)).toBeOnTheScreen(); - }); - - it('renders TitleSubpage with props when titleSubpageProps provided', () => { - const { getByText, getByTestId } = render( - , - ); - - expect(getByText('Token Name')).toBeOnTheScreen(); - expect(getByText('$1,234.56')).toBeOnTheScreen(); - expect(getByTestId(TEST_IDS.TITLE_SUBPAGE)).toBeOnTheScreen(); - }); - - it('renders custom titleSubpage node when provided', () => { - const { getByText } = render( - Custom Title Section} - />, - ); - - expect(getByText('Custom Title Section')).toBeOnTheScreen(); - }); - - it('titleSubpage takes priority over titleSubpageProps', () => { - const { getByText, queryByText } = render( - Custom Node} - titleSubpageProps={{ title: 'Props Title' }} - />, - ); - - expect(getByText('Custom Node')).toBeOnTheScreen(); - expect(queryByText('Props Title')).toBeNull(); - }); - - it('does not render title section when neither titleSubpage nor titleSubpageProps provided', () => { - const { queryByTestId } = render( - , - ); - - expect(queryByTestId(TEST_IDS.TITLE_SECTION)).toBeNull(); - }); - }); - - describe('back button', () => { - it('renders back button when onBack provided', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId(TEST_IDS.BACK_BUTTON)).toBeOnTheScreen(); - }); - - it('renders back button when backButtonProps provided', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId(TEST_IDS.BACK_BUTTON)).toBeOnTheScreen(); - }); - - it('calls onBack when back button pressed', () => { - const onBack = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON)); - - expect(onBack).toHaveBeenCalledTimes(1); - }); - - it('calls backButtonProps.onPress when back button pressed', () => { - const onPress = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON)); - - expect(onPress).toHaveBeenCalledTimes(1); - }); - - it('backButtonProps.onPress takes priority over onBack', () => { - const onBack = jest.fn(); - const onPress = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId(TEST_IDS.BACK_BUTTON)); - - expect(onPress).toHaveBeenCalledTimes(1); - expect(onBack).not.toHaveBeenCalled(); - }); - - it('does not render back button when neither onBack nor backButtonProps provided', () => { - const { queryByLabelText } = render( - , - ); - - expect(queryByLabelText('Arrow Left')).toBeNull(); - }); - }); - - describe('close button', () => { - it('renders close button when onClose provided', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId(TEST_IDS.CLOSE_BUTTON)).toBeOnTheScreen(); - }); - - it('renders close button when closeButtonProps provided', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId(TEST_IDS.CLOSE_BUTTON)).toBeOnTheScreen(); - }); - - it('calls onClose when close button pressed', () => { - const onClose = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId(TEST_IDS.CLOSE_BUTTON)); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('calls closeButtonProps.onPress when close button pressed', () => { - const onPress = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId(TEST_IDS.CLOSE_BUTTON)); - - expect(onPress).toHaveBeenCalledTimes(1); - }); - - it('closeButtonProps.onPress takes priority over onClose', () => { - const onClose = jest.fn(); - const onPress = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId(TEST_IDS.CLOSE_BUTTON)); - - expect(onPress).toHaveBeenCalledTimes(1); - expect(onClose).not.toHaveBeenCalled(); - }); - - it('does not render close button when neither onClose nor closeButtonProps provided', () => { - const { queryByLabelText } = render( - , - ); - - expect(queryByLabelText('Close')).toBeNull(); - }); - }); - - describe('props forwarding', () => { - it('forwards endButtonIconProps to HeaderBase', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId(TEST_IDS.HEADER_BASE_END_ACCESSORY)).toBeOnTheScreen(); - }); - - it('accepts custom testID', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId('custom-header')).toBeOnTheScreen(); - }); - - it('forwards startButtonIconProps directly when provided', () => { - const onPress = jest.fn(); - const { getByTestId } = render( - , - ); - - expect(getByTestId('custom-start-button')).toBeOnTheScreen(); - }); - }); -}); diff --git a/app/component-library/components-temp/HeaderStackedSubpage/HeaderStackedSubpage.tsx b/app/component-library/components-temp/HeaderStackedSubpage/HeaderStackedSubpage.tsx deleted file mode 100644 index 4a57e2af87a0..000000000000 --- a/app/component-library/components-temp/HeaderStackedSubpage/HeaderStackedSubpage.tsx +++ /dev/null @@ -1,120 +0,0 @@ -// Third party dependencies. -import React, { useMemo } from 'react'; - -// External dependencies. -import { - Box, - IconName, - ButtonIconProps, -} from '@metamask/design-system-react-native'; - -// Internal dependencies. -import HeaderBase from '../../components/HeaderBase'; -import TitleSubpage from '../TitleSubpage'; -import { HeaderStackedSubpageProps } from './HeaderStackedSubpage.types'; - -/** - * HeaderStackedSubpage is a header component that combines HeaderBase (with back button) - * on top and a TitleSubpage section below it. - * - * @example - * ```tsx - * - * ``` - */ -const HeaderStackedSubpage: React.FC = ({ - onBack, - backButtonProps, - onClose, - closeButtonProps, - titleSubpage, - titleSubpageProps, - startButtonIconProps, - endButtonIconProps, - twClassName = '', - testID, - titleSectionTestID, - ...headerBaseProps -}) => { - // Build startButtonIconProps with back button if onBack or backButtonProps is provided - const resolvedStartButtonIconProps = useMemo(() => { - if (startButtonIconProps) { - // If startButtonIconProps is explicitly provided, use it as-is - return startButtonIconProps; - } - - if (onBack || backButtonProps) { - const backProps: ButtonIconProps = { - iconName: IconName.ArrowLeft, - ...(backButtonProps || {}), - onPress: backButtonProps?.onPress ?? onBack, - }; - return backProps; - } - - return undefined; - }, [startButtonIconProps, onBack, backButtonProps]); - - // Build endButtonIconProps with close button if onClose or closeButtonProps is provided - const resolvedEndButtonIconProps = useMemo(() => { - const props: ButtonIconProps[] = []; - - if (onClose || closeButtonProps) { - const closeProps: ButtonIconProps = { - iconName: IconName.Close, - ...(closeButtonProps || {}), - onPress: closeButtonProps?.onPress ?? onClose, - }; - props.push(closeProps); - } - - if (endButtonIconProps) { - props.push(...endButtonIconProps); - } - - return props.length > 0 ? props : undefined; - }, [endButtonIconProps, onClose, closeButtonProps]); - - // Render title section content - const renderTitleSection = () => { - if (titleSubpage) { - return titleSubpage; - } - if (titleSubpageProps) { - return ( - - ); - } - return null; - }; - - const hasTitleSection = titleSubpage || titleSubpageProps; - - return ( - - {/* HeaderBase section */} - - - {/* TitleSubpage section */} - {hasTitleSection && ( - {renderTitleSection()} - )} - - ); -}; - -export default HeaderStackedSubpage; diff --git a/app/component-library/components-temp/HeaderStackedSubpage/HeaderStackedSubpage.types.ts b/app/component-library/components-temp/HeaderStackedSubpage/HeaderStackedSubpage.types.ts deleted file mode 100644 index 393195650f4d..000000000000 --- a/app/component-library/components-temp/HeaderStackedSubpage/HeaderStackedSubpage.types.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Third party dependencies. -import { ReactNode } from 'react'; - -// External dependencies. -import { ButtonIconProps } from '@metamask/design-system-react-native'; - -// Internal dependencies. -import { HeaderBaseProps } from '../../components/HeaderBase'; -import { TitleSubpageProps } from '../TitleSubpage/TitleSubpage.types'; - -/** - * HeaderStackedSubpage component props. - */ -export interface HeaderStackedSubpageProps - extends Omit { - /** - * Callback when the back button is pressed. - * If provided, a back button will be rendered as startAccessory. - */ - onBack?: () => void; - /** - * Additional props to pass to the back ButtonIcon. - * If provided, a back button will be rendered with these props spread. - */ - backButtonProps?: Omit; - /** - * Callback when the close button is pressed. - * If provided, a close button will be added to endButtonIconProps. - */ - onClose?: () => void; - /** - * Additional props to pass to the close ButtonIcon. - * If provided, a close button will be added to endButtonIconProps with these props spread. - */ - closeButtonProps?: Omit; - /** - * Custom node to render in the title section. - * If provided, takes priority over titleSubpageProps. - */ - titleSubpage?: ReactNode; - /** - * Props to pass to the TitleSubpage component. - * Only used if titleSubpage is not provided. - */ - titleSubpageProps?: TitleSubpageProps; - /** - * Test ID for the title section wrapper. - */ - titleSectionTestID?: string; -} diff --git a/app/component-library/components-temp/HeaderStackedSubpage/index.ts b/app/component-library/components-temp/HeaderStackedSubpage/index.ts deleted file mode 100644 index b24ac27d497b..000000000000 --- a/app/component-library/components-temp/HeaderStackedSubpage/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './HeaderStackedSubpage'; -export type { HeaderStackedSubpageProps } from './HeaderStackedSubpage.types'; diff --git a/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.stories.tsx b/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.stories.tsx new file mode 100644 index 000000000000..9bc50872a2c1 --- /dev/null +++ b/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.stories.tsx @@ -0,0 +1,113 @@ +/* eslint-disable no-console */ +import React from 'react'; + +import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; +import Animated from 'react-native-reanimated'; + +import HeaderStandardAnimated from './HeaderStandardAnimated'; +import useHeaderStandardAnimated from './useHeaderStandardAnimated'; +import TitleStandard from '../TitleStandard'; + +const HeaderStandardAnimatedMeta = { + title: 'Components Temp / HeaderStandardAnimated', + component: HeaderStandardAnimated, + argTypes: { + title: { + control: 'text', + }, + subtitle: { + control: 'text', + }, + twClassName: { + control: 'text', + }, + }, +}; + +export default HeaderStandardAnimatedMeta; + +const SampleContent = ({ itemCount = 20 }: { itemCount?: number }) => ( + <> + {Array.from({ length: itemCount }).map((_, index) => ( + + Item {index + 1} + + This is sample content to demonstrate scrolling behavior. + + + ))} + +); + +const DefaultStory = () => { + const { scrollY, onScroll, setTitleSectionHeight, titleSectionHeightSv } = + useHeaderStandardAnimated(); + + return ( + + console.log('Back pressed')} + /> + + setTitleSectionHeight(e.nativeEvent.layout.height)} + > + + + + + + ); +}; + +const WithSubtitleStory = () => { + const { scrollY, onScroll, setTitleSectionHeight, titleSectionHeightSv } = + useHeaderStandardAnimated(); + + return ( + + console.log('Back pressed')} + /> + + setTitleSectionHeight(e.nativeEvent.layout.height)} + > + + + + + + ); +}; + +export const Default = { + render: () => , +}; + +export const WithSubtitle = { + render: () => , +}; diff --git a/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.test.tsx b/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.test.tsx new file mode 100644 index 000000000000..48e0fa354398 --- /dev/null +++ b/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.test.tsx @@ -0,0 +1,349 @@ +// Third party dependencies. +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; + +// External dependencies. +import { IconName } from '@metamask/design-system-react-native'; +import type { SharedValue } from 'react-native-reanimated'; + +// Internal dependencies. +import HeaderStandardAnimated from './HeaderStandardAnimated'; + +jest.mock('react-native-reanimated', () => { + const Reanimated = jest.requireActual('react-native-reanimated/mock'); + Reanimated.useSharedValue = jest.fn((initial: number) => ({ + value: initial, + })); + Reanimated.useAnimatedStyle = jest.fn((fn: () => object) => fn()); + return Reanimated; +}); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }), +})); + +const CONTAINER_TEST_ID = 'header-standard-animated-container'; +const TITLE_TEST_ID = 'header-standard-animated-title'; +const SUBTITLE_TEST_ID = 'header-standard-animated-subtitle'; +const BACK_BUTTON_TEST_ID = 'header-standard-animated-back-button'; +const CLOSE_BUTTON_TEST_ID = 'header-standard-animated-close-button'; + +const createMockSharedValue = (initial: number): SharedValue => { + const ref = { value: initial }; + return { + get value() { + return ref.value; + }, + set value(v: number) { + ref.value = v; + }, + get: () => ref.value, + set: (v: number | ((prev: number) => number)) => { + ref.value = typeof v === 'function' ? v(ref.value) : v; + }, + addListener: jest.fn(), + removeListener: jest.fn(), + modify: jest.fn(), + }; +}; + +const defaultProps = { + scrollY: createMockSharedValue(0), + titleSectionHeight: createMockSharedValue(100), +}; + +describe('HeaderStandardAnimated', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders with title', () => { + const { getByText } = render( + , + ); + + expect(getByText('Test Title')).toBeOnTheScreen(); + }); + + it('renders title with testID when provided via titleProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TITLE_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders container with testID when provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CONTAINER_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders subtitle when provided', () => { + const { getByText } = render( + , + ); + + expect(getByText('Test Subtitle')).toBeOnTheScreen(); + }); + + it('does not render subtitle when not provided', () => { + const { queryByText } = render( + , + ); + + expect(queryByText('Test Subtitle')).not.toBeOnTheScreen(); + }); + + it('renders subtitle with testID when provided via subtitleProps', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(SUBTITLE_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders both title and subtitle together', () => { + const { getByText } = render( + , + ); + + expect(getByText('Main Title')).toBeOnTheScreen(); + expect(getByText('Supporting Text')).toBeOnTheScreen(); + }); + }); + + describe('back button', () => { + it('renders back button when onBack provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(BACK_BUTTON_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders back button when backButtonProps provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(BACK_BUTTON_TEST_ID)).toBeOnTheScreen(); + }); + + it('calls onBack when back button pressed', () => { + const onBack = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(BACK_BUTTON_TEST_ID)); + + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it('calls backButtonProps.onPress when back button pressed', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(BACK_BUTTON_TEST_ID)); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('uses backButtonProps.onPress over onBack when both provided', () => { + const onBack = jest.fn(); + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(BACK_BUTTON_TEST_ID)); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onBack).not.toHaveBeenCalled(); + }); + + it('renders startButtonIconProps when provided', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-start-button')).toBeOnTheScreen(); + }); + + it('startButtonIconProps takes priority over onBack', () => { + const onBack = jest.fn(); + const onPress = jest.fn(); + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('custom-start-button')).toBeOnTheScreen(); + expect(queryByTestId(BACK_BUTTON_TEST_ID)).not.toBeOnTheScreen(); + }); + }); + + describe('close button', () => { + it('renders close button when onClose provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CLOSE_BUTTON_TEST_ID)).toBeOnTheScreen(); + }); + + it('renders close button when closeButtonProps provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(CLOSE_BUTTON_TEST_ID)).toBeOnTheScreen(); + }); + + it('calls onClose when close button pressed', () => { + const onClose = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(CLOSE_BUTTON_TEST_ID)); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls closeButtonProps.onPress when close button pressed', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(CLOSE_BUTTON_TEST_ID)); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('uses closeButtonProps.onPress over onClose when both provided', () => { + const onClose = jest.fn(); + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(CLOSE_BUTTON_TEST_ID)); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + describe('props forwarding', () => { + it('accepts custom testID', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('custom-header')).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.tsx b/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.tsx new file mode 100644 index 000000000000..6cb8f3c55ebf --- /dev/null +++ b/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.tsx @@ -0,0 +1,80 @@ +// Third party dependencies. +import React from 'react'; + +// External dependencies. +import { + Box, + BoxAlignItems, + Text, + TextVariant, + TextColor, + FontWeight, +} from '@metamask/design-system-react-native'; +import Animated, { + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated'; + +// Internal dependencies. +import HeaderCompactStandard from '../HeaderCompactStandard'; +import { HeaderStandardAnimatedProps } from './HeaderStandardAnimated.types'; + +const HeaderStandardAnimated: React.FC = ({ + title, + titleProps, + subtitle, + subtitleProps, + scrollY, + titleSectionHeight, + twClassName = '', + ...headerStandardProps +}) => { + const compactTitleProgress = useDerivedValue(() => { + const hasMeasured = titleSectionHeight.value > 0; + const isFullyHidden = + hasMeasured && scrollY.value >= titleSectionHeight.value; + return withTiming(isFullyHidden ? 1 : 0, { duration: 150 }); + }); + + const centerAnimatedStyle = useAnimatedStyle(() => { + const progress = compactTitleProgress.value; + return { + opacity: progress, + transform: [{ translateY: (1 - progress) * 8 }], + }; + }); + + const content = title ? ( + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + ) : null; + + return ( + + {content} + + ); +}; + +export default HeaderStandardAnimated; diff --git a/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.types.ts b/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.types.ts new file mode 100644 index 000000000000..436f8d0530c5 --- /dev/null +++ b/app/component-library/components-temp/HeaderStandardAnimated/HeaderStandardAnimated.types.ts @@ -0,0 +1,35 @@ +// External dependencies. +import { SharedValue, useAnimatedScrollHandler } from 'react-native-reanimated'; + +// Internal dependencies. +import { HeaderCompactStandardProps } from '../HeaderCompactStandard/HeaderCompactStandard.types'; + +/** + * HeaderStandardAnimated component props. + * Extends HeaderCompactStandardProps with scroll-driven animation inputs. + * Content is driven by title/subtitle only; children is not supported. + */ +export interface HeaderStandardAnimatedProps + extends Omit { + /** + * Shared value for scroll offset from the ScrollView. + * Used to drive the center-title animation when scroll passes the title section. + */ + scrollY: SharedValue; + /** + * Shared value for the height of the title section (first child of ScrollView). + * When scrollY >= titleSectionHeight, the compact center title is shown. + */ + titleSectionHeight: SharedValue; +} + +/** + * Return type for useHeaderStandardAnimated hook. + * onScroll is an animated scroll handler; use with Animated.ScrollView for UI-thread updates. + */ +export interface UseHeaderStandardAnimatedReturn { + scrollY: SharedValue; + titleSectionHeightSv: SharedValue; + setTitleSectionHeight: (height: number) => void; + onScroll: ReturnType; +} diff --git a/app/component-library/components-temp/HeaderStandardAnimated/index.ts b/app/component-library/components-temp/HeaderStandardAnimated/index.ts new file mode 100644 index 000000000000..58f7cb8c7f09 --- /dev/null +++ b/app/component-library/components-temp/HeaderStandardAnimated/index.ts @@ -0,0 +1,6 @@ +export { default } from './HeaderStandardAnimated'; +export { default as useHeaderStandardAnimated } from './useHeaderStandardAnimated'; +export type { + HeaderStandardAnimatedProps, + UseHeaderStandardAnimatedReturn, +} from './HeaderStandardAnimated.types'; diff --git a/app/component-library/components-temp/HeaderStandardAnimated/useHeaderStandardAnimated.test.ts b/app/component-library/components-temp/HeaderStandardAnimated/useHeaderStandardAnimated.test.ts new file mode 100644 index 000000000000..e26188955b74 --- /dev/null +++ b/app/component-library/components-temp/HeaderStandardAnimated/useHeaderStandardAnimated.test.ts @@ -0,0 +1,103 @@ +// Third party dependencies. +import { renderHook, act } from '@testing-library/react-native'; + +// Internal dependencies. +import useHeaderStandardAnimated from './useHeaderStandardAnimated'; + +jest.mock('react-native-reanimated', () => ({ + useSharedValue: jest.fn((initial: number) => ({ value: initial })), + useAnimatedScrollHandler: jest.fn( + ( + config: + | { onScroll?: (e: { contentOffset: { y: number } }) => void } + | ((e: { contentOffset: { y: number } }) => void), + ) => + (scrollEvent: { contentOffset: { y: number } }) => { + const handler = + typeof config === 'function' ? config : config?.onScroll; + handler?.(scrollEvent); + }, + ), +})); + +const createScrollEvent = (contentOffsetY: number) => ({ + contentOffset: { y: contentOffsetY, x: 0 }, +}); + +describe('useHeaderStandardAnimated', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('return value', () => { + it('returns scrollY, titleSectionHeightSv, setTitleSectionHeight, and onScroll', () => { + const { result } = renderHook(() => useHeaderStandardAnimated()); + + expect(result.current).toHaveProperty('scrollY'); + expect(result.current).toHaveProperty('titleSectionHeightSv'); + expect(result.current).toHaveProperty('setTitleSectionHeight'); + expect(result.current).toHaveProperty('onScroll'); + expect(typeof result.current.setTitleSectionHeight).toBe('function'); + expect(typeof result.current.onScroll).toBe('function'); + }); + + it('initializes scrollY with value 0', () => { + const { result } = renderHook(() => useHeaderStandardAnimated()); + + expect(result.current.scrollY.value).toBe(0); + }); + + it('initializes titleSectionHeightSv with value 0', () => { + const { result } = renderHook(() => useHeaderStandardAnimated()); + + expect(result.current.titleSectionHeightSv.value).toBe(0); + }); + }); + + describe('setTitleSectionHeight', () => { + it('updates titleSectionHeightSv.value when called', () => { + const { result } = renderHook(() => useHeaderStandardAnimated()); + + act(() => { + result.current.setTitleSectionHeight(120); + }); + + expect(result.current.titleSectionHeightSv.value).toBe(120); + }); + + it('updates titleSectionHeightSv.value on multiple calls', () => { + const { result } = renderHook(() => useHeaderStandardAnimated()); + + act(() => { + result.current.setTitleSectionHeight(50); + }); + expect(result.current.titleSectionHeightSv.value).toBe(50); + + act(() => { + result.current.setTitleSectionHeight(200); + }); + expect(result.current.titleSectionHeightSv.value).toBe(200); + }); + }); + + describe('onScroll', () => { + it('returns onScroll handler that accepts event with contentOffset and does not throw', () => { + // scrollY.value update from contentOffset.y is not asserted here because the hook + // receives the real react-native-reanimated in this test environment; the behavior + // is implemented in the hook and may be covered by integration tests. + const { result } = renderHook(() => useHeaderStandardAnimated()); + + expect(typeof result.current.onScroll).toBe('function'); + + expect(() => { + act(() => { + result.current.onScroll( + createScrollEvent(75) as unknown as Parameters< + ReturnType['onScroll'] + >[0], + ); + }); + }).not.toThrow(); + }); + }); +}); diff --git a/app/component-library/components-temp/HeaderStandardAnimated/useHeaderStandardAnimated.ts b/app/component-library/components-temp/HeaderStandardAnimated/useHeaderStandardAnimated.ts new file mode 100644 index 000000000000..94960abf0391 --- /dev/null +++ b/app/component-library/components-temp/HeaderStandardAnimated/useHeaderStandardAnimated.ts @@ -0,0 +1,64 @@ +// Third party dependencies. +import { useCallback } from 'react'; +import { + useSharedValue, + useAnimatedScrollHandler, +} from 'react-native-reanimated'; + +// Internal dependencies. +import { UseHeaderStandardAnimatedReturn } from './HeaderStandardAnimated.types'; + +/** + * Hook for managing HeaderStandardAnimated scroll-linked animations. + * Use with HeaderStandardAnimated placed outside the ScrollView as a sibling. + * Use the returned onScroll with Animated.ScrollView for UI-thread scroll updates (zero lag). + * + * @returns Object containing scrollY, titleSectionHeightSv, setTitleSectionHeight, and onScroll. + * + * @example + * ```tsx + * const { scrollY, onScroll, setTitleSectionHeight, titleSectionHeightSv } = + * useHeaderStandardAnimated(); + * + * + * + * + * setTitleSectionHeight(e.nativeEvent.layout.height)}> + * + * + * {/* page body *\/} + * + * + * ``` + */ +const useHeaderStandardAnimated = (): UseHeaderStandardAnimatedReturn => { + const scrollYValue = useSharedValue(0); + const titleSectionHeightSv = useSharedValue(0); + + const setTitleSectionHeight = useCallback( + (height: number) => { + titleSectionHeightSv.value = height; + }, + [titleSectionHeightSv], + ); + + const onScroll = useAnimatedScrollHandler({ + onScroll: (scrollEvent) => { + scrollYValue.value = scrollEvent.contentOffset.y; + }, + }); + + return { + scrollY: scrollYValue, + titleSectionHeightSv, + setTitleSectionHeight, + onScroll, + }; +}; + +export default useHeaderStandardAnimated; diff --git a/app/component-library/components-temp/KeyValueRow/__snapshots__/KeyValueRow.test.tsx.snap b/app/component-library/components-temp/KeyValueRow/__snapshots__/KeyValueRow.test.tsx.snap index 6f5cbc66aefe..7bec26896d2c 100644 --- a/app/component-library/components-temp/KeyValueRow/__snapshots__/KeyValueRow.test.tsx.snap +++ b/app/component-library/components-temp/KeyValueRow/__snapshots__/KeyValueRow.test.tsx.snap @@ -34,7 +34,7 @@ exports[`KeyValueRow Prebuilt Component KeyValueRow should render text with icon } > = ({ - - - ); -}; - interface MarketInsightsRouteParams { assetSymbol: string; caip19Id: string; tokenImageUrl?: string; - pricePercentChange?: number; /** Token address for swap navigation */ tokenAddress?: string; /** Token decimals for swap navigation */ @@ -241,11 +133,11 @@ interface MarketInsightsRouteParams { /** * MarketInsightsView is the full-page Market Insights screen. * It displays the AI-generated market report including: - * - Price change indicator with token logo * - Headline and summary * - "A closer look" trends section + * - Consolidated trends sources pill * - "What's being said" social section - * - Sources footer with feedback buttons + * - Feedback row with thumbs actions * - Trade CTA button (navigates to Swaps with asset pre-filled) */ const MarketInsightsView: React.FC = () => { @@ -259,7 +151,6 @@ const MarketInsightsView: React.FC = () => { assetSymbol, caip19Id, tokenImageUrl, - pricePercentChange, tokenAddress, tokenDecimals, tokenName, @@ -280,7 +171,6 @@ const MarketInsightsView: React.FC = () => { const loadingSkeletonTimeoutRef = useRef | null>(null); - const [isSourcesSheetVisible, setIsSourcesSheetVisible] = useState(false); const [isFeedbackSheetVisible, setIsFeedbackSheetVisible] = useState(false); // Build BridgeToken from route params for swap navigation @@ -309,25 +199,19 @@ const MarketInsightsView: React.FC = () => { sourceToken, }); - // Determine if price change is positive or negative - const isPricePositive = (pricePercentChange ?? 0) >= 0; - const formattedPercentChange = - pricePercentChange != null - ? `${isPricePositive ? '+' : ''}${pricePercentChange.toFixed(2)}%` - : null; - // Collect all tweets from all trends for the "What people are saying" section const allTweets: MarketInsightsTweet[] = useMemo(() => { if (!report) return []; return report.trends.flatMap((trend) => trend.tweets).slice(0, 4); }, [report]); - const handleBackPress = useCallback(() => { navigation.goBack(); }, [navigation]); const handleTweetPress = useCallback((url: string) => { - Linking.openURL(url); + if (isSafeUrl(url)) { + Linking.openURL(url); + } }, []); const handleTradePress = useCallback(() => { @@ -371,7 +255,7 @@ const MarketInsightsView: React.FC = () => { } setShowLoadingSkeleton(false); - }, [isLoading, report]); + }, [isLoading, report, error]); useEffect( () => () => { @@ -381,14 +265,6 @@ const MarketInsightsView: React.FC = () => { }, [], ); - const handleOpenSources = useCallback(() => { - setIsSourcesSheetVisible(true); - }, []); - - const handleCloseSources = useCallback(() => { - setIsSourcesSheetVisible(false); - }, []); - const trackMarketInsightsInteraction = useCallback( ( interactionType: 'thumbs_up' | 'thumbs_down' | 'source_click', @@ -467,9 +343,23 @@ const MarketInsightsView: React.FC = () => { const handleSourcePress = useCallback( (url: string) => { + if (!isSafeUrl(url)) { + return; + } trackMarketInsightsInteraction('source_click', { source: url }); + navigation.navigate( + Routes.BROWSER.HOME as never, + { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: url, + timestamp: Date.now(), + fromTrending: true, + }, + } as never, + ); }, - [trackMarketInsightsInteraction], + [trackMarketInsightsInteraction, navigation], ); useEffect(() => { @@ -488,7 +378,7 @@ const MarketInsightsView: React.FC = () => { hasTrackedViewRef.current = true; }, [report, caip19Id, trackEvent, createEventBuilder]); - if (showLoadingSkeleton && !report) { + if (showLoadingSkeleton && !report && !error) { return ( { twClassName={`flex-1 bg-default pt-[${insets.top}px]`} testID={MarketInsightsSelectorsIDs.VIEW_CONTAINER} > - - - - - - - {strings('market_insights.title')} - - - - + - - {tokenImageUrl ? ( - - ) : null} - {formattedPercentChange ? ( - - {formattedPercentChange} - - ) : null} - - - - - - {report.headline} + {report.headline} @@ -594,26 +421,6 @@ const MarketInsightsView: React.FC = () => { {/* "A closer look" section */} - - - - {strings('market_insights.a_closer_look')} - - - {report.trends.map((trend, index) => ( { {/* "What's being said" section */} {allTweets.length > 0 && ( + - - + {strings('market_insights.whats_being_said')} @@ -662,34 +459,76 @@ const MarketInsightsView: React.FC = () => { )} - - - - - - - + tw.style( + 'h-12 w-12 items-center justify-center rounded-full bg-muted', + pressed && 'opacity-70', + ) + } + testID={MarketInsightsSelectorsIDs.THUMBS_UP_BUTTON} > - {strings('market_insights.fixed_footer_disclaimer')} - + + + + tw.style( + 'h-12 w-12 items-center justify-center rounded-full bg-muted', + pressed && 'opacity-70', + ) + } + testID={MarketInsightsSelectorsIDs.THUMBS_DOWN_BUTTON} + > + + + + {strings('market_insights.helpful_prompt')} + + + + + + + + + {strings('market_insights.footer_disclaimer')} + @@ -704,15 +543,6 @@ const MarketInsightsView: React.FC = () => { /> ) : null} - {isSourcesSheetVisible ? ( - - ) : null} - {isFeedbackSheetVisible ? ( void; +} + +const MarketInsightsViewHeader: React.FC = ({ + onBackPress, +}) => { + const tw = useTailwind(); + + return ( + + + + + + + {strings('market_insights.title')} + + + + + ); +}; + +export default MarketInsightsViewHeader; diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsViewSkeleton.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsViewSkeleton.tsx new file mode 100644 index 000000000000..460fef2b30ad --- /dev/null +++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsViewSkeleton.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { ScrollView } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { Box } from '@metamask/design-system-react-native'; +import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { MarketInsightsSelectorsIDs } from '../../MarketInsights.testIds'; +import MarketInsightsViewHeader from './MarketInsightsViewHeader'; + +interface MarketInsightsViewSkeletonProps { + insets: { top: number; bottom: number }; + onBackPress: () => void; +} + +const MarketInsightsViewSkeleton: React.FC = ({ + insets, + onBackPress, +}) => { + const tw = useTailwind(); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default MarketInsightsViewSkeleton; diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx index 8842870ce72e..a9a415be7afb 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef } from 'react'; -import { Animated, Image, Pressable } from 'react-native'; +import { Animated, Pressable } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, @@ -12,15 +12,14 @@ import { IconColor, BoxFlexDirection, BoxAlignItems, + BoxJustifyContent, FontWeight, } from '@metamask/design-system-react-native'; -import type { MarketInsightsSource } from '@metamask/ai-controllers'; import { strings } from '../../../../../../locales/i18n'; import type { MarketInsightsEntryCardProps } from './MarketInsightsEntryCard.types'; +import { getUniqueSourcesByFavicon } from '../../utils/marketInsightsFormatting'; import { endTrace, TraceName } from '../../../../../util/trace'; -import { getFaviconUrl } from '../../utils/marketInsightsFormatting'; - -const MAX_VISIBLE_SOURCE_LOGOS = 3; +import SourceLogoGroup from '../SourceLogoGroup'; const SparkleIcon: React.FC = () => { const opacity = useRef(new Animated.Value(0.45)).current; @@ -57,46 +56,6 @@ const SparkleIcon: React.FC = () => { ); }; -const SourceLogoGroup: React.FC<{ sources?: MarketInsightsSource[] }> = ({ - sources, -}) => { - const tw = useTailwind(); - - const uniqueSources = useMemo(() => { - const seenFaviconUrls = new Set(); - return (sources ?? []).filter((source) => { - const faviconUrl = getFaviconUrl(source.url); - if (seenFaviconUrls.has(faviconUrl)) { - return false; - } - seenFaviconUrls.add(faviconUrl); - return true; - }); - }, [sources]); - - if (uniqueSources.length === 0) { - return null; - } - - return ( - - {uniqueSources.slice(0, MAX_VISIBLE_SOURCE_LOGOS).map((source, index) => ( - 0 ? '-ml-1' : '' - }`} - > - - - ))} - - ); -}; - /** * MarketInsightsEntryCard is the entry point card shown on the token details page. * Tapping navigates to the full Market Insights view. @@ -109,6 +68,10 @@ const MarketInsightsEntryCard: React.FC = ({ testID, }) => { const tw = useTailwind(); + const uniqueSources = useMemo( + () => getUniqueSourcesByFavicon(report.sources ?? []), + [report.sources], + ); useEffect(() => { // End the trace started by the parent (AssetOverviewContent) to measure @@ -123,45 +86,66 @@ const MarketInsightsEntryCard: React.FC = ({ - tw.style('mx-4 rounded-2xl bg-muted p-4', pressed && 'opacity-80') + tw.style('px-4 mt-2 mb-4', pressed && 'opacity-80') } testID={testID} > - + - - - {strings('market_insights.title')} - + + + {strings('market_insights.title')} + + + - - - {report.summary} - + + + {report.summary} + - - + + + + + {strings('market_insights.footer_disclaimer')} + + + {'•'} + + + {timeAgo} + + + - - - {strings('market_insights.disclaimer')} - {' • '} - {timeAgo} - ); }; diff --git a/app/components/UI/MarketInsights/components/MarketInsightsSourcesFooter/MarketInsightsSourcesFooter.test.tsx b/app/components/UI/MarketInsights/components/MarketInsightsSourcesFooter/MarketInsightsSourcesFooter.test.tsx deleted file mode 100644 index 0454a1612c2a..000000000000 --- a/app/components/UI/MarketInsights/components/MarketInsightsSourcesFooter/MarketInsightsSourcesFooter.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import { fireEvent } from '@testing-library/react-native'; -import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import MarketInsightsSourcesFooter from './MarketInsightsSourcesFooter'; -import { MarketInsightsSelectorsIDs } from '../../MarketInsights.testIds'; - -describe('MarketInsightsSourcesFooter', () => { - it('calls sources callback when sources pill is pressed', () => { - const onSourcesPress = jest.fn(); - const sources = [ - { name: 'CoinDesk', type: 'news', url: 'https://coindesk.com/article-1' }, - { name: 'The Block', type: 'news', url: 'https://theblock.co/article-2' }, - { name: 'Decrypt', type: 'news', url: 'https://decrypt.co/article-3' }, - { - name: 'Bitcoin Magazine', - type: 'social', - url: 'https://bitcoinmagazine.com/article-4', - }, - { - name: 'CoinMarketCap', - type: 'data', - url: 'https://coinmarketcap.com/article-5', - }, - ]; - - const { getByText } = renderWithProvider( - , - ); - - fireEvent.press(getByText('+1 sources')); - expect(onSourcesPress).toHaveBeenCalledTimes(1); - }); - - it('calls thumbs callbacks when thumb buttons are pressed', () => { - const onThumbsUp = jest.fn(); - const onThumbsDown = jest.fn(); - const sources = [ - { name: 'CoinDesk', type: 'news', url: 'https://coindesk.com/article-1' }, - ]; - - const { getByTestId } = renderWithProvider( - , - ); - - fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.THUMBS_UP_BUTTON)); - fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.THUMBS_DOWN_BUTTON)); - - expect(onThumbsUp).toHaveBeenCalledTimes(1); - expect(onThumbsDown).toHaveBeenCalledTimes(1); - }); -}); diff --git a/app/components/UI/MarketInsights/components/MarketInsightsSourcesFooter/MarketInsightsSourcesFooter.tsx b/app/components/UI/MarketInsights/components/MarketInsightsSourcesFooter/MarketInsightsSourcesFooter.tsx deleted file mode 100644 index 409f802b61fb..000000000000 --- a/app/components/UI/MarketInsights/components/MarketInsightsSourcesFooter/MarketInsightsSourcesFooter.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import React, { useCallback, useEffect, useRef } from 'react'; -import { Image, Linking, Pressable, ScrollView } from 'react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { - Box, - Text, - TextVariant, - TextColor, - BoxFlexDirection, - BoxAlignItems, - BoxJustifyContent, - Icon, - IconName, - IconSize, - IconColor, - FontWeight, -} from '@metamask/design-system-react-native'; -import BottomSheet, { - BottomSheetRef, -} from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; -import { strings } from '../../../../../../locales/i18n'; -import type { - MarketInsightsSourcesFooterProps, - MarketInsightsSourcesBottomSheetProps, -} from './MarketInsightsSourcesFooter.types'; -import type { MarketInsightsSource } from '@metamask/ai-controllers'; -import { getFaviconUrl } from '../../utils/marketInsightsFormatting'; -import { MarketInsightsSelectorsIDs } from '../../MarketInsights.testIds'; - -// Maximum number of source icons to show in the pill before "+N" -const MAX_VISIBLE_SOURCES = 4; - -// SourceIcon renders a small circular favicon for a source. -const SourceIcon: React.FC<{ - source: MarketInsightsSource; - index: number; - isStacked?: boolean; -}> = ({ source, index, isStacked = false }) => { - const tw = useTailwind(); - return ( - 0 ? '-ml-2' : '' - }`} - > - - - ); -}; - -// MarketInsightsSourcesBottomSheet renders a scrollable list of all sources -export const MarketInsightsSourcesBottomSheet: React.FC< - MarketInsightsSourcesBottomSheetProps -> = ({ isVisible, onClose, sources, onSourcePress }) => { - const tw = useTailwind(); - const bottomSheetRef = useRef(null); - - useEffect(() => { - const sheet = bottomSheetRef.current; - if (isVisible) { - sheet?.onOpenBottomSheet(); - } else { - sheet?.onCloseBottomSheet(); - } - }, [isVisible]); - - const handleSourcePress = useCallback( - (url: string) => { - onSourcePress?.(url); - Linking.openURL(url); - }, - [onSourcePress], - ); - - const uniqueSources = sources.reduce( - (acc, source) => { - if (!acc.find((s) => s.name === source.name)) { - acc.push(source); - } - return acc; - }, - [], - ); - - return ( - - - - {strings('market_insights.sources_title')} - - - - {uniqueSources.map((source) => ( - handleSourcePress(source.url)} - style={({ pressed }) => - tw.style( - 'flex-row items-center py-3 border-b border-muted', - pressed && 'opacity-70', - ) - } - > - - - - - - {source.name} - - - {source.type} - - - - - ))} - - - ); -}; - -const MarketInsightsSourcesFooter: React.FC< - MarketInsightsSourcesFooterProps -> = ({ sources, onSourcesPress, onThumbsUp, onThumbsDown, testID }) => { - const tw = useTailwind(); - - const visibleCount = Math.min(sources.length, MAX_VISIBLE_SOURCES); - const remainingCount = Math.max(sources.length - MAX_VISIBLE_SOURCES, 0); - - return ( - - - tw.style( - 'flex-row items-center bg-muted rounded-full px-3 py-2', - pressed && 'opacity-70', - ) - } - > - - {sources.slice(0, visibleCount).map((source, index) => ( - - ))} - - {remainingCount > 0 ? ( - - {strings('market_insights.sources_count', { - count: String(remainingCount), - })} - - ) : null} - - - - tw.style(pressed && 'opacity-70')} - testID={MarketInsightsSelectorsIDs.THUMBS_UP_BUTTON} - > - - - tw.style(pressed && 'opacity-70')} - testID={MarketInsightsSelectorsIDs.THUMBS_DOWN_BUTTON} - > - - - - - ); -}; - -export default MarketInsightsSourcesFooter; diff --git a/app/components/UI/MarketInsights/components/MarketInsightsSourcesFooter/MarketInsightsSourcesFooter.types.ts b/app/components/UI/MarketInsights/components/MarketInsightsSourcesFooter/MarketInsightsSourcesFooter.types.ts deleted file mode 100644 index 89f0b7d85337..000000000000 --- a/app/components/UI/MarketInsights/components/MarketInsightsSourcesFooter/MarketInsightsSourcesFooter.types.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { MarketInsightsSource } from '@metamask/ai-controllers'; - -export interface MarketInsightsSourcesFooterProps { - /** List of sources used for the report */ - sources: MarketInsightsSource[]; - /** Callback when sources pill is pressed */ - onSourcesPress?: () => void; - /** Callback for thumbs up interaction */ - onThumbsUp?: () => void; - /** Callback for thumbs down interaction */ - onThumbsDown?: () => void; - /** Optional test ID */ - testID?: string; -} - -export interface MarketInsightsSourcesBottomSheetProps { - /** Whether the bottom sheet is visible */ - isVisible: boolean; - /** Callback when the bottom sheet is closed */ - onClose: () => void; - /** List of sources to display */ - sources: MarketInsightsSource[]; - /** Callback when a source URL is pressed */ - onSourcePress?: (url: string) => void; -} diff --git a/app/components/UI/MarketInsights/components/MarketInsightsSourcesFooter/index.ts b/app/components/UI/MarketInsights/components/MarketInsightsSourcesFooter/index.ts deleted file mode 100644 index 8f74905ef17d..000000000000 --- a/app/components/UI/MarketInsights/components/MarketInsightsSourcesFooter/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default } from './MarketInsightsSourcesFooter'; -export { MarketInsightsSourcesBottomSheet } from './MarketInsightsSourcesFooter'; -export type { MarketInsightsSourcesFooterProps } from './MarketInsightsSourcesFooter.types'; diff --git a/app/components/UI/MarketInsights/components/MarketInsightsTrendItem/MarketInsightsTrendItem.test.tsx b/app/components/UI/MarketInsights/components/MarketInsightsTrendItem/MarketInsightsTrendItem.test.tsx index 64d723ed3428..03e369a902c1 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsTrendItem/MarketInsightsTrendItem.test.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsTrendItem/MarketInsightsTrendItem.test.tsx @@ -5,7 +5,7 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider'; import MarketInsightsTrendItem from './MarketInsightsTrendItem'; describe('MarketInsightsTrendItem', () => { - it('renders trend text and deduplicates source icons', () => { + it('renders trend title and description', () => { const trend = { title: 'Macro liquidity supports risk assets', description: 'Broad risk-on sentiment is supporting ETH and BTC.', @@ -14,8 +14,43 @@ describe('MarketInsightsTrendItem', () => { source: 'Cointelegraph', url: 'https://cointelegraph.com/news/market-update-1', }, + { source: 'theblock.co', url: 'https://www.theblock.co/post/1234' }, + ], + }; + + const { getByText } = renderWithProvider( + , + ); + + expect(getByText(trend.title)).toBeOnTheScreen(); + expect(getByText(trend.description)).toBeOnTheScreen(); + }); + + it('shows source name label for a single source', () => { + const trend = { + title: 'Institutional inflows drive BTC demand', + description: 'Large funds keep adding BTC exposure.', + articles: [{ source: 'Coindesk', url: 'https://coindesk.com/news/1' }], + }; + + const { getByText } = renderWithProvider( + , + ); + + expect(getByText('Coindesk')).toBeOnTheScreen(); + }); + + it('shows first source name and remaining count for multiple sources', () => { + const trend = { + title: 'Macro liquidity supports risk assets', + description: 'Broad risk-on sentiment is supporting ETH and BTC.', + articles: [ { - source: 'https://cointelegraph.com/news/market-update-2', + source: 'Cointelegraph', + url: 'https://cointelegraph.com/news/market-update-1', + }, + { + source: 'Cointelegraph duplicate', url: 'https://cointelegraph.com/news/market-update-2', }, { source: 'theblock.co', url: 'https://www.theblock.co/post/1234' }, @@ -26,11 +61,33 @@ describe('MarketInsightsTrendItem', () => { , ); - expect(getByText(trend.title)).toBeOnTheScreen(); - expect(getByText(trend.description)).toBeOnTheScreen(); + // Cointelegraph entries dedupe to one favicon, plus The Block = 2 unique sources. + // Label: "Cointelegraph +1" (2 sources - 1 named = 1 remaining). + expect(getByText('Cointelegraph +1')).toBeOnTheScreen(); + expect(UNSAFE_getAllByType(Image)).toHaveLength(2); + }); - const sourceIcons = UNSAFE_getAllByType(Image); - expect(sourceIcons).toHaveLength(2); + it('renders X icon for tweet sources instead of a favicon Image', () => { + const trend = { + title: 'Cycle discussion', + description: 'Traders discuss repeating patterns on X.', + articles: [], + tweets: [ + { + author: 'ardizor', + contentSummary: 'Same cycle playbook.', + date: '2026-02-17', + url: 'https://x.com/ardizor/status/123', + }, + ], + }; + + const { UNSAFE_queryAllByType } = renderWithProvider( + , + ); + + // X source uses Icon component, not Image. + expect(UNSAFE_queryAllByType(Image)).toHaveLength(0); }); it('calls onPress when trend item is tapped', () => { @@ -54,28 +111,4 @@ describe('MarketInsightsTrendItem', () => { fireEvent.press(getByTestId('trend-item')); expect(onPress).toHaveBeenCalledTimes(1); }); - - it('shows X icon when trend has only tweets and no articles', () => { - const trend = { - title: 'Developer debates', - description: 'Discussions heat up on consensus.', - articles: [], - tweets: [ - { - author: 'adam3us', - contentSummary: 'Minority protections matter.', - date: '2026-02-17', - url: 'https://x.com/adam3us/status/123', - }, - ], - }; - - const { getByText, UNSAFE_getAllByType } = renderWithProvider( - , - ); - - expect(getByText(trend.title)).toBeOnTheScreen(); - const sourceIcons = UNSAFE_getAllByType(Image); - expect(sourceIcons).toHaveLength(1); - }); }); diff --git a/app/components/UI/MarketInsights/components/MarketInsightsTrendItem/MarketInsightsTrendItem.tsx b/app/components/UI/MarketInsights/components/MarketInsightsTrendItem/MarketInsightsTrendItem.tsx index 677b9be6749a..3d61970bc633 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsTrendItem/MarketInsightsTrendItem.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsTrendItem/MarketInsightsTrendItem.tsx @@ -1,42 +1,19 @@ import React, { useMemo } from 'react'; -import { Image, Pressable } from 'react-native'; +import { Pressable } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, + BoxAlignItems, + BoxFlexDirection, Text, - TextVariant, - TextColor, FontWeight, - BoxFlexDirection, - BoxAlignItems, + TextColor, + TextVariant, } from '@metamask/design-system-react-native'; +import type { MarketInsightsSource } from '@metamask/ai-controllers'; import type { MarketInsightsTrendItemProps } from './MarketInsightsTrendItem.types'; -import { getFaviconUrl } from '../../utils/marketInsightsFormatting'; - -const SOURCE_ICON_IMAGE_STYLE = { width: 16, height: 16, borderRadius: 8 }; - -// StackedSourceIcons renders overlapping circular favicons for article sources. -const StackedSourceIcons: React.FC<{ sources: string[] }> = ({ sources }) => ( - - {sources.map((source, index) => ( - 0 ? '-ml-1.5' : '' - }`} - > - - - ))} - -); +import { getUniqueSourcesByFavicon } from '../../utils/marketInsightsFormatting'; +import SourceLogoGroup from '../SourceLogoGroup'; const MarketInsightsTrendItem: React.FC = ({ trend, @@ -45,26 +22,34 @@ const MarketInsightsTrendItem: React.FC = ({ }) => { const tw = useTailwind(); const uniqueSources = useMemo(() => { - const seenFaviconUrls = new Set(); - const fromArticles = trend.articles.reduce((acc, article) => { - const sourceSeed = article.url || article.source; - const faviconUrl = getFaviconUrl(sourceSeed); - if (seenFaviconUrls.has(faviconUrl)) { - return acc; - } - seenFaviconUrls.add(faviconUrl); - acc.push(sourceSeed); - return acc; - }, []); - - const hasTweets = (trend.tweets?.length ?? 0) > 0; - if (hasTweets && !seenFaviconUrls.has(getFaviconUrl('x.com'))) { - return [...fromArticles, 'x.com']; - } + const articleSources: MarketInsightsSource[] = trend.articles.map( + (article) => ({ + name: article.source, + type: 'news', + url: article.url || article.source, + }), + ); + const tweetSources: MarketInsightsSource[] = (trend.tweets ?? []).map( + (tweet) => ({ + name: 'X', + type: 'social', + url: tweet.url || 'https://x.com', + }), + ); - return fromArticles; + return getUniqueSourcesByFavicon([...articleSources, ...tweetSources]); }, [trend.articles, trend.tweets]); + const firstSource = uniqueSources[0]; + const remainingCount = Math.max(0, uniqueSources.length - 1); + const sourceLabel = (() => { + if (!firstSource) return null; + if (firstSource.name === 'X' && remainingCount === 0) return null; + return remainingCount > 0 + ? `${firstSource.name} +${remainingCount}` + : firstSource.name; + })(); + return ( = ({ accessibilityRole={onPress ? 'button' : undefined} > @@ -84,9 +69,23 @@ const MarketInsightsTrendItem: React.FC = ({ {trend.description} - {uniqueSources.length > 0 ? ( - - ) : null} + {uniqueSources.length > 0 && ( + + + {sourceLabel ? ( + + {sourceLabel} + + ) : null} + + )} ); }; diff --git a/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.test.tsx b/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.test.tsx index 6c303c8e8635..d9dd7ac0f94d 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.test.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.test.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { Linking } from 'react-native'; import { fireEvent } from '@testing-library/react-native'; import renderWithProvider from '../../../../util/test/renderWithProvider'; import MarketInsightsTrendSourcesBottomSheet from './MarketInsightsTrendSourcesBottomSheet'; @@ -39,15 +38,7 @@ jest.mock( ); describe('MarketInsightsTrendSourcesBottomSheet', () => { - beforeEach(() => { - jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('renders article and tweet sources and opens their URLs', () => { + it('renders article and tweet sources and triggers source callback', () => { const onClose = jest.fn(); const onSourcePress = jest.fn(); const articleUrl = 'https://www.coindesk.com/article'; @@ -88,11 +79,9 @@ describe('MarketInsightsTrendSourcesBottomSheet', () => { fireEvent.press(getByText('coindesk.com')); expect(onSourcePress).toHaveBeenCalledWith(articleUrl); - expect(Linking.openURL).toHaveBeenCalledWith(articleUrl); fireEvent.press(getByText('@adam3us')); expect(onSourcePress).toHaveBeenCalledWith(tweetUrl); - expect(Linking.openURL).toHaveBeenCalledWith(tweetUrl); }); it('renders safely when hidden and has no callbacks', () => { diff --git a/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.tsx b/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.tsx index 508a3bd9bf45..9033a0b7d44b 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsTrendSourcesBottomSheet.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef } from 'react'; -import { Image, Linking, Pressable, ScrollView } from 'react-native'; +import { Image, Pressable, ScrollView } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, @@ -13,7 +13,6 @@ import { FontWeight, BoxFlexDirection, BoxAlignItems, - BoxJustifyContent, } from '@metamask/design-system-react-native'; import type { MarketInsightsArticle, @@ -24,7 +23,11 @@ import BottomSheet, { } from '../../../../component-library/components/BottomSheets/BottomSheet'; import BottomSheetHeader from '../../../../component-library/components/BottomSheets/BottomSheetHeader'; import { strings } from '../../../../../locales/i18n'; -import { getFaviconUrl } from '../utils/marketInsightsFormatting'; +import { + formatRelativeTime, + getFaviconUrl, + getNormalizedHandle, +} from '../utils/marketInsightsFormatting'; interface MarketInsightsTrendSourcesBottomSheetProps { isVisible: boolean; @@ -60,7 +63,6 @@ const MarketInsightsTrendSourcesBottomSheet: React.FC< const handleSourcePress = useCallback( (url: string) => { onSourcePress?.(url); - Linking.openURL(url); }, [onSourcePress], ); @@ -93,34 +95,76 @@ const MarketInsightsTrendSourcesBottomSheet: React.FC< onPress={() => handleSourcePress(article.url)} style={({ pressed }) => tw.style( - 'flex-row items-center py-3 border-b border-muted', + 'flex-row items-start py-3 border-b border-muted', pressed && 'opacity-70', ) } > - - - - - {article.source} - - + + {article.title} + + + + + + - {article.title} - + + + + + + {article.source} + + {article.date ? ( + <> + + {'•'} + + + {formatRelativeTime(article.date, { nowLabel: 'now' })} + + + ) : null} + + - ))} @@ -130,40 +174,76 @@ const MarketInsightsTrendSourcesBottomSheet: React.FC< onPress={() => handleSourcePress(tweet.url)} style={({ pressed }) => tw.style( - 'flex-row items-center py-3 border-b border-muted', + 'flex-row items-start py-3 border-b border-muted', pressed && 'opacity-70', ) } > - - - - - @{tweet.author} - - + + {tweet.contentSummary} + + + + + + - {tweet.contentSummary} - + + + + + + {getNormalizedHandle(tweet.author)} + + {tweet.date ? ( + <> + + {'•'} + + + {formatRelativeTime(tweet.date, { nowLabel: 'now' })} + + + ) : null} + + - ))} diff --git a/app/components/UI/MarketInsights/components/MarketInsightsTweetCard/MarketInsightsTweetCard.test.tsx b/app/components/UI/MarketInsights/components/MarketInsightsTweetCard/MarketInsightsTweetCard.test.tsx index 8afbd26669db..03d9040ce0bd 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsTweetCard/MarketInsightsTweetCard.test.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsTweetCard/MarketInsightsTweetCard.test.tsx @@ -4,15 +4,6 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider'; import MarketInsightsTweetCard from './MarketInsightsTweetCard'; describe('MarketInsightsTweetCard', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-02-17T12:00:00.000Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - it('renders tweet metadata and handles card press', () => { const onPress = jest.fn(); @@ -31,8 +22,7 @@ describe('MarketInsightsTweetCard', () => { />, ); - expect(getByText('analyst_alpha')).toBeOnTheScreen(); - expect(getByText('1h ago')).toBeOnTheScreen(); + expect(getByText('@analyst_alpha')).toBeOnTheScreen(); fireEvent.press(getByTestId('market-insights-tweet-card')); expect(onPress).toHaveBeenCalledTimes(1); diff --git a/app/components/UI/MarketInsights/components/MarketInsightsTweetCard/MarketInsightsTweetCard.tsx b/app/components/UI/MarketInsights/components/MarketInsightsTweetCard/MarketInsightsTweetCard.tsx index 7af3ffdb1e85..b74cc6f412a4 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsTweetCard/MarketInsightsTweetCard.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsTweetCard/MarketInsightsTweetCard.tsx @@ -16,31 +16,30 @@ import { FontWeight, } from '@metamask/design-system-react-native'; import type { MarketInsightsTweetCardProps } from './MarketInsightsTweetCard.types'; -import { formatRelativeTime } from '../../utils/marketInsightsFormatting'; +import { + getNormalizedHandle, + formatRelativeTime, +} from '../../utils/marketInsightsFormatting'; -// MarketInsightsTweetCard renders a social media post card. const MarketInsightsTweetCard: React.FC = ({ tweet, onPress, testID, }) => { const tw = useTailwind(); - const relativeTime = useMemo( - () => formatRelativeTime(tweet.date, { nowLabel: 'now' }), - [tweet.date], - ); + const timeAgo = useMemo(() => formatRelativeTime(tweet.date), [tweet.date]); return ( - tw.style('rounded-2xl bg-muted p-3', pressed && 'opacity-80') + tw.style('rounded-2xl bg-muted p-4', pressed && 'opacity-80') } testID={testID} > {tweet.contentSummary} @@ -53,19 +52,35 @@ const MarketInsightsTweetCard: React.FC = ({ - - {tweet.author} + + {getNormalizedHandle(tweet.author)} + + + {'•'} - - {relativeTime} + + {timeAgo} diff --git a/app/components/UI/MarketInsights/components/SourceLogoGroup.tsx b/app/components/UI/MarketInsights/components/SourceLogoGroup.tsx new file mode 100644 index 000000000000..90b6fb7e7d6e --- /dev/null +++ b/app/components/UI/MarketInsights/components/SourceLogoGroup.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { Image } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxFlexDirection, + Icon, + IconName, + IconSize, + IconColor, +} from '@metamask/design-system-react-native'; +import type { MarketInsightsSource } from '@metamask/ai-controllers'; +import { getFaviconUrl, isXSourceUrl } from '../utils/marketInsightsFormatting'; + +const MAX_VISIBLE_SOURCE_LOGOS = 3; + +interface SourceLogoGroupProps { + /** Pre-deduplicated sources; only the first MAX_VISIBLE_SOURCE_LOGOS are shown. */ + sources: MarketInsightsSource[]; +} + +/** + * Renders a stacked row of up to three circular source logos. + */ +const SourceLogoGroup: React.FC = ({ sources }) => { + const tw = useTailwind(); + const visibleLogos = sources.slice(0, MAX_VISIBLE_SOURCE_LOGOS); + + if (visibleLogos.length === 0) { + return null; + } + + return ( + + {visibleLogos.map((source, index) => ( + 0 ? '-ml-1' : '' + }`} + > + {isXSourceUrl(source.url) ? ( + + + + ) : ( + + )} + + ))} + + ); +}; + +export default SourceLogoGroup; diff --git a/app/components/UI/MarketInsights/hooks/useMarketInsights.ts b/app/components/UI/MarketInsights/hooks/useMarketInsights.ts index 6ef4e254e11c..b49f228397d1 100644 --- a/app/components/UI/MarketInsights/hooks/useMarketInsights.ts +++ b/app/components/UI/MarketInsights/hooks/useMarketInsights.ts @@ -32,7 +32,7 @@ export const useMarketInsights = ( isEnabled = false, ): UseMarketInsightsResult => { const [report, setReport] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(Boolean(isEnabled && caip19Id)); const [error, setError] = useState(null); const fetchInsights = useCallback(async () => { diff --git a/app/components/UI/MarketInsights/utils/marketInsightsFormatting.test.ts b/app/components/UI/MarketInsights/utils/marketInsightsFormatting.test.ts index e3d9ec622d5a..b966d8f3e76f 100644 --- a/app/components/UI/MarketInsights/utils/marketInsightsFormatting.test.ts +++ b/app/components/UI/MarketInsights/utils/marketInsightsFormatting.test.ts @@ -1,4 +1,4 @@ -import { getFaviconUrl } from './marketInsightsFormatting'; +import { getFaviconUrl, isXSourceUrl } from './marketInsightsFormatting'; describe('getFaviconUrl', () => { it('uses hostname when source is a full URL', () => { @@ -13,3 +13,32 @@ describe('getFaviconUrl', () => { ); }); }); + +describe('isXSourceUrl', () => { + it('matches bare x and twitter strings', () => { + expect(isXSourceUrl('x')).toBe(true); + expect(isXSourceUrl('twitter')).toBe(true); + expect(isXSourceUrl('X')).toBe(true); + expect(isXSourceUrl('Twitter')).toBe(true); + }); + + it('matches x.com and twitter.com URLs', () => { + expect(isXSourceUrl('https://x.com/user/status/123')).toBe(true); + expect(isXSourceUrl('https://twitter.com/user/status/123')).toBe(true); + expect(isXSourceUrl('https://www.x.com/user')).toBe(true); + expect(isXSourceUrl('x.com')).toBe(true); + }); + + it('does not match domains that contain x.com as a substring', () => { + expect(isXSourceUrl('https://box.com')).toBe(false); + expect(isXSourceUrl('https://max.com')).toBe(false); + expect(isXSourceUrl('https://coindesk.com/article-about-x.com')).toBe( + false, + ); + }); + + it('does not match unrelated domains', () => { + expect(isXSourceUrl('https://coindesk.com')).toBe(false); + expect(isXSourceUrl('https://theblock.co')).toBe(false); + }); +}); diff --git a/app/components/UI/MarketInsights/utils/marketInsightsFormatting.ts b/app/components/UI/MarketInsights/utils/marketInsightsFormatting.ts index aec495bf6395..08df00e09db2 100644 --- a/app/components/UI/MarketInsights/utils/marketInsightsFormatting.ts +++ b/app/components/UI/MarketInsights/utils/marketInsightsFormatting.ts @@ -1,3 +1,5 @@ +import type { MarketInsightsSource } from '@metamask/ai-controllers'; + export interface RelativeTimeOptions { nowLabel?: string; } @@ -38,3 +40,58 @@ export const formatRelativeTime = ( if (diffHours < 24) return `${diffHours}h ago`; return `${diffDays}d ago`; }; + +export const getNormalizedHandle = (author: string): string => + `@${author.replace(/^@+/, '')}`; + +export const isXSourceUrl = (source: string): boolean => { + const trimmedSource = source.trim(); + const normalized = trimmedSource.toLowerCase(); + + if (normalized === 'x' || normalized === 'twitter') { + return true; + } + + try { + const normalizedSource = trimmedSource.includes('://') + ? trimmedSource + : `https://${trimmedSource}`; + const hostname = new URL(normalizedSource).hostname + .replace(/^www\./, '') + .toLowerCase(); + return ( + hostname === 'x.com' || + hostname.endsWith('.x.com') || + hostname === 'twitter.com' || + hostname.endsWith('.twitter.com') + ); + } catch { + return false; + } +}; + +const SAFE_URL_SCHEMES = ['http:', 'https:']; + +export const isSafeUrl = (url: string): boolean => { + try { + const parsed = new URL(url); + return SAFE_URL_SCHEMES.includes(parsed.protocol); + } catch { + return false; + } +}; + +export const getUniqueSourcesByFavicon = ( + sources: MarketInsightsSource[], +): MarketInsightsSource[] => { + const seenFaviconUrls = new Set(); + + return sources.filter((source) => { + const faviconUrl = getFaviconUrl(source.url); + if (seenFaviconUrls.has(faviconUrl)) { + return false; + } + seenFaviconUrls.add(faviconUrl); + return true; + }); +}; diff --git a/app/components/UI/Name/__snapshots__/Name.test.tsx.snap b/app/components/UI/Name/__snapshots__/Name.test.tsx.snap index 70a0fc277d60..d5007926157e 100644 --- a/app/components/UI/Name/__snapshots__/Name.test.tsx.snap +++ b/app/components/UI/Name/__snapshots__/Name.test.tsx.snap @@ -51,7 +51,7 @@ exports[`Name recognized address renders account wallet name 1`] = ` isFullScreenModal ? null : ( navigation.pop()} testID={CommonSelectorsIDs.BACK_ARROW_BUTTON} - size={ButtonIconSize.Lg} + size={ButtonIconSize.Md} iconName={IconName.ArrowLeft} iconColor={IconColor.Default} /> @@ -1651,7 +1644,7 @@ export function getPerpsTransactionsDetailsNavbar(navigation, title) { ), headerRight: () => , @@ -1684,7 +1677,7 @@ export function getPerpsMarketDetailsNavbar(navigation, title) { ), }; @@ -1901,7 +1894,7 @@ export function getStakingNavbar( headerLeft: () => hasBackButton ? ( navigation.pop()} testID={CommonSelectorsIDs.BACK_ARROW_BUTTON} - size={ButtonIconSize.Lg} + size={ButtonIconSize.Md} iconName={IconName.ArrowLeft} iconColor={IconColor.Default} /> @@ -1955,146 +1948,11 @@ export function getDeFiProtocolPositionDetailsNavbarOptions(navigation) { }; } -/** - * Function that returns the navigation options for the Ramps Build Quote screen - * - * @param {Object} navigation - Navigation object required to navigate between screens - * @param {Object} options - Options for the navbar - * @param {string} [options.tokenName] - Name of the selected token (used for avatar) - * @param {string} [options.tokenSymbol] - Symbol/ticker of the selected token (e.g., "ETH") - * @param {string} [options.tokenIconUrl] - URL for the token icon - * @param {string} [options.networkName] - Name of the network - * @param {Object} [options.networkImageSource] - Image source for the network icon - * @param {Function} [options.onSettingsPress] - Callback for settings button press - * @returns {Object} - Navigation options object - */ -export function getRampsBuildQuoteNavbarOptions( - navigation, - { - tokenName, - tokenSymbol, - tokenIconUrl, - networkName, - networkImageSource, - onSettingsPress, - } = {}, -) { - const innerStyles = StyleSheet.create({ - centerContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 16, - }, - labelsContainer: { - gap: 0, - marginTop: -2, - }, - backButton: { - marginLeft: 16, - }, - skeletonAvatar: { - borderRadius: 20, - }, - skeletonTitle: { - borderRadius: 4, - }, - skeletonSubtitle: { - borderRadius: 4, - marginTop: 4, - }, - }); - - const isLoading = !tokenName || !tokenSymbol || !networkName; - - return { - header: () => ( - navigation.goBack()} - size={ButtonIconSize.Lg} - iconName={IconName.ArrowLeft} - iconColor={IconColor.Default} - testID="build-quote-back-button" - /> - } - endAccessory={ - - } - > - - {isLoading ? ( - <> - - - - - - - ) : ( - <> - - } - > - - - - - {strings('fiat_on_ramp.buy', { ticker: tokenSymbol })} - - - {strings('fiat_on_ramp.on_network', { networkName })} - - - - )} - - - ), - }; -} - export function getRampsOrderDetailsNavbarOptions( navigation, { title, showBack = true }, theme, - onClose = undefined, + onClose, ) { let startButtonIconProps; if (showBack) { diff --git a/app/components/UI/NavbarBrowserTitle/__snapshots__/index.test.tsx.snap b/app/components/UI/NavbarBrowserTitle/__snapshots__/index.test.tsx.snap index 341e1674b1ab..c6d500052cb1 100644 --- a/app/components/UI/NavbarBrowserTitle/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/NavbarBrowserTitle/__snapshots__/index.test.tsx.snap @@ -27,7 +27,7 @@ exports[`NavbarBrowserTitle should render correctly 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 14, "textAlign": "center", @@ -48,7 +48,7 @@ exports[`NavbarBrowserTitle should render correctly 1`] = ` "fontSize": 14, }, { - "color": "#121314", + "color": "#131416", "marginLeft": 10, "marginTop": 2, }, @@ -92,7 +92,7 @@ exports[`NavbarBrowserTitle should render correctly 1`] = ` numberOfLines={1} style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 11, "lineHeight": 11, diff --git a/app/components/UI/NetworkImages/__snapshots__/index.test.tsx.snap b/app/components/UI/NetworkImages/__snapshots__/index.test.tsx.snap index dd59c30e8439..da14fc2281fe 100644 --- a/app/components/UI/NetworkImages/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/NetworkImages/__snapshots__/index.test.tsx.snap @@ -37,7 +37,7 @@ exports[`NetworkImageComponent should render correctly 1`] = ` testID="button-icon-test-id" > ({ }), })); -jest.mock('../../hooks/useMetrics', () => ({ - useMetrics: () => ({ +jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ trackEvent: mockTrackEvent, createEventBuilder: mockCreateEventBuilder, addTraitsToUser: mockAddTraitsToUser, }), - MetaMetricsEvents: { - ASSET_FILTER_SELECTED: 'asset_filter_selected', - ASSET_FILTER_CUSTOM_SELECTED: 'asset_filter_custom_selected', - }, })); jest.mock('../../hooks/useNetworksByNamespace/useNetworksByNamespace', () => ({ @@ -259,14 +256,6 @@ jest.mock('../../../core/Engine', () => ({ }, })); -jest.mock('../../../core/Analytics', () => ({ - MetaMetrics: { - getInstance: () => ({ - addTraitsToUser: mockAddTraitsToUser, - }), - }, -})); - jest.mock('../../../util/metrics/MultichainAPI/networkMetricUtils', () => ({ removeItemFromChainIdList: (chainId: string) => ({ removedChainId: chainId }), })); @@ -799,7 +788,7 @@ describe('NetworkManager Component', () => { // Assert - Analytics event is tracked expect(mockTrackEvent).toHaveBeenCalledWith({ type: 'test_event' }); expect(mockCreateEventBuilder).toHaveBeenCalledWith( - 'asset_filter_selected', + MetaMetricsEvents.ASSET_FILTER_SELECTED, ); }); }); diff --git a/app/components/UI/NetworkManager/index.tsx b/app/components/UI/NetworkManager/index.tsx index 5c7a712f0e9e..10c172d7a93d 100644 --- a/app/components/UI/NetworkManager/index.tsx +++ b/app/components/UI/NetworkManager/index.tsx @@ -14,7 +14,8 @@ import { toHex } from '@metamask/controller-utils'; import Engine from '../../../core/Engine'; import { removeItemFromChainIdList } from '../../../util/metrics/MultichainAPI/networkMetricUtils'; import { useTheme } from '../../../util/theme'; -import { MetaMetricsEvents, useMetrics } from '../../hooks/useMetrics'; +import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../core/Analytics/MetaMetrics.events'; import { strings } from '../../../../locales/i18n'; import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader/BottomSheetHeader'; import HeaderCompactStandard from '../../../component-library/components-temp/HeaderCompactStandard'; @@ -82,7 +83,7 @@ const NetworkManager = () => { const navigation = useNavigation(); const { colors } = useTheme(); const { styles } = useStyles(createStyles, { colors }); - const { trackEvent, createEventBuilder, addTraitsToUser } = useMetrics(); + const { trackEvent, createEventBuilder, addTraitsToUser } = useAnalytics(); const { disableNetwork, enabledNetworksByNamespace } = useNetworkEnablement(); const enabledNetworks = useMemo(() => { diff --git a/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap b/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap index 9617f9a86482..f31bd1e7c4dd 100644 --- a/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap @@ -125,7 +125,7 @@ exports[`NetworkDetails renders correctly 1`] = ` ({ PopularList: [], })); -jest.mock('../../hooks/useMetrics', () => ({ - useMetrics: jest.fn(), +jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: jest.fn(), })); jest.mock('../../../core/Engine', () => ({ @@ -216,7 +217,7 @@ describe('NetworkMultiSelector', () => { typeof useNetworksToUse >; const mockUseSelector = jest.mocked(useSelector); - const mockUseMetrics = useMetrics as jest.MockedFunction; + const mockUseAnalytics = jest.mocked(useAnalytics); // Shared helper functions for all tests const createMockNetwork = ( @@ -461,19 +462,12 @@ describe('NetworkMultiSelector', () => { build: mockBuild, }); - mockUseMetrics.mockReturnValue({ - trackEvent: mockTrackEvent, - createEventBuilder: mockCreateEventBuilder, - isEnabled: () => true, - enable: jest.fn(), - addTraitsToUser: jest.fn(), - createDataDeletionTask: jest.fn(), - checkDataDeleteStatus: jest.fn(), - getDeleteRegulationCreationDate: jest.fn(), - getDeleteRegulationId: jest.fn(), - isDataRecorded: jest.fn(), - getMetaMetricsId: jest.fn(), - }); + mockUseAnalytics.mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), + ); }); // TODO: Refactor tests - they aren't up to par diff --git a/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.tsx b/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.tsx index 8561e92a1962..376323262486 100644 --- a/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.tsx +++ b/app/components/UI/NetworkMultiSelector/NetworkMultiSelector.tsx @@ -36,7 +36,7 @@ import { selectIsEvmNetworkSelected, selectSelectedNonEvmNetworkChainId, } from '../../../selectors/multichainNetworkController'; -import { useMetrics } from '../../hooks/useMetrics'; +import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { getDecimalChainId } from '../../../util/networks'; import { toHex } from '@metamask/controller-utils'; @@ -94,7 +94,7 @@ const NetworkMultiSelector = ({ const nonEvmNetworkConfigurations = useSelector( selectNonEvmNetworkConfigurationsByChainId, ); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const isEvmSelected = useSelector(selectIsEvmNetworkSelected); const selectedNonEvmChainId = useSelector(selectSelectedNonEvmNetworkChainId); const currentEvmChainId = useSelector(selectEvmChainId); diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.styles.ts b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.styles.ts index 181b8ed0e06b..ffb039e413cc 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.styles.ts +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.styles.ts @@ -27,7 +27,6 @@ const createStyles = (params: { theme: Theme }) => { }, noNetworkFeeContainer: { alignSelf: 'center', - height: 22, }, }); }; diff --git a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx index 34c373ba32c7..0211b8ac400b 100644 --- a/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx +++ b/app/components/UI/NetworkMultiSelectorList/NetworkMultiSelectorList.tsx @@ -29,7 +29,6 @@ import Cell, { } from '../../../component-library/components/Cells/Cell/index.ts'; import Text, { TextVariant, - TextColor, } from '../../../component-library/components/Texts/Text/index.ts'; import { isTestNet } from '../../../util/networks/index.js'; import hideProtocolFromUrl from '../../../util/hideProtocolFromUrl'; @@ -284,13 +283,17 @@ const NetworkMultiSelectList = ({ - - {strings('networks.no_network_fee')} - + {strings('networks.no_network_fee')} ) : ( diff --git a/app/components/UI/NetworkSelectorList/__snapshots__/NetworkSelectorList.test.tsx.snap b/app/components/UI/NetworkSelectorList/__snapshots__/NetworkSelectorList.test.tsx.snap index 47f51cb0876e..f4d37e1f85f3 100644 --- a/app/components/UI/NetworkSelectorList/__snapshots__/NetworkSelectorList.test.tsx.snap +++ b/app/components/UI/NetworkSelectorList/__snapshots__/NetworkSelectorList.test.tsx.snap @@ -113,7 +113,7 @@ exports[`NetworkSelectorList renders correctly with default props 1`] = ` { "alignItems": "center", "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", + "borderColor": "#949596", "borderRadius": 4, "borderWidth": 2, "height": 20, @@ -186,7 +186,7 @@ exports[`NetworkSelectorList renders correctly with default props 1`] = ` numberOfLines={1} style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -350,7 +350,7 @@ exports[`NetworkSelectorList renders correctly with default props 1`] = ` numberOfLines={1} style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -370,7 +370,7 @@ exports[`NetworkSelectorList renders correctly with default props 1`] = ` accessible={true} style={ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "bottom": 0, "flexDirection": "row", "left": 0, diff --git a/app/components/UI/NetworkVerificationInfo/__snapshots__/NetworkVerificationInfo.test.tsx.snap b/app/components/UI/NetworkVerificationInfo/__snapshots__/NetworkVerificationInfo.test.tsx.snap index 241cd29a5fc4..10b38dec4d98 100644 --- a/app/components/UI/NetworkVerificationInfo/__snapshots__/NetworkVerificationInfo.test.tsx.snap +++ b/app/components/UI/NetworkVerificationInfo/__snapshots__/NetworkVerificationInfo.test.tsx.snap @@ -39,9 +39,9 @@ exports[`NetworkVerificationInfo renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, } @@ -71,7 +71,7 @@ exports[`NetworkVerificationInfo renders correctly 1`] = ` { "alignItems": "center", "alignSelf": "center", - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderRadius": 16, "flexDirection": "row", "height": 32, @@ -117,7 +117,7 @@ exports[`NetworkVerificationInfo renders correctly 1`] = ` numberOfLines={1} style={ { - "color": "#121314", + "color": "#131416", "flexShrink": 1, "fontFamily": "Geist-Regular", "fontSize": 16, @@ -179,11 +179,11 @@ exports[`NetworkVerificationInfo renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, } } > @@ -217,7 +217,7 @@ exports[`NetworkVerificationInfo renders correctly 1`] = ` testID="banner-close-button-icon" > { expect(mockAbortDetection).not.toHaveBeenCalled(); }); + + it('tracks Position Screen Viewed event when isFullView is true and data is loaded', async () => { + const mockCollectibles = { '0x1': [mockNft] }; + const store = mockStore(initialState); + + // Simulate fetch cycle: fetching true (detection started) then false (data loaded) + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: true, + }); + const { rerender } = render( + + + , + ); + act(() => { + jest.advanceTimersByTime(0); + }); + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + }); + rerender( + + + , + ); + + await waitFor(() => { + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + item_count: 1, + location: 'homepage', + is_empty: false, + screen_type: 'nfts', + }), + }), + ); + }); + }); + + it('tracks Position Screen Viewed with is_empty true when user has no NFTs', async () => { + const store = mockStore(initialState); + + // Simulate fetch cycle: fetching true then false (data loaded, empty) + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: {}, + isNftFetching: true, + }); + const { rerender } = render( + + + , + ); + act(() => { + jest.advanceTimersByTime(0); + }); + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: {}, + isNftFetching: false, + }); + rerender( + + + , + ); + + await waitFor(() => { + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + item_count: 0, + is_empty: true, + screen_type: 'nfts', + }), + }), + ); + }); + }); + + it('does not track Position Screen Viewed on initial mount before any fetch has run', async () => { + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: {}, + isNftFetching: false, + }); + const store = mockStore(initialState); + + render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + const positionScreenViewedCalls = mockTrackEvent.mock.calls.filter( + (call) => + call[0]?.properties?.screen_type === 'nfts' && + call[0]?.properties?.location === 'homepage', + ); + expect(positionScreenViewedCalls).toHaveLength(0); + }); + + it('does not track Position Screen Viewed when isFullView is false', async () => { + const mockCollectibles = { '0x1': [mockNft] }; + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + }); + const store = mockStore(initialState); + + render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + const positionScreenViewedCalls = mockTrackEvent.mock.calls.filter( + (call) => + call[0]?.properties?.screen_type === 'nfts' && + call[0]?.properties?.location === 'homepage', + ); + expect(positionScreenViewedCalls).toHaveLength(0); + }); + }); + + it('does not track Position Screen Viewed while NFTs are still fetching', async () => { + const mockCollectibles = { '0x1': [mockNft] }; + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: true, + }); + const store = mockStore(initialState); + + render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + const positionScreenViewedCalls = mockTrackEvent.mock.calls.filter( + (call) => + call[0]?.properties?.screen_type === 'nfts' && + call[0]?.properties?.location === 'homepage', + ); + expect(positionScreenViewedCalls).toHaveLength(0); + }); + }); }); diff --git a/app/components/UI/NftGrid/NftGrid.tsx b/app/components/UI/NftGrid/NftGrid.tsx index 367f5efdebd1..a2a1c2645f89 100644 --- a/app/components/UI/NftGrid/NftGrid.tsx +++ b/app/components/UI/NftGrid/NftGrid.tsx @@ -114,6 +114,19 @@ const NftGrid = forwardRef( useNftDetection(); const isInitialMount = useRef(true); + const hasTrackedScreenViewRef = useRef(false); + const hasSeenNftFetchingRef = useRef(false); + + const isNftFetchingProgress = useSelector(isNftFetchingProgressSelector); + + // Mark that a fetch has been initiated (useFocusEffect/detectNfts sets isNftFetchingProgress + // to true). Only track "Position Screen Viewed" after at least one fetch cycle, so we don't + // fire with stale empty data before detection runs (isNftFetchingProgress defaults to false). + useEffect(() => { + if (isNftFetchingProgress) { + hasSeenNftFetchingRef.current = true; + } + }, [isNftFetchingProgress]); const allFilteredCollectibles: Nft[] = useMemo(() => { trace({ name: TraceName.LoadCollectibles }); @@ -140,6 +153,33 @@ const NftGrid = forwardRef( return itemsToProcess; }, [allFilteredCollectibles, maxItems]); + useEffect(() => { + if ( + !isFullView || + isNftFetchingProgress || + !hasSeenNftFetchingRef.current || + hasTrackedScreenViewRef.current + ) + return; + hasTrackedScreenViewRef.current = true; + trackEvent( + createEventBuilder(MetaMetricsEvents.POSITION_SCREEN_VIEWED) + .addProperties({ + item_count: allFilteredCollectibles.length, + location: 'homepage', + is_empty: allFilteredCollectibles.length === 0, + screen_type: 'nfts', + }) + .build(), + ); + }, [ + isFullView, + isNftFetchingProgress, + allFilteredCollectibles.length, + trackEvent, + createEventBuilder, + ]); + // Trigger NFT detection when enabled networks change (after initial mount) useEffect(() => { if (isInitialMount.current) { diff --git a/app/components/UI/Notification/BaseNotification/__snapshots__/index.test.jsx.snap b/app/components/UI/Notification/BaseNotification/__snapshots__/index.test.jsx.snap index fbe47f95ae28..9fb350dafdea 100644 --- a/app/components/UI/Notification/BaseNotification/__snapshots__/index.test.jsx.snap +++ b/app/components/UI/Notification/BaseNotification/__snapshots__/index.test.jsx.snap @@ -21,7 +21,7 @@ exports[`BaseNotification gets icon correctly for each status 1`] = ` activeOpacity={0.8} style={ { - "backgroundColor": "#000000cc", + "backgroundColor": "#0a0d1392", "borderRadius": 8, "flex": 1, "flexDirection": "row", @@ -39,7 +39,7 @@ exports[`BaseNotification gets icon correctly for each status 1`] = ` { - const inset = { top: 0, right: 0, bottom: 0, left: 0 }; - const frame = { width: 0, height: 0, x: 0, y: 0 }; - return { - SafeAreaProvider: jest.fn().mockImplementation(({ children }) => children), - SafeAreaConsumer: jest - .fn() - .mockImplementation(({ children }) => children(inset)), - useSafeAreaInsets: jest.fn().mockImplementation(() => inset), - useSafeAreaFrame: jest.fn().mockImplementation(() => frame), - }; -}); - -jest.mock('@react-navigation/native', () => { - const actualReactNavigation = jest.requireActual('@react-navigation/native'); - return { - ...actualReactNavigation, - useNavigation: () => ({ - navigate: jest.fn(), - setOptions: jest.fn(), - goBack: jest.fn(), - reset: jest.fn(), - dangerouslyGetParent: () => ({ - pop: jest.fn(), - }), - }), - }; -}); - -describe('ResetNotificationsModal', () => { - it('should render correctly', () => { - const { toJSON } = renderWithProvider(); - expect(toJSON()).toMatchSnapshot(); - }); -}); diff --git a/app/components/UI/Notification/ResetNotificationsModal/__snapshots__/ResetNotificationsModal.test.tsx.snap b/app/components/UI/Notification/ResetNotificationsModal/__snapshots__/ResetNotificationsModal.test.tsx.snap deleted file mode 100644 index 42f00d0079be..000000000000 --- a/app/components/UI/Notification/ResetNotificationsModal/__snapshots__/ResetNotificationsModal.test.tsx.snap +++ /dev/null @@ -1,283 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ResetNotificationsModal should render correctly 1`] = ` - - - - - - - - - - - - - Reset notifications - - - Resetting notifications, means you're deleting your notifications storage keys and resetting all your notification history. Are you sure you want to do this? - - - - - - Cancel - - - - - - Reset - - - - - - - - -`; diff --git a/app/components/UI/Notification/ResetNotificationsModal/index.tsx b/app/components/UI/Notification/ResetNotificationsModal/index.tsx deleted file mode 100644 index e66fdb3fd8ab..000000000000 --- a/app/components/UI/Notification/ResetNotificationsModal/index.tsx +++ /dev/null @@ -1,75 +0,0 @@ -// Third party dependencies. -import React, { useContext, useEffect, useRef } from 'react'; - -// External dependencies. -import BottomSheet, { - BottomSheetRef, -} from '../../../../component-library/components/BottomSheets/BottomSheet'; -import { strings } from '../../../../../locales/i18n'; - -import { - IconColor, - IconName, - IconSize, -} from '../../../../component-library/components/Icons/Icon'; -import { useResetNotifications } from '../../../../util/notifications/hooks/useNotifications'; -import ModalContent from '../Modal'; -import { ToastContext } from '../../../../component-library/components/Toast'; -import { ToastVariants } from '../../../../component-library/components/Toast/Toast.types'; - -const ResetNotificationsModal = () => { - const bottomSheetRef = useRef(null); - const [isChecked, setIsChecked] = React.useState(false); - const { resetNotifications, loading } = useResetNotifications(); - const { toastRef } = useContext(ToastContext); - const closeBottomSheet = () => bottomSheetRef.current?.onCloseBottomSheet(); - - const showResultToast = () => { - toastRef?.current?.showToast({ - variant: ToastVariants.Plain, - labelOptions: [ - { - label: strings('app_settings.reset_notifications_success'), - isBold: false, - }, - ], - hasNoTimeout: false, - }); - }; - - const handleCta = async () => { - await resetNotifications().then(() => { - showResultToast(); - }); - }; - - const prevLoading = useRef(loading); - useEffect(() => { - if (prevLoading.current && !loading) { - closeBottomSheet(); - } - prevLoading.current = loading; - }, [loading]); - - return ( - - - - ); -}; - -export default ResetNotificationsModal; diff --git a/app/components/UI/OptinMetrics/__snapshots__/index.test.tsx.snap b/app/components/UI/OptinMetrics/__snapshots__/index.test.tsx.snap index 0a58d52ca2f6..8fbe0760b376 100644 --- a/app/components/UI/OptinMetrics/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/OptinMetrics/__snapshots__/index.test.tsx.snap @@ -267,7 +267,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 32, "fontWeight": "700", @@ -284,7 +284,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -301,7 +301,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` onPress={[Function]} style={ { - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderRadius": 12, "marginBottom": 16, "padding": 16, @@ -331,7 +331,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", "fontSize": 14, "letterSpacing": 0, @@ -395,7 +395,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -430,7 +430,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` style={ [ { - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderRadius": 12, "marginBottom": 16, "padding": 16, @@ -461,7 +461,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", "fontSize": 14, "letterSpacing": 0, @@ -492,7 +492,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` { "alignItems": "center", "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", + "borderColor": "#949596", "borderRadius": 4, "borderWidth": 2, "height": 20, @@ -507,7 +507,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -544,7 +544,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot 1`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#121314", + "backgroundColor": "#131416", "borderRadius": 12, "flex": 1, "flexDirection": "row", @@ -958,7 +958,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 32, "fontWeight": "700", @@ -975,7 +975,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -992,7 +992,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar onPress={[Function]} style={ { - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderRadius": 12, "marginBottom": 16, "padding": 16, @@ -1022,7 +1022,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", "fontSize": 14, "letterSpacing": 0, @@ -1086,7 +1086,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -1121,7 +1121,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar style={ [ { - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderRadius": 12, "marginBottom": 16, "padding": 16, @@ -1152,7 +1152,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", "fontSize": 14, "letterSpacing": 0, @@ -1183,7 +1183,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar { "alignItems": "center", "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", + "borderColor": "#949596", "borderRadius": 4, "borderWidth": 2, "height": 20, @@ -1198,7 +1198,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -1235,7 +1235,7 @@ exports[`OptinMetrics Snapshots android render matches snapshot with status bar { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#121314", + "backgroundColor": "#131416", "borderRadius": 12, "flex": 1, "flexDirection": "row", @@ -1761,7 +1761,7 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 32, "fontWeight": "700", @@ -1778,7 +1778,7 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1795,7 +1795,7 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` onPress={[Function]} style={ { - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderRadius": 12, "marginBottom": 16, "padding": 16, @@ -1825,7 +1825,7 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", "fontSize": 14, "letterSpacing": 0, @@ -1889,7 +1889,7 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -1924,7 +1924,7 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` style={ [ { - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderRadius": 12, "marginBottom": 16, "padding": 16, @@ -1955,7 +1955,7 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", "fontSize": 14, "letterSpacing": 0, @@ -1986,7 +1986,7 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` { "alignItems": "center", "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", + "borderColor": "#949596", "borderRadius": 4, "borderWidth": 2, "height": 20, @@ -2001,7 +2001,7 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -2038,7 +2038,7 @@ exports[`OptinMetrics Snapshots iOS renders correctly 1`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#121314", + "backgroundColor": "#131416", "borderRadius": 12, "flex": 1, "flexDirection": "row", diff --git a/app/components/UI/OptinMetrics/index.test.tsx b/app/components/UI/OptinMetrics/index.test.tsx index 2c7de19cf4e7..b356913ad727 100644 --- a/app/components/UI/OptinMetrics/index.test.tsx +++ b/app/components/UI/OptinMetrics/index.test.tsx @@ -5,6 +5,7 @@ import { strings } from '../../../../locales/i18n'; import { MetaMetricsOptInSelectorsIDs } from './MetaMetricsOptIn.testIds'; import { Platform } from 'react-native'; import Device from '../../../util/device'; +import { MetaMetricsEvents } from '../../../core/Analytics'; const { InteractionManager } = jest.requireActual('react-native'); @@ -275,6 +276,94 @@ describe('OptinMetrics', () => { expect(mockAnalytics.optOut).toHaveBeenCalled(); }); }); + + it('tracks METRICS_OPT_OUT when user navigates out with basic usage unchecked', async () => { + renderScreen(OptinMetrics, { name: 'OptinMetrics' }, { state: {} }); + + const basicUsageCheckbox = screen.getByText( + strings('privacy_policy.gather_basic_usage_title'), + ); + + fireEvent.press(basicUsageCheckbox); + + fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); + + await waitFor(() => { + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_OUT.category, + properties: expect.objectContaining({ + updated_after_onboarding: false, + location: 'onboarding_metametrics', + }), + }), + ); + }); + }); + + it('tracks METRICS_OPT_OUT when navigating out via checkbox component uncheck', async () => { + renderScreen(OptinMetrics, { name: 'OptinMetrics' }, { state: {} }); + + const checkboxes = screen.getAllByRole('checkbox'); + const basicUsageCheckbox = checkboxes[0]; + + fireEvent.press(basicUsageCheckbox); + + fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); + + await waitFor(() => { + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_OUT.category, + properties: expect.objectContaining({ + updated_after_onboarding: false, + location: 'onboarding_metametrics', + }), + }), + ); + }); + }); + + it('unchecks marketing when basic usage is unchecked and fires METRICS_OPT_OUT on navigate out', async () => { + renderScreen(OptinMetrics, { name: 'OptinMetrics' }, { state: {} }); + + const marketingCheckbox = screen.getByText( + strings('privacy_policy.checkbox_marketing'), + ); + fireEvent.press(marketingCheckbox); + + const basicUsageCheckbox = screen.getByText( + strings('privacy_policy.gather_basic_usage_title'), + ); + fireEvent.press(basicUsageCheckbox); + + fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); + + await waitFor(() => { + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: MetaMetricsEvents.METRICS_OPT_OUT.category, + properties: expect.objectContaining({ + updated_after_onboarding: false, + location: 'onboarding_metametrics', + }), + }), + ); + }); + + fireEvent.press(screen.getByText(strings('privacy_policy.continue'))); + + await waitFor(() => { + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + has_marketing_consent: false, + is_metrics_opted_in: false, + }), + }), + ); + }); + }); }); describe('Learn more functionality', () => { diff --git a/app/components/UI/OptinMetrics/index.tsx b/app/components/UI/OptinMetrics/index.tsx index 1354a98917e2..6188ec382673 100644 --- a/app/components/UI/OptinMetrics/index.tsx +++ b/app/components/UI/OptinMetrics/index.tsx @@ -152,7 +152,19 @@ const OptinMetrics = () => { dispatch(setDataCollectionForMarketing(isMarketingChecked)); - // Track the analytics preference event first + // Track opt-out event if user opted out of metrics + if (!isBasicUsageChecked) { + metrics.trackEvent( + metrics + .createEventBuilder(MetaMetricsEvents.METRICS_OPT_OUT) + .addProperties({ + updated_after_onboarding: false, + location: 'onboarding_metametrics', + }) + .build(), + ); + } + metrics.trackEvent( metrics .createEventBuilder(MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED) @@ -228,9 +240,8 @@ const OptinMetrics = () => { ); const handleBasicUsageToggle = useCallback(() => { - setIsBasicUsageChecked((prev) => { - const newValue = !prev; - // If unchecking basic usage, also uncheck marketing + setIsBasicUsageChecked((prevValue) => { + const newValue = !prevValue; if (!newValue) { setIsMarketingChecked(false); } diff --git a/app/components/UI/PaymentRequest/__snapshots__/index.test.tsx.snap b/app/components/UI/PaymentRequest/__snapshots__/index.test.tsx.snap index 5b310290f55b..86cc6ba76a92 100644 --- a/app/components/UI/PaymentRequest/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/PaymentRequest/__snapshots__/index.test.tsx.snap @@ -17,7 +17,7 @@ exports[`PaymentRequest renders correctly 1`] = ` { "alignItems": "center", "alignSelf": "center", - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderRadius": 16, "flexDirection": "row", "height": 32, @@ -61,7 +61,7 @@ exports[`PaymentRequest renders correctly 1`] = ` numberOfLines={1} style={ { - "color": "#121314", + "color": "#131416", "flexShrink": 1, "fontFamily": "Geist-Regular", "fontSize": 16, @@ -73,7 +73,7 @@ exports[`PaymentRequest renders correctly 1`] = ` testID="open-networks-text" /> @@ -12,7 +12,7 @@ exports[`PermissionsSummary should render correctly 1`] = ` style={ [ { - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderTopLeftRadius": 20, "borderTopRightRadius": 20, "height": undefined, @@ -96,8 +96,8 @@ exports[`PermissionsSummary should render correctly 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#f3f5f9", - "borderColor": "#b7bbc866", + "backgroundColor": "#f3f3f4", + "borderColor": "#b4b4b566", "borderRadius": 16, "borderWidth": 1, "height": 32, @@ -111,7 +111,7 @@ exports[`PermissionsSummary should render correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -231,9 +231,9 @@ exports[`PermissionsSummary should render correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, "textAlign": "center", @@ -246,7 +246,7 @@ exports[`PermissionsSummary should render correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -331,7 +331,7 @@ exports[`PermissionsSummary should render correctly 1`] = ` style={ { "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", + "borderColor": "#949596", "borderRadius": 4, "borderWidth": 0, "height": 75, @@ -383,7 +383,7 @@ exports[`PermissionsSummary should render correctly 1`] = ` numberOfLines={1} style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -397,7 +397,7 @@ exports[`PermissionsSummary should render correctly 1`] = ` numberOfLines={1} style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -428,7 +428,7 @@ exports[`PermissionsSummary should render correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -574,7 +574,7 @@ exports[`PermissionsSummary should render correctly 1`] = ` } > @@ -1147,7 +1147,7 @@ exports[`PermissionsSummary should render correctly for network switch 1`] = ` style={ [ { - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderTopLeftRadius": 20, "borderTopRightRadius": 20, "height": undefined, @@ -1231,8 +1231,8 @@ exports[`PermissionsSummary should render correctly for network switch 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#f3f5f9", - "borderColor": "#b7bbc866", + "backgroundColor": "#f3f3f4", + "borderColor": "#b4b4b566", "borderRadius": 16, "borderWidth": 1, "height": 32, @@ -1246,7 +1246,7 @@ exports[`PermissionsSummary should render correctly for network switch 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1366,9 +1366,9 @@ exports[`PermissionsSummary should render correctly for network switch 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, "textAlign": "center", @@ -1381,7 +1381,7 @@ exports[`PermissionsSummary should render correctly for network switch 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1519,7 +1519,7 @@ exports[`PermissionsSummary should render correctly for network switch 1`] = ` } > @@ -1867,7 +1867,7 @@ exports[`PermissionsSummary should render only the account permissions card when style={ [ { - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderTopLeftRadius": 20, "borderTopRightRadius": 20, "height": 325, @@ -1951,8 +1951,8 @@ exports[`PermissionsSummary should render only the account permissions card when style={ { "alignItems": "center", - "backgroundColor": "#f3f5f9", - "borderColor": "#b7bbc866", + "backgroundColor": "#f3f3f4", + "borderColor": "#b4b4b566", "borderRadius": 16, "borderWidth": 1, "height": 32, @@ -1966,7 +1966,7 @@ exports[`PermissionsSummary should render only the account permissions card when accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -2086,9 +2086,9 @@ exports[`PermissionsSummary should render only the account permissions card when accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, "textAlign": "center", @@ -2101,7 +2101,7 @@ exports[`PermissionsSummary should render only the account permissions card when accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -2154,7 +2154,7 @@ exports[`PermissionsSummary should render only the account permissions card when } > @@ -2531,7 +2531,7 @@ exports[`PermissionsSummary should render only the network permissions card when style={ [ { - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderTopLeftRadius": 20, "borderTopRightRadius": 20, "height": 325, @@ -2612,7 +2612,7 @@ exports[`PermissionsSummary should render only the network permissions card when style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 24, "textAlign": "center", @@ -2653,9 +2653,9 @@ exports[`PermissionsSummary should render only the network permissions card when accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, "textAlign": "center", @@ -2668,7 +2668,7 @@ exports[`PermissionsSummary should render only the network permissions card when accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -2720,7 +2720,7 @@ exports[`PermissionsSummary should render only the network permissions card when } > @@ -3107,7 +3107,7 @@ exports[`PermissionsSummary should render the tab view when both showAccountsOnl style={ [ { - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderTopLeftRadius": 20, "borderTopRightRadius": 20, "height": undefined, @@ -3191,8 +3191,8 @@ exports[`PermissionsSummary should render the tab view when both showAccountsOnl style={ { "alignItems": "center", - "backgroundColor": "#f3f5f9", - "borderColor": "#b7bbc866", + "backgroundColor": "#f3f3f4", + "borderColor": "#b4b4b566", "borderRadius": 16, "borderWidth": 1, "height": 32, @@ -3206,7 +3206,7 @@ exports[`PermissionsSummary should render the tab view when both showAccountsOnl accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -3326,9 +3326,9 @@ exports[`PermissionsSummary should render the tab view when both showAccountsOnl accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, "textAlign": "center", @@ -3341,7 +3341,7 @@ exports[`PermissionsSummary should render the tab view when both showAccountsOnl accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -3426,7 +3426,7 @@ exports[`PermissionsSummary should render the tab view when both showAccountsOnl style={ { "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", + "borderColor": "#949596", "borderRadius": 4, "borderWidth": 0, "height": 75, @@ -3478,7 +3478,7 @@ exports[`PermissionsSummary should render the tab view when both showAccountsOnl numberOfLines={1} style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -3492,7 +3492,7 @@ exports[`PermissionsSummary should render the tab view when both showAccountsOnl numberOfLines={1} style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -3523,7 +3523,7 @@ exports[`PermissionsSummary should render the tab view when both showAccountsOnl accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -3669,7 +3669,7 @@ exports[`PermissionsSummary should render the tab view when both showAccountsOnl } > @@ -4242,7 +4242,7 @@ exports[`PermissionsSummary should render with the correct initial tab based on style={ [ { - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderTopLeftRadius": 20, "borderTopRightRadius": 20, "height": undefined, @@ -4326,8 +4326,8 @@ exports[`PermissionsSummary should render with the correct initial tab based on style={ { "alignItems": "center", - "backgroundColor": "#f3f5f9", - "borderColor": "#b7bbc866", + "backgroundColor": "#f3f3f4", + "borderColor": "#b4b4b566", "borderRadius": 16, "borderWidth": 1, "height": 32, @@ -4341,7 +4341,7 @@ exports[`PermissionsSummary should render with the correct initial tab based on accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -4461,9 +4461,9 @@ exports[`PermissionsSummary should render with the correct initial tab based on accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, "textAlign": "center", @@ -4476,7 +4476,7 @@ exports[`PermissionsSummary should render with the correct initial tab based on accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -4561,7 +4561,7 @@ exports[`PermissionsSummary should render with the correct initial tab based on style={ { "backgroundColor": "#ffffff", - "borderColor": "#b7bbc8", + "borderColor": "#949596", "borderRadius": 4, "borderWidth": 0, "height": 75, @@ -4613,7 +4613,7 @@ exports[`PermissionsSummary should render with the correct initial tab based on numberOfLines={1} style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -4627,7 +4627,7 @@ exports[`PermissionsSummary should render with the correct initial tab based on numberOfLines={1} style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -4658,7 +4658,7 @@ exports[`PermissionsSummary should render with the correct initial tab based on accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -4804,7 +4804,7 @@ exports[`PermissionsSummary should render with the correct initial tab based on } > { const pnlText = getByTestId(getPerpsHeroCardViewSelector.pnlText(0)); - expect(pnlText).toHaveTextContent('+10.0%'); + expect(pnlText).toHaveTextContent('+10.00%'); }); }); @@ -627,7 +627,7 @@ describe('PerpsHeroCardView', () => { const pnlText = getByTestId(getPerpsHeroCardViewSelector.pnlText(0)); - expect(pnlText).toHaveTextContent('-10.0%'); + expect(pnlText).toHaveTextContent('-10.00%'); }); }); }); diff --git a/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx b/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx index 36dbdd5f240d..8f058e4d75c1 100644 --- a/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx +++ b/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx @@ -195,7 +195,7 @@ const PerpsHeroCardView: React.FC = () => { }; const pnlSign = data.pnl >= 0 ? '+' : ''; - const pnlDisplay = `${pnlSign}${data.roe.toFixed(1)}%`; + const pnlDisplay = `${pnlSign}${data.roe.toFixed(2)}%`; const directionText = data.direction.charAt(0).toUpperCase() + data.direction.slice(1); const directionBadgeText = data.leverage diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx index 2651ce163de6..87b5af4f31cf 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx @@ -560,9 +560,7 @@ describe('PerpsHomeView', () => { // Act - Press search toggle fireEvent.press(getByTestId('perps-home-search-toggle')); - // Assert - Should navigate to MarketListView with search enabled and 'all' category expect(mockNavigateToMarketList).toHaveBeenCalledWith({ - defaultSearchVisible: true, defaultMarketTypeFilter: 'all', source: PERPS_EVENT_VALUE.SOURCE.HOMESCREEN_TAB, fromHome: true, diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx index 3de5b5e2bf77..e1b30cdbd255 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx @@ -231,10 +231,7 @@ const PerpsHomeView = () => { }) .build(), ); - // Navigate to MarketListView with search enabled and 'all' category - // When user closes search, they should see all markets (not a specific category) perpsNavigation.navigateToMarketList({ - defaultSearchVisible: true, defaultMarketTypeFilter: 'all', source: PERPS_EVENT_VALUE.SOURCE.HOMESCREEN_TAB, fromHome: true, diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.styles.ts b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.styles.ts index 1e42557dff13..c362a8395477 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.styles.ts +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.styles.ts @@ -135,6 +135,11 @@ const styleSheet = (params: { theme: Theme }) => { animatedListContainer: { flex: 1, }, + searchBarRow: { + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 8, + }, searchContainer: { paddingTop: 16, paddingHorizontal: 16, diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx index 2c202a3530aa..67a8d7300136 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { screen, fireEvent, act, waitFor } from '@testing-library/react-native'; +import { screen, fireEvent, waitFor } from '@testing-library/react-native'; import { NavigationProp, ParamListBase, @@ -76,22 +76,14 @@ jest.mock('../../hooks/stream', () => ({ // Mock variables to hold state that will be set in beforeEach const mockMarketDataForHook: PerpsMarketData[] = []; -let mockSearchVisible = false; let mockSearchQuery = ''; // Create persistent mock functions that update the shared state const mockSetSearchQuery = jest.fn((q: string) => { mockSearchQuery = q; }); -const mockSetIsSearchVisible = jest.fn((v: boolean) => { - mockSearchVisible = v; -}); -const mockToggleSearchVisibility = jest.fn(() => { - mockSearchVisible = !mockSearchVisible; -}); const mockClearSearch = jest.fn(() => { mockSearchQuery = ''; - mockSearchVisible = false; }); jest.mock('../../hooks', () => ({ @@ -151,9 +143,6 @@ jest.mock('../../hooks', () => ({ searchState: { searchQuery: mockSearchQuery, setSearchQuery: mockSetSearchQuery, - isSearchVisible: mockSearchVisible, // This will be read fresh each render - setIsSearchVisible: mockSetIsSearchVisible, - toggleSearchVisibility: mockToggleSearchVisibility, clearSearch: mockClearSearch, }, sortState: { @@ -308,128 +297,6 @@ jest.mock('./components/PerpsMarketFiltersBar', () => { }; }); -jest.mock( - '../../../../../component-library/components/Form/TextFieldSearch', - () => { - const { - TextInput, - View, - TouchableOpacity: RNTouchableOpacity, - } = jest.requireActual('react-native'); - return function MockTextFieldSearch({ - value, - onChangeText, - placeholder, - testID, - onPressClearButton, - }: { - value: string; - onChangeText: (text: string) => void; - placeholder: string; - testID: string; - onPressClearButton?: () => void; - }) { - return ( - - - {!!value && ( - - )} - - ); - }; - }, -); - -// Mock PerpsMarketListHeader -jest.mock('../../components/PerpsMarketListHeader', () => { - const ReactActual = jest.requireActual('react'); - const { View, TouchableOpacity, TextInput, Pressable, Text } = - jest.requireActual('react-native'); - return { - __esModule: true, - default: function PerpsMarketListHeader({ - title, - isSearchVisible, - searchQuery, - onSearchQueryChange, - onBack, - onSearchToggle, - testID, - }: { - title?: string; - isSearchVisible?: boolean; - searchQuery?: string; - onSearchQueryChange?: (text: string) => void; - onSearchClear?: () => void; - onBack?: () => void; - onSearchToggle?: () => void; - testID?: string; - }) { - if (isSearchVisible) { - return ReactActual.createElement( - View, - { testID }, - ReactActual.createElement( - View, - { testID: `${testID}-search-bar-container` }, - ReactActual.createElement(View, { testID: 'search-icon' }), - ReactActual.createElement(TextInput, { - testID: `${testID}-search-input`, - placeholder: 'Search by token symbol', - value: searchQuery || '', - onChangeText: onSearchQueryChange, - }), - onSearchToggle && - ReactActual.createElement( - Pressable, - { - testID: `${testID}-search-close`, - onPress: onSearchToggle, - }, - ReactActual.createElement(Text, null, 'Cancel'), - ), - ), - ); - } - - return ReactActual.createElement( - View, - { testID }, - ReactActual.createElement( - TouchableOpacity, - { - testID: `${testID}-back-button`, - onPress: onBack, - }, - ReactActual.createElement(Text, null, '<'), - ), - ReactActual.createElement( - Text, - { testID: `${testID}-title` }, - title || 'Perps', - ), - ReactActual.createElement( - TouchableOpacity, - { - testID: `${testID}-search-toggle`, - onPress: onSearchToggle, - }, - ReactActual.createElement(Text, null, 'Search'), - ), - ); - }, - }; -}); - jest.mock('../../../../Views/confirmations/hooks/useConfirmNavigation', () => ({ useConfirmNavigation: jest.fn(() => ({ navigateToConfirmation: jest.fn(), @@ -459,121 +326,6 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ }), })); -// Mock design system - needed because real module requires tailwind setup -jest.mock('@metamask/design-system-react-native', () => { - const { - View, - TouchableOpacity, - Text: RNText, - } = jest.requireActual('react-native'); - const React = jest.requireActual('react'); - return { - ...jest.requireActual('@metamask/design-system-react-native'), - Box: ({ - children, - testID, - }: { - children: React.ReactNode; - testID?: string; - }) => React.createElement(View, { testID }, children), - ButtonIcon: ({ - testID, - onPress, - }: { - testID?: string; - onPress?: () => void; - }) => React.createElement(TouchableOpacity, { testID, onPress }), - Text: ({ - children, - testID, - }: { - children?: React.ReactNode; - testID?: string; - }) => React.createElement(RNText, { testID }, children), - }; -}); - -jest.mock( - '../../../../../component-library/components/Navigation/TabBarItem', - () => { - const { TouchableOpacity: MockTouchable, Text: MockText } = - jest.requireActual('react-native'); - return jest.fn(({ label, onPress, testID }) => ( - - {label} - - )); - }, -); - -// Mock TabsBar and Tab components -jest.mock( - '../../../../../component-library/components-temp/Tabs/TabsBar', - () => { - const ReactActual = jest.requireActual('react'); - const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: function TabsBar({ - tabs, - onTabPress, - testID, - }: { - tabs: { key: string; label: string }[]; - activeIndex: number; - onTabPress: (index: number) => void; - testID?: string; - }) { - return ReactActual.createElement( - View, - { testID }, - tabs.map((tab, index) => - ReactActual.createElement( - TouchableOpacity, - { - key: tab.key, - testID: testID ? `${testID}-tab-${index}` : undefined, - onPress: () => onTabPress(index), - }, - ReactActual.createElement(Text, null, tab.label), - ), - ), - ); - }, - }; - }, -); - -jest.mock('../../../../../component-library/components-temp/Tabs/Tab', () => { - const ReactActual = jest.requireActual('react'); - const { View, Pressable, Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: function Tab({ - label, - onPress, - testID, - onLayout, - }: { - label: string; - isActive?: boolean; - onPress?: () => void; - testID?: string; - onLayout?: (event: unknown) => void; - }) { - return ReactActual.createElement( - View, - { testID, onLayout }, - ReactActual.createElement( - Pressable, - { onPress }, - ReactActual.createElement(Text, null, label), - ), - ); - }, - }; -}); - // Mock Animated to prevent act() warnings jest.mock('react-native', () => { const RN = jest.requireActual('react-native'); @@ -635,30 +387,6 @@ jest.mock('../../hooks/usePerpsAssetsMetadata', () => ({ })), })); -// Mock component-library Text component with FontWeight -jest.mock('../../../../../component-library/components/Texts/Text', () => { - const { Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: Text, - TextVariant: { - BodyMd: 'BodyMd', - BodySM: 'BodySM', - HeadingMD: 'HeadingMD', - HeadingSM: 'HeadingSM', - }, - TextColor: { - Default: 'Default', - Alternative: 'Alternative', - }, - FontWeight: { - Bold: 'bold', - Medium: 'medium', - Regular: 'regular', - }, - }; -}); - interface FlashListProps { data: PerpsMarketData[]; renderItem: ({ @@ -822,11 +550,8 @@ describe('PerpsMarketListView', () => { mockMarketDataForHook.push(...mockMarketData); // Reset search state - mockSearchVisible = false; mockSearchQuery = ''; mockSetSearchQuery.mockClear(); - mockSetIsSearchVisible.mockClear(); - mockToggleSearchVisibility.mockClear(); mockClearSearch.mockClear(); // Suppress console warnings for Animated during tests @@ -868,17 +593,14 @@ describe('PerpsMarketListView', () => { }); describe('Component Rendering', () => { - it('renders the component with header and search button', async () => { + it('renders the component with header and search bar', async () => { renderWithProvider(, { state: mockState }); expect(screen.getByText('Markets')).toBeOnTheScreen(); expect( - screen.getByTestId( - `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, - ), + screen.getByTestId(PerpsMarketListViewSelectorsIDs.SEARCH_BAR), ).toBeOnTheScreen(); - // Wait for filter bar to render (it renders when tabs exist and markets are available) await waitFor(() => { expect(screen.getByText('Volume')).toBeOnTheScreen(); }); @@ -900,11 +622,8 @@ describe('PerpsMarketListView', () => { it('renders interactive elements', async () => { renderWithProvider(, { state: mockState }); - // Should have search toggle button and market rows expect( - screen.getByTestId( - `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, - ), + screen.getByTestId(PerpsMarketListViewSelectorsIDs.SEARCH_BAR), ).toBeOnTheScreen(); await waitFor(() => { @@ -919,194 +638,32 @@ describe('PerpsMarketListView', () => { }); describe('Search Functionality', () => { - it('shows search input when search button is pressed', async () => { - const { rerender } = renderWithProvider(, { - state: mockState, - }); + it('shows search bar always visible', async () => { + renderWithProvider(, { state: mockState }); - // Initially search should not be visible expect( - screen.queryByPlaceholderText('Search by token symbol'), - ).not.toBeOnTheScreen(); - - // Click search toggle button - const searchButton = screen.getByTestId( - `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, - ); - await act(async () => { - fireEvent.press(searchButton); - rerender(); - }); - - // Now search input should be visible - await waitFor(() => { - expect( - screen.getByPlaceholderText('Search by token symbol'), - ).toBeOnTheScreen(); - }); - }); - - it('shows all markets when search is visible with empty query', async () => { - const { rerender } = renderWithProvider(, { - state: mockState, - }); - - const btcRows = screen.queryAllByTestId('market-row-BTC'); - const ethRows = screen.queryAllByTestId('market-row-ETH'); - const solRows = screen.queryAllByTestId('market-row-SOL'); - expect(btcRows.length).toBeGreaterThan(0); - expect(ethRows.length).toBeGreaterThan(0); - expect(solRows.length).toBeGreaterThan(0); - - const searchButton = screen.getByTestId( - `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, - ); - await act(async () => { - fireEvent.press(searchButton); - rerender(); - }); - - await waitFor(() => { - expect( - screen.getByPlaceholderText('Search by token symbol'), - ).toBeOnTheScreen(); - }); - - // Markets should still be visible with empty search query - const btcRowsAfter = screen.queryAllByTestId('market-row-BTC'); - const ethRowsAfter = screen.queryAllByTestId('market-row-ETH'); - const solRowsAfter = screen.queryAllByTestId('market-row-SOL'); - expect(btcRowsAfter.length).toBeGreaterThan(0); - expect(ethRowsAfter.length).toBeGreaterThan(0); - expect(solRowsAfter.length).toBeGreaterThan(0); - }); - - it('hides PerpsMarketBalanceActions when search is visible', async () => { - const { rerender } = renderWithProvider(, { - state: mockState, - }); - - // Initially balance actions should be visible + screen.getByTestId(PerpsMarketListViewSelectorsIDs.SEARCH_BAR), + ).toBeOnTheScreen(); expect( - screen.getByTestId('perps-market-balance-actions'), + screen.getByPlaceholderText('Search by token symbol'), ).toBeOnTheScreen(); - - // Click search toggle button to show search - const searchButton = screen.getByTestId( - `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, - ); - await act(async () => { - fireEvent.press(searchButton); - rerender(); - }); - - // Balance actions should now be hidden (component hides it when search is visible) - await waitFor(() => { - expect( - screen.queryByTestId('perps-market-balance-actions'), - ).not.toBeOnTheScreen(); - }); - - // Search input should be visible - await waitFor(() => { - expect( - screen.getByPlaceholderText('Search by token symbol'), - ).toBeOnTheScreen(); - }); }); - it('shows search input when search toggle is pressed', async () => { - const { rerender } = renderWithProvider(, { - state: mockState, - }); + it('shows all markets when search query is empty', async () => { + renderWithProvider(, { state: mockState }); - // Initially search should not be visible expect( - screen.queryByPlaceholderText('Search by token symbol'), - ).not.toBeOnTheScreen(); - - // Click search toggle button to show search - const searchButton = screen.getByTestId( - `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, - ); - await act(async () => { - fireEvent.press(searchButton); - rerender(); - }); - - // Search input should be visible - await waitFor(() => { - expect( - screen.getByPlaceholderText('Search by token symbol'), - ).toBeOnTheScreen(); - }); - }); - - it('hides search when cancel is pressed while search is visible', async () => { - const { rerender } = renderWithProvider(, { - state: mockState, - }); - - // Click search toggle button to show search - const searchButton = screen.getByTestId( - `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, - ); - await act(async () => { - fireEvent.press(searchButton); - rerender(); - }); - - // Search input should be visible - await waitFor(() => { - expect( - screen.getByPlaceholderText('Search by token symbol'), - ).toBeOnTheScreen(); - }); - - // Click the cancel button to close search - const cancelButton = screen.getByTestId( - `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-close`, - ); - await act(async () => { - fireEvent.press(cancelButton); - rerender(); - }); - - // Search should be hidden - await waitFor(() => { - expect( - screen.queryByPlaceholderText('Search by token symbol'), - ).not.toBeOnTheScreen(); - }); - }); - - it('handles keyboard dismissal while search is visible', async () => { - const { rerender } = renderWithProvider(, { - state: mockState, - }); - - // Click search toggle button to show search - const searchButton = screen.getByTestId( - `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, - ); - await act(async () => { - fireEvent.press(searchButton); - rerender(); - }); + screen.getByTestId(PerpsMarketListViewSelectorsIDs.SEARCH_BAR), + ).toBeOnTheScreen(); - // Search input should be visible await waitFor(() => { - expect( - screen.getByPlaceholderText('Search by token symbol'), - ).toBeOnTheScreen(); + const btcRows = screen.queryAllByTestId('market-row-BTC'); + const ethRows = screen.queryAllByTestId('market-row-ETH'); + const solRows = screen.queryAllByTestId('market-row-SOL'); + expect(btcRows.length).toBeGreaterThan(0); + expect(ethRows.length).toBeGreaterThan(0); + expect(solRows.length).toBeGreaterThan(0); }); - - // Note: PerpsMarketListHeader doesn't use Keyboard.addListener. - // It uses Keyboard.dismiss() directly in the Pressable onPress handler. - // This test verifies that search remains visible (which it does). - expect( - screen.getByPlaceholderText('Search by token symbol'), - ).toBeOnTheScreen(); }); }); @@ -1196,14 +753,13 @@ describe('PerpsMarketListView', () => { describe('Navigation', () => { it('does not navigate back when canGoBack returns false', () => { - const { TouchableOpacity } = jest.requireActual('react-native'); mockNavigation.canGoBack.mockReturnValue(false); renderWithProvider(, { state: mockState }); - // Find close button (first TouchableOpacity after the market rows) - const touchableElements = screen.root.findAllByType(TouchableOpacity); - const closeButton = touchableElements[0]; // Close button is the first one - fireEvent.press(closeButton); + const backButton = screen.getByTestId( + `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-back-button`, + ); + fireEvent.press(backButton); expect(mockNavigation.goBack).not.toHaveBeenCalled(); }); @@ -1242,8 +798,6 @@ describe('PerpsMarketListView', () => { it('filters markets with whitespace-only query', async () => { const { usePerpsMarketListView } = jest.requireMock('../../hooks'); - // Start with search visible - mockSearchVisible = true; mockSearchQuery = ' '; // Mock to return empty results when search query is whitespace @@ -1252,9 +806,6 @@ describe('PerpsMarketListView', () => { searchState: { searchQuery: ' ', setSearchQuery: mockSetSearchQuery, - isSearchVisible: true, - setIsSearchVisible: mockSetIsSearchVisible, - toggleSearchVisibility: mockToggleSearchVisibility, clearSearch: mockClearSearch, }, sortState: { diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index 589fa2ffb9e0..c665775e0da9 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -7,12 +7,12 @@ import React, { } from 'react'; import { View, Animated } from 'react-native'; import { useStyles } from '../../../../../component-library/hooks'; -import { IconName as DSIconName } from '@metamask/design-system-react-native'; import Icon, { IconName, IconSize, } from '../../../../../component-library/components/Icons/Icon'; import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; +import TextFieldSearch from '../../../../../component-library/components/Form/TextFieldSearch/TextFieldSearch'; import { strings } from '../../../../../../locales/i18n'; import Text, { TextVariant, @@ -44,7 +44,6 @@ import { TraceName } from '../../../../../util/trace'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { PerpsNavigationParamList } from '../../types/navigation'; -import PerpsMarketListHeader from '../../components/PerpsMarketListHeader'; const PerpsMarketListView = ({ onMarketSelect, @@ -52,23 +51,18 @@ const PerpsMarketListView = ({ variant: propVariant, title: propTitle, showBalanceActions: propShowBalanceActions, - defaultSearchVisible: propDefaultSearchVisible, showWatchlistOnly: propShowWatchlistOnly, }: PerpsMarketListViewProps) => { const { styles, theme } = useStyles(styleSheet, {}); const route = useRoute>(); - // Use centralized navigation hook const perpsNavigation = usePerpsNavigation(); - // Merge route params with props (route params take precedence) const variant = route.params?.variant ?? propVariant ?? 'full'; const title = route.params?.title ?? propTitle; const showBalanceActions = route.params?.showBalanceActions ?? propShowBalanceActions ?? true; - const defaultSearchVisible = - route.params?.defaultSearchVisible ?? propDefaultSearchVisible ?? false; const showWatchlistOnly = route.params?.showWatchlistOnly ?? propShowWatchlistOnly ?? false; const defaultMarketTypeFilter = @@ -77,10 +71,6 @@ const PerpsMarketListView = ({ const fadeAnimation = useRef(new Animated.Value(0)).current; const [isSortFieldSheetVisible, setIsSortFieldSheetVisible] = useState(false); - // Store the market type filter before entering search, so we can restore it when exiting - const preSearchFilterRef = useRef(defaultMarketTypeFilter); - - // Use the combined market list view hook for all business logic const { markets: filteredMarkets, searchState, @@ -91,21 +81,13 @@ const PerpsMarketListView = ({ isLoading: isLoadingMarkets, error, } = usePerpsMarketListView({ - defaultSearchVisible, enablePolling: false, showWatchlistOnly, defaultMarketTypeFilter, - showZeroVolume: __DEV__, // Only show $0.00 volume markets in development + showZeroVolume: __DEV__, }); - // Destructure search state for easier access - const { - searchQuery, - setSearchQuery, - isSearchVisible, - toggleSearchVisibility, - clearSearch, - } = searchState; + const { searchQuery, setSearchQuery } = searchState; // Destructure sort state for easier access const { selectedOptionId, sortBy, direction, handleOptionChange } = sortState; @@ -179,35 +161,8 @@ const PerpsMarketListView = ({ } }, [filteredMarkets.length, fadeAnimation]); - // Use navigation hook for back button const handleBackPressed = perpsNavigation.navigateBack; - const handleSearchToggle = useCallback(() => { - // Toggle search visibility - toggleSearchVisibility(); - - if (isSearchVisible) { - // When disabling search, clear the query and restore the filter to what it was before search - clearSearch(); - setMarketTypeFilter(preSearchFilterRef.current); - } else { - // When enabling search, store the current filter so we can restore it when exiting - preSearchFilterRef.current = marketTypeFilter; - // Track the event - track(MetaMetricsEvents.PERPS_UI_INTERACTION, { - [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: - PERPS_EVENT_VALUE.INTERACTION_TYPE.SEARCH_CLICKED, - }); - } - }, [ - isSearchVisible, - toggleSearchVisibility, - clearSearch, - track, - setMarketTypeFilter, - marketTypeFilter, - ]); - // Performance tracking: Measure screen load time until market data is displayed usePerpsMeasurement({ traceName: TraceName.PerpsMarketListView, @@ -309,8 +264,8 @@ const PerpsMarketListView = ({ ); } - // Empty search results - show when search is visible and no markets match - if (isSearchVisible && filteredMarkets.length === 0) { + // Empty search results - show when user has typed and no markets match + if (searchQuery.trim() && filteredMarkets.length === 0) { return ( @@ -358,43 +313,35 @@ const PerpsMarketListView = ({ return ( - {/* Header */} - {isSearchVisible ? ( - setSearchQuery('')} - onBack={handleBackPressed} - onSearchToggle={handleSearchToggle} - testID={PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON} - /> - ) : ( - - )} + - {/* Balance Actions Component - Only show in full variant when search not visible */} - {!isSearchVisible && showBalanceActions && variant === 'full' && ( + {showBalanceActions && variant === 'full' && ( )} - {/* Filter Bar - Show when not loading and no error */} - {!isSearchVisible && !isLoadingMarkets && !error && ( + {!isLoadingMarkets && !error && ( + + setSearchQuery('')} + placeholder={strings('perps.search_by_token_symbol')} + testID={PerpsMarketListViewSelectorsIDs.SEARCH_BAR} + clearButtonProps={{ + testID: PerpsMarketListViewSelectorsIDs.SEARCH_CLEAR_BUTTON, + }} + /> + + )} + + {!isLoadingMarkets && !error && ( setIsSortFieldSheetVisible(true)} diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.types.ts b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.types.ts index e4f63caccbbc..2bfb4d4bd6c9 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.types.ts +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.types.ts @@ -39,7 +39,6 @@ export interface PerpsMarketListViewProps { title?: string; /** * Show balance actions component (deposit/withdraw) - * Only applicable when search is not visible * @default true */ showBalanceActions?: boolean; @@ -48,11 +47,6 @@ export interface PerpsMarketListViewProps { * @default true */ showBottomNav?: boolean; - /** - * Start with search bar visible - * @default false - */ - defaultSearchVisible?: boolean; /** * Start with watchlist filter enabled (show only watchlisted markets) * @default false diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.view.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.view.test.tsx index 0c833ef79fbf..a98a4d76ac7c 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.view.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.view.test.tsx @@ -80,14 +80,8 @@ describe('PerpsMarketListView', () => { streamOverrides: { marketData: marketDataWithCategories }, }); - const searchToggle = await screen.findByTestId( - `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-toggle`, - ); - - fireEvent.press(searchToggle); - const searchInput = await screen.findByTestId( - `${PerpsMarketListViewSelectorsIDs.CLOSE_BUTTON}-search-bar`, + PerpsMarketListViewSelectorsIDs.SEARCH_BAR, ); fireEvent.changeText(searchInput, 'ZZZ-NOT-FOUND'); diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx index 69bd174f6c6c..229fb2c9c263 100644 --- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx @@ -499,7 +499,7 @@ const PerpsOrderBookView: React.FC = ({ diff --git a/app/components/UI/Perps/Views/PerpsOrderLifecycleFlow.view.test.tsx b/app/components/UI/Perps/Views/PerpsOrderLifecycleFlow.view.test.tsx index 3fc92129bfe6..c1d5a7164757 100644 --- a/app/components/UI/Perps/Views/PerpsOrderLifecycleFlow.view.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderLifecycleFlow.view.test.tsx @@ -181,11 +181,13 @@ describe('Order Lifecycle & Funds Flow', () => { screen.getByText(strings('perps.provider_selector.title')), ).toBeOnTheScreen(); expect( - screen.getByTestId('perps-select-provider-sheet-option-hyperliquid'), + screen.getByTestId( + 'perps-select-provider-sheet-option-hyperliquid-mainnet', + ), ).toBeOnTheScreen(); // MYX option hidden when feature flag is disabled expect( - screen.queryByTestId('perps-select-provider-sheet-option-myx'), + screen.queryByTestId('perps-select-provider-sheet-option-myx-mainnet'), ).not.toBeOnTheScreen(); // With MYX enabled + aggregated provider → HyperLiquid shows selected @@ -203,11 +205,11 @@ describe('Order Lifecycle & Funds Flow', () => { }); expect( await screen.findByTestId( - 'perps-select-provider-sheet-check-hyperliquid', + 'perps-select-provider-sheet-check-aggregated-mainnet', ), ).toBeOnTheScreen(); expect( - screen.queryByTestId('perps-select-provider-sheet-check-myx'), + screen.queryByTestId('perps-select-provider-sheet-check-myx-mainnet'), ).not.toBeOnTheScreen(); // Trader selects MYX provider — switchProvider is called @@ -216,7 +218,7 @@ describe('Order Lifecycle & Funds Flow', () => { .switchProvider as jest.Mock; renderPerpsSelectProviderView({ overrides: myxEnabledOverrides }); const myxOption = await screen.findByTestId( - 'perps-select-provider-sheet-option-myx', + 'perps-select-provider-sheet-option-myx-mainnet', ); fireEvent.press(myxOption); await waitFor(() => { diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index e21aed6c640e..3786c9869fa4 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -600,8 +600,9 @@ jest.mock('../../components/PerpsNotificationTooltip', () => { }; }); -// Mock network utils - these are external utilities that should be mocked +// Mock network utils - spread actual to satisfy transitive deps (e.g. stakeableTokens.getDecimalChainId), override only what we need jest.mock('../../../../../util/networks', () => ({ + ...jest.requireActual('../../../../../util/networks'), getDefaultNetworkByChainId: jest.fn(() => ({ name: 'Arbitrum' })), getNetworkImageSource: jest.fn(() => ({ uri: 'network-icon' })), })); diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx index ed7d374fbc73..808fd880830c 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx @@ -64,6 +64,7 @@ jest.mock('../../../../Views/confirmations/hooks/useConfirmNavigation', () => ({ jest.mock('../../../../../util/address'); jest.mock('../../../../Base/TokenIcon', () => jest.fn(() => null)); jest.mock('../../../../../util/networks', () => ({ + ...jest.requireActual('../../../../../util/networks'), getNetworkImageSource: jest.fn(() => ({ uri: 'network-icon.png' })), })); diff --git a/app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.test.tsx b/app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.test.tsx new file mode 100644 index 000000000000..d10a101f2f35 --- /dev/null +++ b/app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.test.tsx @@ -0,0 +1,224 @@ +import React from 'react'; +import { render, act } from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { usePerpsProvider } from '../../hooks/usePerpsProvider'; +import { usePerpsNetworkConfig } from '../../hooks/usePerpsNetworkConfig'; +import PerpsSelectProviderView from './PerpsSelectProviderView'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../hooks/usePerpsProvider', () => ({ + usePerpsProvider: jest.fn(), +})); + +jest.mock('../../hooks/usePerpsNetworkConfig', () => ({ + usePerpsNetworkConfig: jest.fn(), +})); + +jest.mock('../../../../../util/Logger', () => ({ + error: jest.fn(), +})); + +// Configurable option for the sheet mock — set per-test to drive onOptionSelect +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let mockSheetOption: any = { + id: 'myx-mainnet', + providerId: 'myx', + isTestnet: false, + name: 'MYX', + network: 'Mainnet', + description: '', +}; + +// Mock the sheet component so we can inspect the props passed to it +jest.mock( + '../../components/PerpsProviderSelector/PerpsProviderSelectorSheet', + () => { + const { View, TouchableOpacity, Text } = jest.requireActual('react-native'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + default: ({ onClose, onOptionSelect, selectedOptionId, testID }: any) => ( + + + onOptionSelect(mockSheetOption)} + /> + {selectedOptionId} + + ), + }; + }, +); + +const mockGoBack = jest.fn(); +const mockSwitchProvider = jest.fn(); +const mockToggleTestnet = jest.fn(); +const mockUseSelector = useSelector as jest.Mock; +const mockUsePerpsProvider = usePerpsProvider as jest.Mock; +const mockUsePerpsNetworkConfig = usePerpsNetworkConfig as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue({ goBack: mockGoBack }); + mockUseSelector.mockReturnValue('mainnet'); + mockUsePerpsProvider.mockReturnValue({ + activeProvider: 'hyperliquid', + switchProvider: mockSwitchProvider, + }); + mockUsePerpsNetworkConfig.mockReturnValue({ + toggleTestnet: mockToggleTestnet, + }); + mockSwitchProvider.mockResolvedValue({ success: true }); + mockToggleTestnet.mockResolvedValue({ success: true }); + // Default: select myx (provider changes, no network change) + mockSheetOption = { + id: 'myx-mainnet', + providerId: 'myx', + isTestnet: false, + name: 'MYX', + network: 'Mainnet', + description: '', + }; +}); + +describe('PerpsSelectProviderView', () => { + it('renders the sheet with isVisible=true', () => { + const { getByTestId } = render(); + + expect(getByTestId('perps-select-provider-sheet')).toBeTruthy(); + }); + + it('shows selectedOptionId as hyperliquid-mainnet by default', () => { + const { getByTestId } = render(); + + expect(getByTestId('selected-option-id').props.children).toBe( + 'hyperliquid-mainnet', + ); + }); + + it('shows testnet suffix when network is testnet', () => { + mockUseSelector.mockReturnValue('testnet'); + + const { getByTestId } = render(); + + expect(getByTestId('selected-option-id').props.children).toBe( + 'hyperliquid-testnet', + ); + }); + + it('calls navigation.goBack when onClose is triggered', () => { + const { getByTestId } = render(); + + act(() => { + getByTestId('btn-close').props.onPress(); + }); + + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('calls switchProvider when provider changes', async () => { + const { getByTestId } = render(); + + await act(async () => { + getByTestId('btn-select-option').props.onPress(); + }); + + expect(mockSwitchProvider).toHaveBeenCalledWith('myx'); + }); + + it('does not call switchProvider or toggleTestnet when nothing changes', async () => { + // Same provider, same network → no-op + mockSheetOption = { + id: 'hyperliquid-mainnet', + providerId: 'hyperliquid', + isTestnet: false, + name: 'HyperLiquid', + network: 'Mainnet', + description: '', + }; + + const { getByTestId } = render(); + + await act(async () => { + getByTestId('btn-select-option').props.onPress(); + }); + + expect(mockSwitchProvider).not.toHaveBeenCalled(); + expect(mockToggleTestnet).not.toHaveBeenCalled(); + }); + + it('calls toggleTestnet when only network changes (same provider)', async () => { + // Same provider, different network → only toggleTestnet + mockSheetOption = { + id: 'hyperliquid-testnet', + providerId: 'hyperliquid', + isTestnet: true, + name: 'HyperLiquid', + network: 'Testnet', + description: '', + }; + + const { getByTestId } = render(); + + await act(async () => { + getByTestId('btn-select-option').props.onPress(); + }); + + expect(mockSwitchProvider).not.toHaveBeenCalled(); + expect(mockToggleTestnet).toHaveBeenCalled(); + }); + + it('logs error when switchProvider fails', async () => { + const Logger = jest.requireMock('../../../../../util/Logger'); + mockSwitchProvider.mockResolvedValue({ + success: false, + error: 'Switch failed', + }); + + const { getByTestId } = render(); + + await act(async () => { + getByTestId('btn-select-option').props.onPress(); + }); + + expect(Logger.error).toHaveBeenCalled(); + }); + + it('logs error when toggleTestnet fails', async () => { + const Logger = jest.requireMock('../../../../../util/Logger'); + mockToggleTestnet.mockResolvedValue({ + success: false, + error: 'Toggle failed', + }); + + // Network-only change: same provider, different isTestnet + mockSheetOption = { + id: 'hyperliquid-testnet', + providerId: 'hyperliquid', + isTestnet: true, + name: 'HyperLiquid', + network: 'Testnet', + description: '', + }; + + const { getByTestId } = render(); + + await act(async () => { + getByTestId('btn-select-option').props.onPress(); + }); + + expect(mockToggleTestnet).toHaveBeenCalled(); + expect(Logger.error).toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.tsx b/app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.tsx index a95d25b7cf93..de7b7a07b600 100644 --- a/app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.tsx +++ b/app/components/UI/Perps/Views/PerpsSelectProviderView/PerpsSelectProviderView.tsx @@ -1,49 +1,78 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; import Logger from '../../../../../util/Logger'; import { usePerpsProvider } from '../../hooks/usePerpsProvider'; +import { usePerpsNetworkConfig } from '../../hooks/usePerpsNetworkConfig'; +import { selectPerpsNetwork } from '../../selectors/perpsController'; import PerpsProviderSelectorSheet from '../../components/PerpsProviderSelector/PerpsProviderSelectorSheet'; -import type { PerpsProviderType } from '@metamask/perps-controller'; +import type { ProviderNetworkOption } from '../../components/PerpsProviderSelector/PerpsProviderSelector.types'; /** * PerpsSelectProviderView * * Navigation-based wrapper for the provider selector bottom sheet. - * This ensures the sheet renders at full-screen level rather than - * being constrained by parent container bounds. + * Handles combined provider + network switching. */ const PerpsSelectProviderView: React.FC = () => { const navigation = useNavigation(); const { activeProvider, switchProvider } = usePerpsProvider(); + const { toggleTestnet } = usePerpsNetworkConfig(); + const network = useSelector(selectPerpsNetwork); + const isTestnet = network === 'testnet'; const handleClose = useCallback(() => { navigation.goBack(); }, [navigation]); - const handleProviderSelect = useCallback( - async (providerId: PerpsProviderType) => { - const result = await switchProvider(providerId); - if (!result.success) { - Logger.error( - new Error(`Failed to switch perps provider to ${providerId}`), - { message: result.error }, - ); + const selectedOptionId = useMemo( + () => `${activeProvider}-${isTestnet ? 'testnet' : 'mainnet'}`, + [activeProvider, isTestnet], + ); + + const handleOptionSelect = useCallback( + async (option: ProviderNetworkOption) => { + const providerChanged = option.providerId !== activeProvider; + const networkChanged = option.isTestnet !== isTestnet; + + if (!providerChanged && !networkChanged) { + return; + } + + // Switch provider first if needed + if (providerChanged) { + const result = await switchProvider(option.providerId); + if (!result.success) { + Logger.error( + new Error( + `Failed to switch perps provider to ${option.providerId}`, + ), + { message: result.error }, + ); + return; + } + } + + // Then toggle network if needed + if (networkChanged) { + const result = await toggleTestnet(); + if (!result.success) { + Logger.error(new Error(`Failed to toggle perps testnet`), { + message: result.error, + }); + return; + } } - // Navigation is handled by handleClose when bottom sheet closes }, - [switchProvider], + [activeProvider, isTestnet, switchProvider, toggleTestnet], ); - // Determine selected provider, defaulting to hyperliquid for aggregated mode - const selectedProvider = - activeProvider !== 'aggregated' ? activeProvider : 'hyperliquid'; - return ( ); diff --git a/app/components/UI/Perps/__mocks__/serviceMocks.ts b/app/components/UI/Perps/__mocks__/serviceMocks.ts index 1117c9af2134..ae4edc0217ff 100644 --- a/app/components/UI/Perps/__mocks__/serviceMocks.ts +++ b/app/components/UI/Perps/__mocks__/serviceMocks.ts @@ -11,6 +11,23 @@ import { type PerpsPlatformDependencies, } from '@metamask/perps-controller'; +/** + * Create a mock EVM account (KeyringAccount) + */ +export const createMockEvmAccount = () => ({ + id: '00000000-0000-0000-0000-000000000000', + address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`, + type: 'eip155:eoa' as const, + options: {}, + scopes: ['eip155:1'], + methods: ['eth_signTransaction', 'eth_sign'], + metadata: { + name: 'Test Account', + importTime: Date.now(), + keyring: { type: 'HD Key Tree' }, + }, +}); + /** * Create a mock PerpsPlatformDependencies instance. * Returns a type-safe mock with jest.Mock functions for all methods. @@ -53,11 +70,6 @@ export const createMockInfrastructure = clearAllChannels: jest.fn(), }, - // === Rewards (no standard messenger action in core) === - rewards: { - getFeeDiscount: jest.fn().mockResolvedValue(0), - }, - // === Feature Flags (platform-specific version gating) === featureFlags: { validateVersionGated: jest.fn().mockReturnValue(undefined), @@ -76,6 +88,11 @@ export const createMockInfrastructure = invalidate: jest.fn(), invalidateAll: jest.fn(), }, + + // === Rewards (DI — no RewardsController in Core yet) === + rewards: { + getPerpsDiscountForAccount: jest.fn().mockResolvedValue(0), + }, }) as unknown as jest.Mocked; /** @@ -128,13 +145,8 @@ export const createMockPerpsControllerState = ( lastUpdateTimestamp: Date.now(), hip3ConfigVersion: 0, selectedPaymentToken: null, - cachedMarketData: null, - cachedMarketDataTimestamp: 0, - cachedPositions: null, - cachedOrders: null, - cachedAccountState: null, - cachedUserDataTimestamp: 0, - cachedUserDataAddress: null, + cachedMarketDataByProvider: {}, + cachedUserDataByProvider: {}, ...overrides, }); @@ -161,23 +173,6 @@ export const createMockServiceContext = ( ...overrides, }); -/** - * Create a mock EVM account (KeyringAccount) - */ -export const createMockEvmAccount = () => ({ - id: '00000000-0000-0000-0000-000000000000', - address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`, - type: 'eip155:eoa' as const, - options: {}, - scopes: ['eip155:1'], - methods: ['eth_signTransaction', 'eth_sign'], - metadata: { - name: 'Test Account', - importTime: Date.now(), - keyring: { type: 'HD Key Tree' }, - }, -}); - /** * Create a mock PerpsControllerMessenger for testing inter-controller communication. * The messenger.call() method should be configured in each test to return appropriate values. diff --git a/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts b/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts index baad6cc4f596..eb70ccec4720 100644 --- a/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts +++ b/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts @@ -3,6 +3,7 @@ import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEvent import { analytics } from '../../../../util/analytics/analytics'; import type { PerpsAnalyticsEvent } from '@metamask/perps-controller'; import { createMobileInfrastructure } from './mobileInfrastructure'; +import Engine from '../../../../core/Engine'; jest.mock('../../../../util/analytics/analytics', () => ({ analytics: { @@ -55,7 +56,7 @@ jest.mock('../providers/PerpsStreamManager', () => ({ jest.mock('../../../../core/Engine', () => ({ context: { RewardsController: { - getPerpsDiscountForAccount: jest.fn(), + getPerpsDiscountForAccount: jest.fn().mockResolvedValue(5), }, }, })); @@ -159,4 +160,20 @@ describe('createMobileInfrastructure', () => { }); }); }); + + describe('rewards', () => { + it('delegates getPerpsDiscountForAccount to RewardsController', async () => { + const infra = createMobileInfrastructure(); + const caipAccountId = + 'eip155:42161:0x1234' as `${string}:${string}:${string}`; + + const result = + await infra.rewards.getPerpsDiscountForAccount(caipAccountId); + + expect( + Engine.context.RewardsController.getPerpsDiscountForAccount, + ).toHaveBeenCalledWith(caipAccountId); + expect(result).toBe(5); + }); + }); }); diff --git a/app/components/UI/Perps/adapters/mobileInfrastructure.ts b/app/components/UI/Perps/adapters/mobileInfrastructure.ts index 91e873d26126..2e51bee9e55c 100644 --- a/app/components/UI/Perps/adapters/mobileInfrastructure.ts +++ b/app/components/UI/Perps/adapters/mobileInfrastructure.ts @@ -216,14 +216,6 @@ export function createMobileInfrastructure(): PerpsPlatformDependencies { // === Platform Services === streamManager: createStreamManagerAdapter(), - // === Rewards === - rewards: { - getFeeDiscount: (caipAccountId: `${string}:${string}:${string}`) => - Engine.context.RewardsController.getPerpsDiscountForAccount( - caipAccountId, - ), - }, - // === Feature Flags === featureFlags: { validateVersionGated(flag: VersionGatedFeatureFlag): boolean | undefined { @@ -236,6 +228,17 @@ export function createMobileInfrastructure(): PerpsPlatformDependencies { // === Cache Invalidation === cacheInvalidator: createCacheInvalidatorAdapter(), + + // === Rewards (DI — no RewardsController in Core yet) === + rewards: { + getPerpsDiscountForAccount( + caipAccountId: `${string}:${string}:${string}`, + ) { + return Engine.context.RewardsController.getPerpsDiscountForAccount( + caipAccountId, + ); + }, + }, }; } diff --git a/app/components/UI/Perps/components/PerpsConnectionErrorView/PerpsConnectionErrorView.test.tsx b/app/components/UI/Perps/components/PerpsConnectionErrorView/PerpsConnectionErrorView.test.tsx index b17feae1289e..038341bcf9fb 100644 --- a/app/components/UI/Perps/components/PerpsConnectionErrorView/PerpsConnectionErrorView.test.tsx +++ b/app/components/UI/Perps/components/PerpsConnectionErrorView/PerpsConnectionErrorView.test.tsx @@ -109,6 +109,12 @@ jest.mock('../../../../../component-library/components/Texts/Text', () => { }; }); +// Mock usePerpsEventTracking +const mockTrack = jest.fn(); +jest.mock('../../hooks/usePerpsEventTracking', () => ({ + usePerpsEventTracking: jest.fn(() => ({ track: mockTrack })), +})); + // Mock ScreenView jest.mock('../../../../Base/ScreenView', () => { const { View } = jest.requireActual('react-native'); @@ -219,6 +225,30 @@ describe('PerpsConnectionErrorView', () => { expect(backButton).toBeTruthy(); }); + it('navigates back and tracks event when back button is pressed', () => { + const { getByText } = render( + , + ); + + const backButton = getByText('perps.errors.connectionFailed.go_back'); + if (backButton.parent) { + fireEvent.press(backButton.parent); + } + + expect(mockTrack).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: 'connection_go_back', + }), + ); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + it('should hide back button when showBackButton is false', () => { const { queryByText } = render( = ({ const navigation = useNavigation(); const { track } = usePerpsEventTracking(); - // Track error screen view - usePerpsEventTracking({ - eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, - properties: { + const errorMessage = + (typeof error === 'string' ? error : error?.message) || + PERPS_EVENT_VALUE.ERROR_MESSAGE_KEY.UNKNOWN; + + // Track error screen view on mount and after each retry. + // Uses imperative track() in a useEffect keyed on retryAttempts so the event + // fires reliably every time, unlike the declarative resetConditions API which + // can skip renders when the reset condition stays true across retries. + useEffect(() => { + track(MetaMetricsEvents.PERPS_SCREEN_VIEWED, { [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: PERPS_EVENT_VALUE.SCREEN_TYPE.ERROR, - }, - }); + [PERPS_EVENT_PROPERTY.SCREEN_NAME]: + PERPS_EVENT_VALUE.SCREEN_NAME.CONNECTION_ERROR, + [PERPS_EVENT_PROPERTY.ERROR_TYPE]: PERPS_EVENT_VALUE.ERROR_TYPE.NETWORK, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, + [PERPS_EVENT_PROPERTY.RETRY_ATTEMPTS]: retryAttempts, + }); + }, [retryAttempts, errorMessage, track]); // Filter debug messages in production - show generic error message const shouldShowDebugDetails = @@ -122,6 +133,7 @@ const PerpsConnectionErrorView: React.FC = ({ [PERPS_EVENT_PROPERTY.ACTION]: PERPS_EVENT_VALUE.ACTION.CONNECTION_RETRY, [PERPS_EVENT_PROPERTY.ATTEMPT_NUMBER]: retryAttempts + 1, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, }); onRetry(); }} @@ -135,7 +147,17 @@ const PerpsConnectionErrorView: React.FC = ({ size={ButtonSize.Lg} width={ButtonWidthTypes.Full} label={strings('perps.errors.connectionFailed.go_back')} - onPress={handleGoBack} + onPress={() => { + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.TAP, + [PERPS_EVENT_PROPERTY.ACTION]: + PERPS_EVENT_VALUE.ACTION.CONNECTION_GO_BACK, + [PERPS_EVENT_PROPERTY.ATTEMPT_NUMBER]: retryAttempts, + [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, + }); + handleGoBack(); + }} style={styles.backButton} /> )} diff --git a/app/components/UI/Perps/components/PerpsDeveloperOptionsSection/__snapshots__/PerpsDeveloperOptionsSection.test.tsx.snap b/app/components/UI/Perps/components/PerpsDeveloperOptionsSection/__snapshots__/PerpsDeveloperOptionsSection.test.tsx.snap index 57023eb97800..0706ce69c1b5 100644 --- a/app/components/UI/Perps/components/PerpsDeveloperOptionsSection/__snapshots__/PerpsDeveloperOptionsSection.test.tsx.snap +++ b/app/components/UI/Perps/components/PerpsDeveloperOptionsSection/__snapshots__/PerpsDeveloperOptionsSection.test.tsx.snap @@ -13,7 +13,7 @@ exports[`PerpsDeveloperOptionsSection renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 24, "letterSpacing": 0, @@ -38,7 +38,7 @@ exports[`PerpsDeveloperOptionsSection renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -66,7 +66,7 @@ exports[`PerpsDeveloperOptionsSection renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, diff --git a/app/components/UI/Perps/components/PerpsDeveloperOptionsSection/__snapshots__/PerpsTestnetToggle.test.tsx.snap b/app/components/UI/Perps/components/PerpsDeveloperOptionsSection/__snapshots__/PerpsTestnetToggle.test.tsx.snap index 7e275618cda9..7d92401459e3 100644 --- a/app/components/UI/Perps/components/PerpsDeveloperOptionsSection/__snapshots__/PerpsTestnetToggle.test.tsx.snap +++ b/app/components/UI/Perps/components/PerpsDeveloperOptionsSection/__snapshots__/PerpsTestnetToggle.test.tsx.snap @@ -15,7 +15,7 @@ exports[`PerpsTestnetToggle renders correctly with testnet network 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -43,7 +43,7 @@ exports[`PerpsTestnetToggle renders correctly with testnet network 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, diff --git a/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.styles.ts b/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.styles.ts index b66f937a1d8e..9eff224de697 100644 --- a/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.styles.ts +++ b/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.styles.ts @@ -44,6 +44,22 @@ const styleSheet = (params: { theme: Theme }) => { flex: 1, marginRight: 8, }, + testnetBadge: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 16, + backgroundColor: theme.colors.warning.muted, + marginLeft: 8, + gap: 4, + }, + testnetDot: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: theme.colors.warning.default, + }, }); }; diff --git a/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.tsx b/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.tsx index fd3b391a7679..b704ccc503da 100644 --- a/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.tsx +++ b/app/components/UI/Perps/components/PerpsHomeHeader/PerpsHomeHeader.tsx @@ -22,8 +22,9 @@ import { strings } from '../../../../../../locales/i18n'; import { useTheme } from '../../../../../util/theme'; import type { PerpsHomeHeaderProps } from './PerpsHomeHeader.types'; import styleSheet from './PerpsHomeHeader.styles'; -import { selectPerpsMYXProviderEnabledFlag } from '../../selectors/featureFlags'; +import { selectPerpsNetwork } from '../../selectors/perpsController'; import { PerpsProviderSelectorBadge } from '../PerpsProviderSelector'; +import { usePerpsProvider } from '../../hooks/usePerpsProvider'; /** * PerpsHomeHeader Component @@ -68,7 +69,9 @@ const PerpsHomeHeader: React.FC = ({ const tw = useTailwind(); const { colors } = useTheme(); const navigation = useNavigation(); - const isMYXProviderEnabled = useSelector(selectPerpsMYXProviderEnabledFlag); + const { isMultiProviderEnabled } = usePerpsProvider(); + const network = useSelector(selectPerpsNetwork); + const isTestnet = network === 'testnet'; // Default back handler const defaultHandleBack = useCallback(() => { @@ -154,11 +157,22 @@ const PerpsHomeHeader: React.FC = ({ > {title || strings('perps.title')} - {isMYXProviderEnabled && ( + {isMultiProviderEnabled && ( )} + {isTestnet && !isMultiProviderEnabled && ( + + + + Testnet + + + )} diff --git a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx index 61d0e16cf543..338568674186 100644 --- a/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx +++ b/app/components/UI/Perps/components/PerpsPositionCard/PerpsPositionCard.tsx @@ -120,7 +120,7 @@ const PerpsPositionCard: React.FC = ({ const pnlNum = parseFloat(position.unrealizedPnl); - // ROE is always stored as a decimal (e.g., 0.171 for 17.1%) + // ROE is always stored as a decimal (e.g., 0.171 for 17.10%) // Convert to percentage for display const roeValue = parseFloat(position.returnOnEquity || '0'); const roe = isNaN(roeValue) ? 0 : roeValue * 100; @@ -203,7 +203,7 @@ const PerpsPositionCard: React.FC = ({ const roeRaw = Number.parseFloat(position.returnOnEquity || ''); const hasValidRoe = !Number.isNaN(roeRaw) && Number.isFinite(roeRaw); const roeDisplay = hasValidRoe - ? formatPercentage(roeRaw * 100, 1) + ? formatPercentage(roeRaw * 100, 2) : PERPS_CONSTANTS.FallbackPercentageDisplay; const isPositionVariant = compactVariant === 'position'; diff --git a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.constants.ts b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.constants.ts index 503540c9d3bb..ce5a9415972a 100644 --- a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.constants.ts +++ b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.constants.ts @@ -1,11 +1,14 @@ -import type { PerpsProviderType } from '@metamask/perps-controller'; -import type { ProviderDisplayInfo } from './PerpsProviderSelector.types'; +import type { PerpsActiveProviderMode } from '@metamask/perps-controller'; +import type { + ProviderDisplayInfo, + ProviderNetworkOption, +} from './PerpsProviderSelector.types'; /** * Provider display configuration */ export const PROVIDER_DISPLAY_INFO: Record< - PerpsProviderType, + PerpsActiveProviderMode, ProviderDisplayInfo > = { hyperliquid: { @@ -18,4 +21,63 @@ export const PROVIDER_DISPLAY_INFO: Record< name: 'MYX', description: 'BNB Chain perps (Beta)', }, + aggregated: { + id: 'aggregated', + name: 'All Providers', + description: 'Aggregated multi-provider view', + }, }; + +/** + * Combined provider + network options for the unified selector + */ +export const PROVIDER_NETWORK_OPTIONS: ProviderNetworkOption[] = [ + { + id: 'aggregated-mainnet', + providerId: 'aggregated', + isTestnet: false, + name: 'All Providers', + network: 'Mainnet', + description: 'Aggregated multi-provider view', + }, + { + id: 'aggregated-testnet', + providerId: 'aggregated', + isTestnet: true, + name: 'All Providers', + network: 'Testnet', + description: 'Aggregated multi-provider view', + }, + { + id: 'hyperliquid-mainnet', + providerId: 'hyperliquid', + isTestnet: false, + name: 'HyperLiquid', + network: 'Mainnet', + description: 'High-performance L1 perps', + }, + { + id: 'hyperliquid-testnet', + providerId: 'hyperliquid', + isTestnet: true, + name: 'HyperLiquid', + network: 'Testnet', + description: 'High-performance L1 perps', + }, + { + id: 'myx-mainnet', + providerId: 'myx', + isTestnet: false, + name: 'MYX', + network: 'Mainnet', + description: 'BNB Chain perps (Beta)', + }, + { + id: 'myx-testnet', + providerId: 'myx', + isTestnet: true, + name: 'MYX', + network: 'Testnet', + description: 'Linea Sepolia perps (Beta)', + }, +]; diff --git a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.styles.ts b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.styles.ts index c1e152387f81..41fbe03ccd11 100644 --- a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.styles.ts +++ b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.styles.ts @@ -17,10 +17,9 @@ export const styleSheet = (params: { theme: Theme }) => { borderRadius: 16, backgroundColor: theme.colors.background.alternative, marginLeft: 8, + gap: 4, }, - badgeText: { - marginRight: 4, - }, + badgeText: {}, // Bottom sheet styles optionsList: { @@ -45,9 +44,28 @@ export const styleSheet = (params: { theme: Theme }) => { optionContent: { flex: 1, }, - optionName: { + optionNameRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: 8, marginBottom: 2, }, + optionName: {}, + testnetTag: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: 4, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 8, + backgroundColor: theme.colors.warning.muted, + }, + testnetDot: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: theme.colors.warning.default, + }, checkIcon: { marginLeft: 8, }, diff --git a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.types.ts b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.types.ts index b357e3e0a38f..8124758871a3 100644 --- a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.types.ts +++ b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelector.types.ts @@ -1,4 +1,4 @@ -import type { PerpsProviderType } from '@metamask/perps-controller'; +import type { PerpsActiveProviderMode } from '@metamask/perps-controller'; /** * Props for PerpsProviderSelectorBadge component @@ -25,14 +25,14 @@ export interface PerpsProviderSelectorSheetProps { onClose: () => void; /** - * Currently selected provider + * Currently selected option ID (e.g. 'hyperliquid-mainnet') */ - selectedProvider?: PerpsProviderType; + selectedOptionId?: string; /** - * Callback when a provider is selected + * Callback when an option is selected */ - onProviderSelect: (providerId: PerpsProviderType) => void; + onOptionSelect: (option: ProviderNetworkOption) => void | Promise; /** * Test ID for testing purposes @@ -44,8 +44,20 @@ export interface PerpsProviderSelectorSheetProps { * Provider display info for UI */ export interface ProviderDisplayInfo { - id: PerpsProviderType; + id: PerpsActiveProviderMode; name: string; description: string; iconName?: string; } + +/** + * Combined provider + network option for the unified selector + */ +export interface ProviderNetworkOption { + id: string; + providerId: PerpsActiveProviderMode; + isTestnet: boolean; + name: string; + network: string; + description: string; +} diff --git a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorBadge.test.tsx b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorBadge.test.tsx index a85de4a2d5a2..525406638aad 100644 --- a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorBadge.test.tsx +++ b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorBadge.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; import PerpsProviderSelectorBadge from './PerpsProviderSelectorBadge'; import { usePerpsProvider } from '../../hooks/usePerpsProvider'; import Routes from '../../../../../constants/navigation/Routes'; @@ -9,6 +10,10 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), })); +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + jest.mock('../../hooks/usePerpsProvider', () => ({ usePerpsProvider: jest.fn(), })); @@ -18,6 +23,7 @@ jest.mock('../../../../../component-library/hooks', () => ({ styles: { badgeContainer: {}, badgeText: {}, + testnetDot: {}, }, }), })); @@ -32,7 +38,7 @@ jest.mock('../../../../../component-library/components/Texts/Text', () => { __esModule: true, default: MockText, TextVariant: { BodySM: 'BodySM' }, - TextColor: { Alternative: 'Alternative' }, + TextColor: { Alternative: 'Alternative', Warning: 'Warning' }, }; }); @@ -45,16 +51,19 @@ jest.mock('../../../../../component-library/components/Icons/Icon', () => { ), IconName: { ArrowDown: 'ArrowDown' }, IconSize: { Xs: 'Xs' }, - IconColor: { Alternative: 'Alternative' }, + IconColor: { Alternative: 'Alternative', Warning: 'Warning' }, }; }); const mockNavigate = jest.fn(); const mockUsePerpsProvider = usePerpsProvider as jest.Mock; +const mockUseSelector = useSelector as jest.Mock; beforeEach(() => { jest.clearAllMocks(); (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate }); + // Default to mainnet + mockUseSelector.mockReturnValue('mainnet'); }); describe('PerpsProviderSelectorBadge', () => { @@ -91,7 +100,7 @@ describe('PerpsProviderSelectorBadge', () => { expect(getByText('MYX')).toBeTruthy(); }); - it('defaults to HyperLiquid when activeProvider is aggregated', () => { + it('shows All Providers when activeProvider is aggregated', () => { mockUsePerpsProvider.mockReturnValue({ activeProvider: 'aggregated', isMultiProviderEnabled: true, @@ -99,7 +108,7 @@ describe('PerpsProviderSelectorBadge', () => { const { getByText } = render(); - expect(getByText('HyperLiquid')).toBeTruthy(); + expect(getByText('All Providers')).toBeOnTheScreen(); }); it('navigates to provider selection on press', () => { @@ -132,5 +141,21 @@ describe('PerpsProviderSelectorBadge', () => { const badge = getByTestId('badge'); expect(badge.props.accessibilityRole).toBe('button'); expect(badge.props.accessibilityLabel).toContain('MYX'); + expect(badge.props.accessibilityLabel).toContain('Mainnet'); + }); + + it('renders testnet dot and warning styling when on testnet', () => { + mockUseSelector.mockReturnValue('testnet'); + mockUsePerpsProvider.mockReturnValue({ + activeProvider: 'hyperliquid', + isMultiProviderEnabled: true, + }); + + const { getByTestId } = render( + , + ); + + const badge = getByTestId('badge'); + expect(badge.props.accessibilityLabel).toContain('Testnet'); }); }); diff --git a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorBadge.tsx b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorBadge.tsx index 4a51f0382994..8d6819a3d97c 100644 --- a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorBadge.tsx +++ b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorBadge.tsx @@ -1,6 +1,7 @@ import React, { useCallback } from 'react'; -import { TouchableOpacity } from 'react-native'; +import { TouchableOpacity, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; import { useStyles } from '../../../../../component-library/hooks'; import Text, { TextVariant, @@ -13,6 +14,7 @@ import Icon, { } from '../../../../../component-library/components/Icons/Icon'; import Routes from '../../../../../constants/navigation/Routes'; import { usePerpsProvider } from '../../hooks/usePerpsProvider'; +import { selectPerpsNetwork } from '../../selectors/perpsController'; import type { PerpsProviderSelectorBadgeProps } from './PerpsProviderSelector.types'; import { PROVIDER_DISPLAY_INFO } from './PerpsProviderSelector.constants'; import { styleSheet } from './PerpsProviderSelector.styles'; @@ -20,13 +22,9 @@ import { styleSheet } from './PerpsProviderSelector.styles'; /** * PerpsProviderSelectorBadge Component * - * A compact badge that shows the current provider and opens selection sheet. + * A compact badge that shows the current provider + network and opens selection sheet. * Only visible when multiple providers are available. - * - * @example - * ```tsx - * - * ``` + * Shows a warning dot when on testnet. */ const PerpsProviderSelectorBadge: React.FC = ({ testID, @@ -34,6 +32,8 @@ const PerpsProviderSelectorBadge: React.FC = ({ const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); const { activeProvider, isMultiProviderEnabled } = usePerpsProvider(); + const network = useSelector(selectPerpsNetwork); + const isTestnet = network === 'testnet'; const handlePress = useCallback(() => { navigation.navigate(Routes.PERPS.MODALS.ROOT, { @@ -47,10 +47,9 @@ const PerpsProviderSelectorBadge: React.FC = ({ } // Get display info for current provider - const currentProvider = - activeProvider && activeProvider !== 'aggregated' - ? PROVIDER_DISPLAY_INFO[activeProvider] - : PROVIDER_DISPLAY_INFO.hyperliquid; + const currentProvider = activeProvider + ? PROVIDER_DISPLAY_INFO[activeProvider] + : PROVIDER_DISPLAY_INFO.hyperliquid; return ( = ({ onPress={handlePress} testID={testID} accessibilityRole="button" - accessibilityLabel={`Current provider: ${currentProvider.name}. Tap to change.`} + accessibilityLabel={`Current provider: ${currentProvider.name}, ${isTestnet ? 'Testnet' : 'Mainnet'}. Tap to change.`} > + {isTestnet && } {currentProvider.name} @@ -70,7 +70,7 @@ const PerpsProviderSelectorBadge: React.FC = ({ ); diff --git a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorSheet.test.tsx b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorSheet.test.tsx new file mode 100644 index 000000000000..d9b26bd6994d --- /dev/null +++ b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorSheet.test.tsx @@ -0,0 +1,202 @@ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react-native'; +import { usePerpsProvider } from '../../hooks/usePerpsProvider'; +import PerpsProviderSelectorSheet from './PerpsProviderSelectorSheet'; + +jest.mock('../../hooks/usePerpsProvider', () => ({ + usePerpsProvider: jest.fn(), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock('../../../../../component-library/hooks', () => ({ + useStyles: () => ({ + styles: { + optionsList: {}, + optionRow: {}, + optionRowSelected: {}, + optionContent: {}, + optionNameRow: {}, + optionName: {}, + testnetTag: {}, + testnetDot: {}, + checkIcon: {}, + }, + }), +})); + +jest.mock('../../../../../component-library/components/Texts/Text', () => { + const { Text: RNText } = jest.requireActual('react-native'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const MockText = ({ children, ...props }: any) => ( + {children} + ); + MockText.displayName = 'Text'; + return { + __esModule: true, + default: MockText, + TextVariant: { + HeadingMD: 'HeadingMD', + BodyMDMedium: 'BodyMDMedium', + BodyXS: 'BodyXS', + BodySM: 'BodySM', + }, + TextColor: { Alternative: 'Alternative', Warning: 'Warning' }, + }; +}); + +jest.mock('../../../../../component-library/components/Icons/Icon', () => { + const { View } = jest.requireActual('react-native'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + default: (props: any) => , + IconName: { Check: 'Check' }, + IconSize: { Md: 'Md' }, + IconColor: { Primary: 'Primary' }, + }; +}); + +/* eslint-disable @typescript-eslint/no-explicit-any */ +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const { View } = jest.requireActual('react-native'); + const mockReact = jest.requireActual('react') as any; + const MockBottomSheet = mockReact.forwardRef( + ({ children, onClose, testID }: any, _ref: any) => ( + + {children} + + ), + ); + MockBottomSheet.displayName = 'BottomSheet'; + return { __esModule: true, default: MockBottomSheet }; + }, +); +/* eslint-enable @typescript-eslint/no-explicit-any */ + +jest.mock( + '../../../../../component-library/components/BottomSheets/BottomSheetHeader', + () => { + const { View } = jest.requireActual('react-native'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + default: ({ children, onClose }: any) => ( + + {children} + + + ), + }; + }, +); + +const mockUsePerpsProvider = usePerpsProvider as jest.Mock; + +const defaultProps = { + isVisible: true, + onClose: jest.fn(), + onOptionSelect: jest.fn(), + testID: 'provider-sheet', +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockUsePerpsProvider.mockReturnValue({ + availableProviders: ['hyperliquid', 'myx'], + }); +}); + +describe('PerpsProviderSelectorSheet', () => { + it('returns null when not visible', () => { + const { toJSON } = render( + , + ); + + expect(toJSON()).toBeNull(); + }); + + it('renders when visible', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('provider-sheet')).toBeTruthy(); + }); + + it('renders only options matching availableProviders', () => { + mockUsePerpsProvider.mockReturnValue({ + availableProviders: ['hyperliquid'], + }); + + const { getAllByText, queryByText } = render( + , + ); + + expect(getAllByText('HyperLiquid').length).toBeGreaterThan(0); + expect(queryByText('MYX')).toBeNull(); + }); + + it('renders all matching options when all providers available', () => { + mockUsePerpsProvider.mockReturnValue({ + availableProviders: ['hyperliquid', 'myx'], + }); + + const { getAllByText } = render( + , + ); + + expect(getAllByText('HyperLiquid').length).toBeGreaterThan(0); + expect(getAllByText('MYX').length).toBeGreaterThan(0); + }); + + it('calls onOptionSelect when an option is pressed', async () => { + const onOptionSelect = jest.fn().mockResolvedValue(undefined); + + const { getByTestId } = render( + , + ); + + await act(async () => { + fireEvent.press(getByTestId('provider-sheet-option-hyperliquid-mainnet')); + }); + + expect(onOptionSelect).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'hyperliquid-mainnet', + providerId: 'hyperliquid', + }), + ); + }); + + it('shows check icon for selected option', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId('provider-sheet-check-hyperliquid-mainnet'), + ).toBeTruthy(); + }); + + it('shows testnet tag for testnet options', () => { + const { getAllByText } = render( + , + ); + + // Testnet network label is rendered for testnet options + expect(getAllByText('Testnet').length).toBeGreaterThan(0); + }); +}); diff --git a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorSheet.tsx b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorSheet.tsx index a1159510f90e..68e816b4b731 100644 --- a/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorSheet.tsx +++ b/app/components/UI/Perps/components/PerpsProviderSelector/PerpsProviderSelectorSheet.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useCallback } from 'react'; +import React, { useRef, useEffect, useCallback, useMemo } from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useStyles } from '../../../../../component-library/hooks'; import Text, { @@ -16,37 +16,37 @@ import BottomSheet, { import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; import { strings } from '../../../../../../locales/i18n'; import { usePerpsProvider } from '../../hooks/usePerpsProvider'; -import type { PerpsProviderSelectorSheetProps } from './PerpsProviderSelector.types'; -import { PROVIDER_DISPLAY_INFO } from './PerpsProviderSelector.constants'; +import type { + PerpsProviderSelectorSheetProps, + ProviderNetworkOption, +} from './PerpsProviderSelector.types'; +import { PROVIDER_NETWORK_OPTIONS } from './PerpsProviderSelector.constants'; import { styleSheet } from './PerpsProviderSelector.styles'; -import type { PerpsProviderType } from '@metamask/perps-controller'; /** * PerpsProviderSelectorSheet Component * - * Bottom sheet for selecting between available perps providers. - * - * @example - * ```tsx - * setShowSheet(false)} - * selectedProvider="hyperliquid" - * onProviderSelect={handleProviderSelect} - * /> - * ``` + * Bottom sheet for selecting between available perps provider + network combinations. */ const PerpsProviderSelectorSheet: React.FC = ({ isVisible, onClose, - selectedProvider, - onProviderSelect, + selectedOptionId, + onOptionSelect, testID, }) => { const { styles } = useStyles(styleSheet, {}); const bottomSheetRef = useRef(null); const { availableProviders } = usePerpsProvider(); + const filteredOptions = useMemo( + () => + PROVIDER_NETWORK_OPTIONS.filter((opt) => + availableProviders.includes(opt.providerId), + ), + [availableProviders], + ); + useEffect(() => { const sheet = bottomSheetRef.current; if (isVisible) { @@ -60,13 +60,12 @@ const PerpsProviderSelectorSheet: React.FC = ({ }; }, [isVisible]); - const handleProviderPress = useCallback( - async (providerId: PerpsProviderType) => { - await onProviderSelect(providerId); - // Just close the sheet - the BottomSheet's onClose prop handles navigation + const handleOptionPress = useCallback( + async (option: ProviderNetworkOption) => { + await onOptionSelect(option); bottomSheetRef.current?.onCloseBottomSheet(); }, - [onProviderSelect], + [onOptionSelect], ); if (!isVisible) return null; @@ -85,32 +84,51 @@ const PerpsProviderSelectorSheet: React.FC = ({ - {availableProviders.map((providerId) => { - const providerInfo = PROVIDER_DISPLAY_INFO[providerId]; - const isSelected = selectedProvider === providerId; + {filteredOptions.map((option) => { + const isSelected = selectedOptionId === option.id; return ( handleProviderPress(providerId)} - testID={testID ? `${testID}-option-${providerId}` : undefined} + onPress={() => handleOptionPress(option)} + testID={testID ? `${testID}-option-${option.id}` : undefined} accessibilityRole="radio" accessibilityState={{ selected: isSelected }} > - - {providerInfo.name} - + + + {option.name} + + {option.isTestnet ? ( + + + + {option.network} + + + ) : ( + + {option.network} + + )} + - {providerInfo.description} + {option.description} {isSelected && ( @@ -119,7 +137,7 @@ const PerpsProviderSelectorSheet: React.FC = ({ size={IconSize.Md} color={IconColor.Primary} style={styles.checkIcon} - testID={testID ? `${testID}-check-${providerId}` : undefined} + testID={testID ? `${testID}-check-${option.id}` : undefined} /> )} diff --git a/app/components/UI/Perps/components/PerpsProviderSelector/index.ts b/app/components/UI/Perps/components/PerpsProviderSelector/index.ts index c0a8b8926f7c..3809c2270ed5 100644 --- a/app/components/UI/Perps/components/PerpsProviderSelector/index.ts +++ b/app/components/UI/Perps/components/PerpsProviderSelector/index.ts @@ -4,5 +4,9 @@ export type { PerpsProviderSelectorBadgeProps, PerpsProviderSelectorSheetProps, ProviderDisplayInfo, + ProviderNetworkOption, } from './PerpsProviderSelector.types'; -export { PROVIDER_DISPLAY_INFO } from './PerpsProviderSelector.constants'; +export { + PROVIDER_DISPLAY_INFO, + PROVIDER_NETWORK_OPTIONS, +} from './PerpsProviderSelector.constants'; diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index be1e7362b02e..280281947dbd 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -6,7 +6,7 @@ * * This file contains: * - UI-only configuration constants (layout, display, navigation) - * - Mobile-specific exports (TokenI, @metamask/swaps-controller dependencies) + * - Mobile-specific exports (TokenI) */ import type { Hex } from '@metamask/utils'; import { TokenI } from '../../Tokens/types'; @@ -16,7 +16,7 @@ export const PERPS_BALANCE_PLACEHOLDER_ADDRESS = '0x0000000000000000000000000000000000000000' as Hex; /** Chain id used for the "Perps balance" payment option. */ -export { ARBITRUM_CHAIN_ID as PERPS_BALANCE_CHAIN_ID } from '@metamask/swaps-controller/dist/constants'; +export { ARBITRUM_MAINNET_CHAIN_ID_HEX as PERPS_BALANCE_CHAIN_ID } from '@metamask/perps-controller/constants/hyperLiquidConfig'; /** * Minimum number of aggregators (exchanges) a token must be listed on @@ -255,5 +255,5 @@ export const PROVIDER_CONFIG = { /** Default perpetual DEX provider when no explicit selection exists */ DefaultProvider: 'hyperliquid' as const, /** Force MYX to testnet only (mainnet credentials not yet available) */ - MYX_TESTNET_ONLY: true, + MYX_TESTNET_ONLY: false, } as const; diff --git a/app/components/UI/Perps/hooks/stream/hasCachedPerpsData.test.ts b/app/components/UI/Perps/hooks/stream/hasCachedPerpsData.test.ts index d6e57809f6c5..e4a2e07eaccf 100644 --- a/app/components/UI/Perps/hooks/stream/hasCachedPerpsData.test.ts +++ b/app/components/UI/Perps/hooks/stream/hasCachedPerpsData.test.ts @@ -1,124 +1,97 @@ import { hasPreloadedData, getPreloadedData } from './hasCachedPerpsData'; -const mockEngineState: Record = {}; - -const mockGetAccountsFromSelectedAccountGroup = jest.fn().mockReturnValue([ - { - address: '0xABCdef1234567890', - id: 'account-1', - type: 'eip155:eoa', - metadata: { name: 'Test', importTime: 0, keyring: { type: 'HD Key Tree' } }, - methods: [], - options: {}, - scopes: [], - }, -]); +let mockCachedMarketDataForActiveProvider: unknown[] | null = null; +let mockCachedUserDataForActiveProvider: { + positions: unknown[]; + orders: unknown[]; + accountState: unknown; +} | null = null; jest.mock('../../../../../core/Engine', () => ({ context: { PerpsController: { - get state() { - return mockEngineState; - }, - }, - AccountTreeController: { - getAccountsFromSelectedAccountGroup: (...args: unknown[]) => - mockGetAccountsFromSelectedAccountGroup(...args), + getCachedMarketDataForActiveProvider: () => + mockCachedMarketDataForActiveProvider, + getCachedUserDataForActiveProvider: () => + mockCachedUserDataForActiveProvider, }, }, })); -const mockFindEvmAccount = jest.fn().mockReturnValue({ - address: '0xABCdef1234567890', -}); - -jest.mock('@metamask/perps-controller', () => ({ - findEvmAccount: (...args: unknown[]) => mockFindEvmAccount(...args), -})); - -/** Helper: set a recent timestamp so user data is not stale */ -function setFreshTimestamp() { - mockEngineState.cachedUserDataTimestamp = Date.now(); -} - describe('hasPreloadedData', () => { beforeEach(() => { - // Reset to empty state - Object.keys(mockEngineState).forEach((key) => delete mockEngineState[key]); - mockFindEvmAccount.mockReturnValue({ - address: '0xABCdef1234567890', - }); - mockGetAccountsFromSelectedAccountGroup.mockReturnValue([ - { - address: '0xABCdef1234567890', - id: 'account-1', - type: 'eip155:eoa', - metadata: { - name: 'Test', - importTime: 0, - keyring: { type: 'HD Key Tree' }, - }, - methods: [], - options: {}, - scopes: [], - }, - ]); + mockCachedMarketDataForActiveProvider = null; + mockCachedUserDataForActiveProvider = null; }); - describe('cachedPositions (array field)', () => { - it('returns false when no cached data exists', () => { + describe('cachedPositions', () => { + it('returns false when no cached data exists (helper returns null)', () => { expect(hasPreloadedData('cachedPositions')).toBe(false); }); - it('returns true when cachedPositions is empty array (valid cache)', () => { - mockEngineState.cachedPositions = []; - setFreshTimestamp(); + it('returns true when helper returns data with empty positions (valid cache)', () => { + mockCachedUserDataForActiveProvider = { + positions: [], + orders: [], + accountState: null, + }; expect(hasPreloadedData('cachedPositions')).toBe(true); }); - it('returns false when cachedPositions is null', () => { - mockEngineState.cachedPositions = null; - expect(hasPreloadedData('cachedPositions')).toBe(false); - }); - - it('returns true when cachedPositions has items', () => { - mockEngineState.cachedPositions = [{ symbol: 'BTC-PERP' }]; - setFreshTimestamp(); + it('returns true when helper returns data with positions', () => { + mockCachedUserDataForActiveProvider = { + positions: [{ symbol: 'BTC-PERP' }], + orders: [], + accountState: null, + }; expect(hasPreloadedData('cachedPositions')).toBe(true); }); }); - describe('cachedOrders (array field)', () => { + describe('cachedOrders', () => { it('returns false when no cached data exists', () => { expect(hasPreloadedData('cachedOrders')).toBe(false); }); - it('returns true when cachedOrders has items', () => { - mockEngineState.cachedOrders = [{ orderId: 'order-1' }]; - setFreshTimestamp(); + it('returns true when helper returns data with orders', () => { + mockCachedUserDataForActiveProvider = { + positions: [], + orders: [{ orderId: 'order-1' }], + accountState: null, + }; expect(hasPreloadedData('cachedOrders')).toBe(true); }); - it('returns true when cachedOrders is empty array (valid cache)', () => { - mockEngineState.cachedOrders = []; - setFreshTimestamp(); + it('returns true when helper returns data with empty orders (valid cache)', () => { + mockCachedUserDataForActiveProvider = { + positions: [], + orders: [], + accountState: null, + }; expect(hasPreloadedData('cachedOrders')).toBe(true); }); }); - describe('cachedAccountState (object field)', () => { + describe('cachedAccountState', () => { it('returns false when no cached data exists', () => { expect(hasPreloadedData('cachedAccountState')).toBe(false); }); - it('returns true when cachedAccountState exists', () => { - mockEngineState.cachedAccountState = { availableBalance: '1000' }; - setFreshTimestamp(); + it('returns true when helper returns data with accountState', () => { + mockCachedUserDataForActiveProvider = { + positions: [], + orders: [], + accountState: { availableBalance: '1000' }, + }; expect(hasPreloadedData('cachedAccountState')).toBe(true); }); - it('returns false when cachedAccountState is null', () => { - mockEngineState.cachedAccountState = null; + it('returns false when helper returns data with null accountState', () => { + mockCachedUserDataForActiveProvider = { + positions: [], + orders: [], + accountState: null, + }; expect(hasPreloadedData('cachedAccountState')).toBe(false); }); }); @@ -128,249 +101,104 @@ describe('hasPreloadedData', () => { expect(hasPreloadedData('cachedMarketData')).toBe(false); }); - it('returns false when cachedMarketData is null', () => { - mockEngineState.cachedMarketData = null; + it('returns false when getCachedMarketDataForActiveProvider returns null', () => { + mockCachedMarketDataForActiveProvider = null; expect(hasPreloadedData('cachedMarketData')).toBe(false); }); - it('returns true when cachedMarketData is empty array (valid cache)', () => { - mockEngineState.cachedMarketData = []; + it('returns true when getCachedMarketDataForActiveProvider returns empty array (valid cache)', () => { + mockCachedMarketDataForActiveProvider = []; expect(hasPreloadedData('cachedMarketData')).toBe(true); }); - it('returns true when cachedMarketData has items', () => { - mockEngineState.cachedMarketData = [{ symbol: 'BTC', price: '$50000' }]; + it('returns true when getCachedMarketDataForActiveProvider returns items', () => { + mockCachedMarketDataForActiveProvider = [ + { symbol: 'BTC', price: '$50000' }, + ]; expect(hasPreloadedData('cachedMarketData')).toBe(true); }); }); describe('edge cases', () => { - it('returns false when field is undefined (not set)', () => { - expect(hasPreloadedData('cachedPositions')).toBe(false); - }); - - it('returns false for user data when timestamp is missing (stale)', () => { - mockEngineState.cachedPositions = [{ symbol: 'BTC-PERP' }]; - // No timestamp set — treated as stale for user data - expect(hasPreloadedData('cachedPositions')).toBe(false); - }); - }); - - describe('staleness check', () => { - it('returns false for user data when cache is older than 60s', () => { - mockEngineState.cachedPositions = [{ symbol: 'BTC-PERP' }]; - mockEngineState.cachedUserDataTimestamp = Date.now() - 61_000; - - expect(hasPreloadedData('cachedPositions')).toBe(false); - }); - - it('returns true for user data when cache is within 60s', () => { - mockEngineState.cachedPositions = [{ symbol: 'BTC-PERP' }]; - mockEngineState.cachedUserDataTimestamp = Date.now() - 30_000; - - expect(hasPreloadedData('cachedPositions')).toBe(true); - }); - - it('does not apply staleness check to cachedMarketData', () => { - mockEngineState.cachedMarketData = [{ symbol: 'BTC', price: '$50000' }]; - // No timestamp — market data is not affected by staleness check - expect(hasPreloadedData('cachedMarketData')).toBe(true); - }); - }); - - describe('account validation', () => { - it('returns false for user data when cachedUserDataAddress does not match current account', () => { - mockEngineState.cachedPositions = [{ symbol: 'BTC-PERP' }]; - mockEngineState.cachedUserDataAddress = '0xDIFFERENTADDRESS'; - setFreshTimestamp(); - + it('returns false when helper returns null (no cache / stale / wrong account)', () => { expect(hasPreloadedData('cachedPositions')).toBe(false); }); - - it('trusts cache when cachedUserDataAddress is not set', () => { - mockEngineState.cachedPositions = [{ symbol: 'BTC-PERP' }]; - // cachedUserDataAddress not set (undefined) — should trust the cache - setFreshTimestamp(); - - expect(hasPreloadedData('cachedPositions')).toBe(true); - }); - - it('trusts cache when cachedUserDataAddress is null', () => { - mockEngineState.cachedPositions = [{ symbol: 'BTC-PERP' }]; - mockEngineState.cachedUserDataAddress = null; - setFreshTimestamp(); - - expect(hasPreloadedData('cachedPositions')).toBe(true); - }); - - it('trusts cache when AccountTreeController throws', () => { - mockEngineState.cachedPositions = [{ symbol: 'BTC-PERP' }]; - mockEngineState.cachedUserDataAddress = '0xABCdef1234567890'; - setFreshTimestamp(); - mockGetAccountsFromSelectedAccountGroup.mockImplementation(() => { - throw new Error('Controller not initialized'); - }); - - expect(hasPreloadedData('cachedPositions')).toBe(true); - }); - - it('trusts cache when no EVM account found', () => { - mockEngineState.cachedPositions = [{ symbol: 'BTC-PERP' }]; - mockEngineState.cachedUserDataAddress = '0xABCdef1234567890'; - setFreshTimestamp(); - mockFindEvmAccount.mockReturnValue(null); - - expect(hasPreloadedData('cachedPositions')).toBe(true); - }); - - it('trusts cache when EVM account has no address', () => { - mockEngineState.cachedPositions = [{ symbol: 'BTC-PERP' }]; - mockEngineState.cachedUserDataAddress = '0xABCdef1234567890'; - setFreshTimestamp(); - mockFindEvmAccount.mockReturnValue({ address: undefined }); - - expect(hasPreloadedData('cachedPositions')).toBe(true); - }); - - it('skips account check for cachedMarketData (not account-specific)', () => { - mockEngineState.cachedMarketData = [{ symbol: 'BTC', price: '$50000' }]; - mockEngineState.cachedUserDataAddress = '0xDIFFERENTADDRESS'; - mockFindEvmAccount.mockReturnValue({ address: '0xABCdef1234567890' }); - - // Market data is not in USER_DATA_FIELDS, so account check is skipped - expect(hasPreloadedData('cachedMarketData')).toBe(true); - }); - - it('matches addresses case-insensitively', () => { - mockEngineState.cachedPositions = [{ symbol: 'BTC-PERP' }]; - mockEngineState.cachedUserDataAddress = '0xABCDEF1234567890'; - setFreshTimestamp(); - mockFindEvmAccount.mockReturnValue({ address: '0xabcdef1234567890' }); - - expect(hasPreloadedData('cachedPositions')).toBe(true); - }); }); }); describe('getPreloadedData', () => { beforeEach(() => { - Object.keys(mockEngineState).forEach((key) => delete mockEngineState[key]); - mockFindEvmAccount.mockReturnValue({ - address: '0xABCdef1234567890', - }); - mockGetAccountsFromSelectedAccountGroup.mockReturnValue([ - { - address: '0xABCdef1234567890', - id: 'account-1', - type: 'eip155:eoa', - metadata: { - name: 'Test', - importTime: 0, - keyring: { type: 'HD Key Tree' }, - }, - methods: [], - options: {}, - scopes: [], - }, - ]); - }); - - it('returns cached data when available', () => { - const accountState = { availableBalance: '1000' }; - mockEngineState.cachedAccountState = accountState; - setFreshTimestamp(); - expect(getPreloadedData('cachedAccountState')).toEqual(accountState); + mockCachedMarketDataForActiveProvider = null; + mockCachedUserDataForActiveProvider = null; }); - it('returns cached array when available', () => { + it('returns cached positions when available', () => { const positions = [{ symbol: 'BTC-PERP' }]; - mockEngineState.cachedPositions = positions; - setFreshTimestamp(); + mockCachedUserDataForActiveProvider = { + positions, + orders: [], + accountState: null, + }; expect(getPreloadedData('cachedPositions')).toEqual(positions); }); - it('returns empty array when field is empty array', () => { - mockEngineState.cachedOrders = []; - setFreshTimestamp(); - expect(getPreloadedData('cachedOrders')).toEqual([]); + it('returns cached orders when available', () => { + const orders = [{ orderId: 'order-1' }]; + mockCachedUserDataForActiveProvider = { + positions: [], + orders, + accountState: null, + }; + expect(getPreloadedData('cachedOrders')).toEqual(orders); }); - it('returns null when no cached data exists', () => { - expect(getPreloadedData('cachedPositions')).toBeNull(); + it('returns empty array for empty positions (valid cache)', () => { + mockCachedUserDataForActiveProvider = { + positions: [], + orders: [], + accountState: null, + }; + expect(getPreloadedData('cachedPositions')).toEqual([]); + }); + + it('returns cached accountState when available', () => { + const accountState = { availableBalance: '1000' }; + mockCachedUserDataForActiveProvider = { + positions: [], + orders: [], + accountState, + }; + expect(getPreloadedData('cachedAccountState')).toEqual(accountState); }); - it('returns null when field is null', () => { - mockEngineState.cachedAccountState = null; + it('returns null for accountState when accountState is null', () => { + mockCachedUserDataForActiveProvider = { + positions: [], + orders: [], + accountState: null, + }; expect(getPreloadedData('cachedAccountState')).toBeNull(); }); + it('returns null when no cached data exists (helper returns null)', () => { + expect(getPreloadedData('cachedPositions')).toBeNull(); + }); + it('returns cached market data when available', () => { const marketData = [{ symbol: 'BTC', price: '$50000' }]; - mockEngineState.cachedMarketData = marketData; + mockCachedMarketDataForActiveProvider = marketData; expect(getPreloadedData('cachedMarketData')).toEqual(marketData); }); it('returns null when cachedMarketData is not set', () => { + mockCachedMarketDataForActiveProvider = null; expect(getPreloadedData('cachedMarketData')).toBeNull(); }); - it('returns null for user data when timestamp is missing (stale)', () => { - mockEngineState.cachedPositions = [{ symbol: 'BTC-PERP' }]; - // No timestamp — treated as stale for user data - expect(getPreloadedData('cachedPositions')).toBeNull(); - }); - - describe('staleness check', () => { - it('returns null for user data when cache is older than 60s', () => { - mockEngineState.cachedOrders = [{ orderId: 'order-1' }]; - mockEngineState.cachedUserDataTimestamp = Date.now() - 61_000; - - expect(getPreloadedData('cachedOrders')).toBeNull(); - }); - - it('returns data for user data when cache is within 60s', () => { - const orders = [{ orderId: 'order-1' }]; - mockEngineState.cachedOrders = orders; - mockEngineState.cachedUserDataTimestamp = Date.now() - 30_000; - - expect(getPreloadedData('cachedOrders')).toEqual(orders); - }); - - it('does not apply staleness check to cachedMarketData', () => { - const marketData = [{ symbol: 'BTC', price: '$50000' }]; - mockEngineState.cachedMarketData = marketData; - // No timestamp — market data is not affected by staleness check - expect(getPreloadedData('cachedMarketData')).toEqual(marketData); - }); - }); - - describe('account validation', () => { - it('returns null for user data when address mismatch', () => { - mockEngineState.cachedAccountState = { availableBalance: '1000' }; - mockEngineState.cachedUserDataAddress = '0xDIFFERENTADDRESS'; - setFreshTimestamp(); - mockFindEvmAccount.mockReturnValue({ address: '0xABCdef1234567890' }); - - expect(getPreloadedData('cachedAccountState')).toBeNull(); - }); - - it('returns data when cachedUserDataAddress is not set', () => { - mockEngineState.cachedAccountState = { availableBalance: '1000' }; - // No cachedUserDataAddress — should trust cache - setFreshTimestamp(); - - expect(getPreloadedData('cachedAccountState')).toEqual({ - availableBalance: '1000', - }); - }); - - it('returns market data regardless of address mismatch', () => { - const marketData = [{ symbol: 'BTC', price: '$50000' }]; - mockEngineState.cachedMarketData = marketData; - mockEngineState.cachedUserDataAddress = '0xDIFFERENTADDRESS'; - mockFindEvmAccount.mockReturnValue({ address: '0xABCdef1234567890' }); - - expect(getPreloadedData('cachedMarketData')).toEqual(marketData); - }); + it('returns market data regardless of user data helper state', () => { + const marketData = [{ symbol: 'BTC', price: '$50000' }]; + mockCachedMarketDataForActiveProvider = marketData; + mockCachedUserDataForActiveProvider = null; // user data is stale/missing + expect(getPreloadedData('cachedMarketData')).toEqual(marketData); }); }); diff --git a/app/components/UI/Perps/hooks/stream/hasCachedPerpsData.ts b/app/components/UI/Perps/hooks/stream/hasCachedPerpsData.ts index b2144b88a38b..c3c1f8ed09d7 100644 --- a/app/components/UI/Perps/hooks/stream/hasCachedPerpsData.ts +++ b/app/components/UI/Perps/hooks/stream/hasCachedPerpsData.ts @@ -1,6 +1,5 @@ -import { InternalAccount } from '@metamask/keyring-internal-api'; import Engine from '../../../../../core/Engine'; -import { findEvmAccount } from '@metamask/perps-controller'; +import type { Position, Order, AccountState } from '@metamask/perps-controller'; type CacheField = | 'cachedPositions' @@ -8,38 +7,25 @@ type CacheField = | 'cachedAccountState' | 'cachedMarketData'; -const USER_DATA_FIELDS: CacheField[] = [ - 'cachedPositions', - 'cachedOrders', - 'cachedAccountState', -]; - /** - * Maximum age (ms) of controller-preloaded user data before it's considered stale. - * Intentionally shorter than the controller's 5-minute refresh cycle — WebSocket - * streams should take over within seconds, making REST preload cache irrelevant. + * Read per-provider market data from the controller's getCachedMarketDataForActiveProvider helper. */ -export const USER_DATA_CACHE_STALE_MS = 60_000; +function getMarketDataFromController(): unknown[] | null { + const controller = Engine.context.PerpsController; + return controller?.getCachedMarketDataForActiveProvider?.() ?? null; +} /** - * Check if the controller's cached data belongs to the currently selected - * EVM account. Market data is not account-specific, so it always passes. + * Read per-provider user data from the controller's getCachedUserDataForActiveProvider helper. + * Returns positions, orders, and accountState for the active provider with TTL + address validation. */ -export function isCacheForCurrentAccount(controller: { - state?: { cachedUserDataAddress?: string | null }; -}): boolean { - const cachedAddr = controller?.state?.cachedUserDataAddress; - if (!cachedAddr) return true; // No address recorded = trust the cache - try { - const { AccountTreeController } = Engine.context; - const accounts = - AccountTreeController.getAccountsFromSelectedAccountGroup(); - const evmAccount = findEvmAccount(accounts as InternalAccount[]); - if (!evmAccount?.address) return true; // Can't determine current account - return cachedAddr.toLowerCase() === evmAccount.address.toLowerCase(); - } catch { - return true; // Error getting account = trust cache - } +function getUserDataFromController(): { + positions: Position[]; + orders: Order[]; + accountState: AccountState | null; +} | null { + const controller = Engine.context.PerpsController; + return controller?.getCachedUserDataForActiveProvider?.() ?? null; } /** @@ -51,17 +37,24 @@ export function isCacheForCurrentAccount(controller: { * (startMarketDataPreload), not by the hooks. */ export function hasPreloadedData(cacheField: CacheField): boolean { - const controller = Engine.context.PerpsController; - const preloaded = controller?.state?.[cacheField]; - // null/undefined = not loaded yet, [] = loaded with no data (valid cache) - if (preloaded == null) return false; - if (USER_DATA_FIELDS.includes(cacheField)) { - const timestamp = controller?.state?.cachedUserDataTimestamp; - if (!timestamp || Date.now() - timestamp >= USER_DATA_CACHE_STALE_MS) - return false; - if (!isCacheForCurrentAccount(controller)) return false; + if (cacheField === 'cachedMarketData') { + const marketData = getMarketDataFromController(); + return marketData != null; + } + + const userData = getUserDataFromController(); + if (!userData) return false; + + if (cacheField === 'cachedPositions') { + return true; // positions is always an array (possibly empty = valid cache) } - return true; + if (cacheField === 'cachedOrders') { + return true; // orders is always an array (possibly empty = valid cache) + } + if (cacheField === 'cachedAccountState') { + return userData.accountState != null; + } + return false; } /** @@ -73,14 +66,21 @@ export function hasPreloadedData(cacheField: CacheField): boolean { * (startMarketDataPreload), not by the hooks. */ export function getPreloadedData(cacheField: CacheField): T | null { - const controller = Engine.context.PerpsController; - const preloaded = (controller?.state?.[cacheField] as T) ?? null; - if (preloaded == null) return null; - if (USER_DATA_FIELDS.includes(cacheField)) { - const timestamp = controller?.state?.cachedUserDataTimestamp; - if (!timestamp || Date.now() - timestamp >= USER_DATA_CACHE_STALE_MS) - return null; - if (!isCacheForCurrentAccount(controller)) return null; + if (cacheField === 'cachedMarketData') { + return (getMarketDataFromController() as T) ?? null; + } + + const userData = getUserDataFromController(); + if (!userData) return null; + + if (cacheField === 'cachedPositions') { + return userData.positions as T; + } + if (cacheField === 'cachedOrders') { + return userData.orders as T; + } + if (cacheField === 'cachedAccountState') { + return (userData.accountState as T) ?? null; } - return preloaded; + return null; } diff --git a/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts b/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts index 93522cb86fc2..4584a54f23e0 100644 --- a/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts +++ b/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts @@ -7,17 +7,16 @@ import { import { type Position, type PriceUpdate } from '@metamask/perps-controller'; // Mock Engine for lazy isInitialLoading check -const mockEngineState = { - cachedPositions: null as Position[] | null, - cachedUserDataTimestamp: 0, -}; +let mockCachedUserData: { + positions: Position[]; + orders: unknown[]; + accountState: unknown; +} | null = null; jest.mock('../../../../../core/Engine', () => ({ context: { PerpsController: { - get state() { - return mockEngineState; - }, + getCachedUserDataForActiveProvider: () => mockCachedUserData, }, }, })); @@ -66,6 +65,7 @@ describe('usePerpsLivePositions', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); + mockCachedUserData = null; }); afterEach(() => { @@ -643,8 +643,11 @@ describe('usePerpsLivePositions', () => { { ...mockPosition, symbol: 'ETH-PERP', size: '10.0' }, ]; - mockEngineState.cachedPositions = cachedPositions; - mockEngineState.cachedUserDataTimestamp = Date.now(); + mockCachedUserData = { + positions: cachedPositions, + orders: [], + accountState: null, + }; mockPositionsSubscribe.mockReturnValue(jest.fn()); mockPricesSubscribe.mockReturnValue(jest.fn()); @@ -655,23 +658,20 @@ describe('usePerpsLivePositions', () => { expect(result.current.isInitialLoading).toBe(false); }); - it('returns empty positions for stale cache (older than 60s)', () => { - mockEngineState.cachedPositions = [mockPosition]; - mockEngineState.cachedUserDataTimestamp = Date.now() - 61_000; + it('returns empty positions for stale cache (helper returns null)', () => { + mockCachedUserData = null; mockPositionsSubscribe.mockReturnValue(jest.fn()); mockPricesSubscribe.mockReturnValue(jest.fn()); const { result } = renderHook(() => usePerpsLivePositions()); - // getPreloadedData enforces 60s TTL — stale cache is not used expect(result.current.positions).toEqual([]); expect(result.current.isInitialLoading).toBe(true); }); it('returns empty positions when no cache exists', () => { - mockEngineState.cachedPositions = null; - mockEngineState.cachedUserDataTimestamp = 0; + mockCachedUserData = null; mockPositionsSubscribe.mockReturnValue(jest.fn()); mockPricesSubscribe.mockReturnValue(jest.fn()); @@ -683,8 +683,7 @@ describe('usePerpsLivePositions', () => { }); it('handles empty cached positions array (valid cache, no positions)', () => { - mockEngineState.cachedPositions = []; - mockEngineState.cachedUserDataTimestamp = Date.now(); + mockCachedUserData = { positions: [], orders: [], accountState: null }; mockPositionsSubscribe.mockReturnValue(jest.fn()); mockPricesSubscribe.mockReturnValue(jest.fn()); diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLiveAccount.test.ts b/app/components/UI/Perps/hooks/stream/usePerpsLiveAccount.test.ts index 863e7bee02a0..a426556269b2 100644 --- a/app/components/UI/Perps/hooks/stream/usePerpsLiveAccount.test.ts +++ b/app/components/UI/Perps/hooks/stream/usePerpsLiveAccount.test.ts @@ -8,17 +8,16 @@ jest.mock('../../../../../../locales/i18n', () => ({ })); // Mock Engine for lazy isInitialLoading check -const mockEngineState = { - cachedAccountState: null as AccountState | null, - cachedUserDataTimestamp: 0, -}; +let mockCachedUserData: { + positions: unknown[]; + orders: unknown[]; + accountState: AccountState | null; +} | null = null; jest.mock('../../../../../core/Engine', () => ({ context: { PerpsController: { - get state() { - return mockEngineState; - }, + getCachedUserDataForActiveProvider: () => mockCachedUserData, }, }, })); @@ -36,6 +35,7 @@ jest.mock('../../providers/PerpsStreamManager', () => ({ describe('usePerpsLiveAccount', () => { beforeEach(() => { jest.clearAllMocks(); + mockCachedUserData = null; }); describe('default state', () => { @@ -269,8 +269,11 @@ describe('usePerpsLiveAccount', () => { totalBalance: '7100', }; - mockEngineState.cachedAccountState = cachedAccount; - mockEngineState.cachedUserDataTimestamp = Date.now(); + mockCachedUserData = { + positions: [], + orders: [], + accountState: cachedAccount, + }; // Mock subscription to NOT call the callback (no WebSocket data yet) mockSubscribe.mockImplementation(() => jest.fn()); @@ -286,11 +289,8 @@ describe('usePerpsLiveAccount', () => { }); }); - it('returns null for stale cached account (older than 60s)', () => { - mockEngineState.cachedAccountState = { - availableBalance: '5000', - } as AccountState; - mockEngineState.cachedUserDataTimestamp = Date.now() - 61_000; + it('returns null for stale cached account (helper returns null)', () => { + mockCachedUserData = null; mockSubscribe.mockImplementation(() => jest.fn()); @@ -298,7 +298,6 @@ describe('usePerpsLiveAccount', () => { state: {}, }); - // getPreloadedData enforces 60s TTL — stale cache is not used expect(result.current).toEqual({ account: null, isInitialLoading: true, @@ -306,8 +305,7 @@ describe('usePerpsLiveAccount', () => { }); it('has null account when no cache exists', () => { - mockEngineState.cachedAccountState = null; - mockEngineState.cachedUserDataTimestamp = 0; + mockCachedUserData = null; mockSubscribe.mockImplementation(() => jest.fn()); diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.test.ts b/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.test.ts index db8261749ec2..8be962a520c1 100644 --- a/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.test.ts +++ b/app/components/UI/Perps/hooks/stream/usePerpsLiveOrders.test.ts @@ -4,17 +4,16 @@ import { usePerpsLiveOrders } from './index'; import { type Order } from '@metamask/perps-controller'; // Mock Engine for lazy isInitialLoading check -const mockEngineState = { - cachedOrders: null as Order[] | null, - cachedUserDataTimestamp: 0, -}; +let mockCachedUserData: { + positions: unknown[]; + orders: Order[]; + accountState: unknown; +} | null = null; jest.mock('../../../../../core/Engine', () => ({ context: { PerpsController: { - get state() { - return mockEngineState; - }, + getCachedUserDataForActiveProvider: () => mockCachedUserData, }, }, })); @@ -49,6 +48,7 @@ describe('usePerpsLiveOrders', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); + mockCachedUserData = null; }); afterEach(() => { @@ -209,8 +209,11 @@ describe('usePerpsLiveOrders', () => { { ...mockOrder, orderId: 'order-2', symbol: 'ETH-PERP' } as Order, ]; - mockEngineState.cachedOrders = cachedOrders; - mockEngineState.cachedUserDataTimestamp = Date.now(); + mockCachedUserData = { + positions: [], + orders: cachedOrders, + accountState: null, + }; mockSubscribe.mockReturnValue(jest.fn()); @@ -220,22 +223,19 @@ describe('usePerpsLiveOrders', () => { expect(result.current.isInitialLoading).toBe(false); }); - it('returns empty orders for stale cache (older than 60s)', () => { - mockEngineState.cachedOrders = [mockOrder]; - mockEngineState.cachedUserDataTimestamp = Date.now() - 61_000; + it('returns empty orders for stale cache (helper returns null)', () => { + mockCachedUserData = null; // Controller helper returns null for stale/invalid cache mockSubscribe.mockReturnValue(jest.fn()); const { result } = renderHook(() => usePerpsLiveOrders()); - // getPreloadedData enforces 60s TTL — stale cache is not used expect(result.current.orders).toEqual([]); expect(result.current.isInitialLoading).toBe(true); }); it('returns empty orders when no cache exists', () => { - mockEngineState.cachedOrders = null; - mockEngineState.cachedUserDataTimestamp = 0; + mockCachedUserData = null; mockSubscribe.mockReturnValue(jest.fn()); @@ -246,8 +246,7 @@ describe('usePerpsLiveOrders', () => { }); it('handles empty cached orders array (valid cache, no orders)', () => { - mockEngineState.cachedOrders = []; - mockEngineState.cachedUserDataTimestamp = Date.now(); + mockCachedUserData = { positions: [], orders: [], accountState: null }; mockSubscribe.mockReturnValue(jest.fn()); diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts index d4d37e29cd71..75018cd64c3f 100644 --- a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-native'; +import { renderHook, waitFor } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import { TransactionType } from '@metamask/transaction-controller'; import { usePerpsBalanceTokenFilter } from './usePerpsBalanceTokenFilter'; @@ -10,8 +10,12 @@ import { isHighlightedItemOutsideAssetList, } from '../../../Views/confirmations/types/token'; import { usePerpsTrading } from './usePerpsTrading'; -import { useConfirmNavigation } from '../../../Views/confirmations/hooks/useConfirmNavigation'; import { usePerpsPaymentToken } from './usePerpsPaymentToken'; +import Routes from '../../../../constants/navigation/Routes'; +import { useNavigation } from '@react-navigation/native'; +import useApprovalRequest from '../../../Views/confirmations/hooks/useApprovalRequest'; +import { selectPerpsAccountState } from '../selectors/perpsController'; +import { selectPerpsPayWithAnyTokenAllowlistAssets } from '../selectors/featureFlags'; jest.mock('../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => key), @@ -22,8 +26,12 @@ jest.mock( ); jest.mock('./useIsPerpsBalanceSelected'); jest.mock('./usePerpsTrading'); -jest.mock('../../../Views/confirmations/hooks/useConfirmNavigation', () => ({ - useConfirmNavigation: jest.fn(), +jest.mock('../../../Views/confirmations/hooks/useApprovalRequest', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), })); jest.mock('./usePerpsPaymentToken'); jest.mock('./usePerpsNetworkManagement', () => ({ @@ -58,17 +66,21 @@ const mockUseSelector = useSelector as jest.MockedFunction; const mockUsePerpsTrading = usePerpsTrading as jest.MockedFunction< typeof usePerpsTrading >; -const mockUseConfirmNavigation = useConfirmNavigation as jest.MockedFunction< - typeof useConfirmNavigation ->; const mockUsePerpsPaymentToken = usePerpsPaymentToken as jest.MockedFunction< typeof usePerpsPaymentToken >; +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockUseApprovalRequest = useApprovalRequest as jest.MockedFunction< + typeof useApprovalRequest +>; describe('usePerpsBalanceTokenFilter', () => { const chainId = '0xa4b1'; const mockDepositWithConfirmation = jest.fn().mockResolvedValue(undefined); - const mockNavigateToConfirmation = jest.fn(); + const mockNavigate = jest.fn(); + const mockOnReject = jest.fn(); const mockOnPerpsPaymentTokenChange = jest.fn(); beforeEach(() => { @@ -77,10 +89,10 @@ describe('usePerpsBalanceTokenFilter', () => { mockUseIsPerpsBalanceSelected.mockReturnValue(false); mockUseSelector.mockImplementation( (selector: (state: unknown) => unknown) => { - if (selector.name === 'selectPerpsAccountState') { + if (selector === selectPerpsAccountState) { return { availableBalance: '1500.00' }; } - if (selector.name === 'selectPerpsPayWithAnyTokenAllowlistAssets') { + if (selector === selectPerpsPayWithAnyTokenAllowlistAssets) { return []; } return undefined; @@ -89,9 +101,12 @@ describe('usePerpsBalanceTokenFilter', () => { mockUsePerpsTrading.mockReturnValue({ depositWithConfirmation: mockDepositWithConfirmation, } as unknown as ReturnType); - mockUseConfirmNavigation.mockReturnValue({ - navigateToConfirmation: mockNavigateToConfirmation, - } as unknown as ReturnType); + mockUseNavigation.mockReturnValue({ + navigate: mockNavigate, + } as unknown as ReturnType); + mockUseApprovalRequest.mockReturnValue({ + onReject: mockOnReject, + } as unknown as ReturnType); mockUsePerpsPaymentToken.mockReturnValue({ onPaymentTokenChange: mockOnPerpsPaymentTokenChange, } as unknown as ReturnType); @@ -200,9 +215,8 @@ describe('usePerpsBalanceTokenFilter', () => { it('uses zero balance when perps account is null', () => { mockUseSelector.mockImplementation( (selector: (state: unknown) => unknown) => { - if (selector.name === 'selectPerpsAccountState') return null; - if (selector.name === 'selectPerpsPayWithAnyTokenAllowlistAssets') - return []; + if (selector === selectPerpsAccountState) return null; + if (selector === selectPerpsPayWithAnyTokenAllowlistAssets) return []; return undefined; }, ); @@ -222,10 +236,9 @@ describe('usePerpsBalanceTokenFilter', () => { it('clears isSelected on other tokens when perps balance is selected', () => { mockUseSelector.mockImplementation( (selector: (state: unknown) => unknown) => { - if (selector.name === 'selectPerpsAccountState') + if (selector === selectPerpsAccountState) return { availableBalance: '1500.00' }; - if (selector.name === 'selectPerpsPayWithAnyTokenAllowlistAssets') - return []; + if (selector === selectPerpsPayWithAnyTokenAllowlistAssets) return []; return undefined; }, ); @@ -255,10 +268,9 @@ describe('usePerpsBalanceTokenFilter', () => { it('keeps token isSelected when perps balance is not selected', () => { mockUseSelector.mockImplementation( (selector: (state: unknown) => unknown) => { - if (selector.name === 'selectPerpsAccountState') + if (selector === selectPerpsAccountState) return { availableBalance: '1500.00' }; - if (selector.name === 'selectPerpsPayWithAnyTokenAllowlistAssets') - return []; + if (selector === selectPerpsPayWithAnyTokenAllowlistAssets) return []; return undefined; }, ); @@ -280,14 +292,15 @@ describe('usePerpsBalanceTokenFilter', () => { it('filters to only allowlisted tokens when allowlist is set', () => { const allowlistKey = `${chainId}.0xusdc`.toLowerCase(); - let callIndex = 0; - mockUseSelector.mockImplementation(() => { - callIndex += 1; - // Hook calls selectPerpsAccountState first, then selectPerpsPayWithAnyTokenAllowlistAssets - if (callIndex === 1) return { availableBalance: '100.00' }; - if (callIndex === 2) return [allowlistKey]; - return []; - }); + mockUseSelector.mockImplementation( + (selector: (state: unknown) => unknown) => { + if (selector === selectPerpsAccountState) + return { availableBalance: '100.00' }; + if (selector === selectPerpsPayWithAnyTokenAllowlistAssets) + return [allowlistKey]; + return []; + }, + ); const inputTokens: AssetType[] = [ { address: '0xusdc', @@ -314,7 +327,7 @@ describe('usePerpsBalanceTokenFilter', () => { expect((output[1] as AssetType).symbol).toBe('USDC'); }); - it('calls navigateToConfirmation and depositWithConfirmation when Add funds is pressed', async () => { + it('calls onReject, depositWithConfirmation and navigation.navigate when Add funds is pressed', async () => { mockUseSelector.mockReturnValue({ availableBalance: '500.00', }); @@ -336,12 +349,17 @@ describe('usePerpsBalanceTokenFilter', () => { expect(isHighlightedItemOutsideAssetList(highlightedAction)).toBe(true); if (isHighlightedItemOutsideAssetList(highlightedAction)) { highlightedAction.actions?.[0]?.onPress(); - // handlePerpsDepositPress is async (ensureArbitrumNetworkExists().then(...)) - await Promise.resolve(); - expect(mockNavigateToConfirmation).toHaveBeenCalledWith({ - stack: expect.any(String), - }); - expect(mockDepositWithConfirmation).toHaveBeenCalledTimes(1); + await waitFor( + () => { + expect(mockOnReject).toHaveBeenCalledTimes(1); + expect(mockDepositWithConfirmation).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + { showPerpsHeader: true }, + ); + }, + { timeout: 2000 }, + ); } }); diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts index 659d16311de8..76b80d8cec04 100644 --- a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts @@ -19,8 +19,9 @@ import { useIsPerpsBalanceSelected } from './useIsPerpsBalanceSelected'; import { usePerpsPaymentToken } from './usePerpsPaymentToken'; import Routes from '../../../../constants/navigation/Routes'; import { usePerpsTrading } from './usePerpsTrading'; +import { useNavigation } from '@react-navigation/native'; +import useApprovalRequest from '../../../Views/confirmations/hooks/useApprovalRequest'; import { usePerpsNetworkManagement } from './usePerpsNetworkManagement'; -import { useConfirmNavigation } from '../../../Views/confirmations/hooks/useConfirmNavigation'; /** URI for the perps balance token icon, shared with PerpsPayRow and pay-with modal. */ const resolvedPerpsIcon = Image.resolveAssetSource(perpsPayTokenIcon); @@ -46,27 +47,38 @@ export function usePerpsBalanceTokenFilter(): ( const formatFiat = useFiatFormatter({ currency: 'usd' }); const { depositWithConfirmation } = usePerpsTrading(); - const { ensureArbitrumNetworkExists } = usePerpsNetworkManagement(); - const { navigateToConfirmation } = useConfirmNavigation(); const isPerpsDepositAndOrder = hasTransactionType(transactionMeta, [ TransactionType.perpsDepositAndOrder, ]); + const { onReject: handleReject } = useApprovalRequest(); + const { ensureArbitrumNetworkExists } = usePerpsNetworkManagement(); + + const navigation = useNavigation(); + const handlePerpsDepositPress = useCallback(() => { ensureArbitrumNetworkExists() .then(() => { - navigateToConfirmation({ stack: Routes.PERPS.ROOT }); + handleReject(); return depositWithConfirmation(); }) - .catch((_err) => { + .then(() => { + navigation.navigate( + Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, + { + showPerpsHeader: true, + }, + ); + }) + .catch(() => { // Deposit flow handles errors (e.g. user rejection or missing network). - // ensureArbitrumNetworkExists errors are logged inside the hook itself. }); }, [ - ensureArbitrumNetworkExists, - navigateToConfirmation, + navigation, depositWithConfirmation, + handleReject, + ensureArbitrumNetworkExists, ]); const { onPaymentTokenChange: onPerpsPaymentTokenChange } = diff --git a/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts b/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts index 08a2ab45ba52..2390f2de6b64 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts @@ -107,9 +107,6 @@ describe('usePerpsMarketListView', () => { mockUsePerpsSearch.mockReturnValue({ searchQuery: '', setSearchQuery: jest.fn(), - isSearchVisible: false, - setIsSearchVisible: jest.fn(), - toggleSearchVisibility: jest.fn(), filteredMarkets: mockMarketsWithValidVolume, // Already filtered by volume clearSearch: jest.fn(), }); @@ -148,25 +145,11 @@ describe('usePerpsMarketListView', () => { expect(result.current.favoritesState).toBeDefined(); }); - it('respects defaultSearchVisible parameter', () => { - mockUsePerpsSearch.mockReturnValue({ - searchQuery: '', - setSearchQuery: jest.fn(), - isSearchVisible: true, - setIsSearchVisible: jest.fn(), - toggleSearchVisibility: jest.fn(), - filteredMarkets: mockMarketsWithValidVolume, - clearSearch: jest.fn(), - }); - - const { result } = renderHook(() => - usePerpsMarketListView({ defaultSearchVisible: true }), - ); + it('passes markets to usePerpsSearch', () => { + renderHook(() => usePerpsMarketListView()); - expect(result.current.searchState.isSearchVisible).toBe(true); expect(mockUsePerpsSearch).toHaveBeenCalledWith({ markets: expect.any(Array), - initialSearchVisible: true, }); }); @@ -268,9 +251,6 @@ describe('usePerpsMarketListView', () => { mockUsePerpsSearch.mockReturnValue({ searchQuery: '', setSearchQuery: jest.fn(), - isSearchVisible: false, - setIsSearchVisible: jest.fn(), - toggleSearchVisibility: jest.fn(), filteredMarkets: [], clearSearch: jest.fn(), }); @@ -286,9 +266,6 @@ describe('usePerpsMarketListView', () => { const mockSearchState = { searchQuery: 'BTC', setSearchQuery: jest.fn(), - isSearchVisible: true, - setIsSearchVisible: jest.fn(), - toggleSearchVisibility: jest.fn(), filteredMarkets: [mockMarketsWithValidVolume[0]], clearSearch: jest.fn(), }; @@ -298,13 +275,9 @@ describe('usePerpsMarketListView', () => { const { result } = renderHook(() => usePerpsMarketListView()); expect(result.current.searchState.searchQuery).toBe('BTC'); - expect(result.current.searchState.isSearchVisible).toBe(true); expect(result.current.searchState.setSearchQuery).toBe( mockSearchState.setSearchQuery, ); - expect(result.current.searchState.toggleSearchVisibility).toBe( - mockSearchState.toggleSearchVisibility, - ); expect(result.current.searchState.clearSearch).toBe( mockSearchState.clearSearch, ); @@ -460,9 +433,6 @@ describe('usePerpsMarketListView', () => { mockUsePerpsSearch.mockReturnValue({ searchQuery: 'BTC', setSearchQuery: jest.fn(), - isSearchVisible: true, - setIsSearchVisible: jest.fn(), - toggleSearchVisibility: jest.fn(), filteredMarkets: [mockMarketsWithValidVolume[0]], // Only BTC clearSearch: jest.fn(), }); @@ -489,9 +459,6 @@ describe('usePerpsMarketListView', () => { mockUsePerpsSearch.mockReturnValue({ searchQuery: 'ETH', setSearchQuery: jest.fn(), - isSearchVisible: true, - setIsSearchVisible: jest.fn(), - toggleSearchVisibility: jest.fn(), filteredMarkets: [mockMarketsWithValidVolume[1]], // Only ETH clearSearch: jest.fn(), }); @@ -508,10 +475,7 @@ describe('usePerpsMarketListView', () => { }); const { result } = renderHook(() => - usePerpsMarketListView({ - showWatchlistOnly: true, - defaultSearchVisible: true, - }), + usePerpsMarketListView({ showWatchlistOnly: true }), ); // All filters applied @@ -645,9 +609,6 @@ describe('usePerpsMarketListView', () => { mockUsePerpsSearch.mockReturnValue({ searchQuery: '', setSearchQuery: jest.fn(), - isSearchVisible: false, - setIsSearchVisible: jest.fn(), - toggleSearchVisibility: jest.fn(), filteredMarkets: mixedMarkets, clearSearch: jest.fn(), }); @@ -675,9 +636,6 @@ describe('usePerpsMarketListView', () => { mockUsePerpsSearch.mockReturnValue({ searchQuery: '', setSearchQuery: jest.fn(), - isSearchVisible: false, - setIsSearchVisible: jest.fn(), - toggleSearchVisibility: jest.fn(), filteredMarkets: [], clearSearch: jest.fn(), }); @@ -719,9 +677,6 @@ describe('usePerpsMarketListView', () => { mockUsePerpsSearch.mockReturnValue({ searchQuery: '', setSearchQuery: jest.fn(), - isSearchVisible: false, - setIsSearchVisible: jest.fn(), - toggleSearchVisibility: jest.fn(), filteredMarkets: initialMarkets, clearSearch: jest.fn(), }); @@ -745,9 +700,6 @@ describe('usePerpsMarketListView', () => { mockUsePerpsSearch.mockReturnValue({ searchQuery: '', setSearchQuery: jest.fn(), - isSearchVisible: false, - setIsSearchVisible: jest.fn(), - toggleSearchVisibility: jest.fn(), filteredMarkets: updatedMarkets, clearSearch: jest.fn(), }); @@ -799,9 +751,6 @@ describe('usePerpsMarketListView', () => { mockUsePerpsSearch.mockReturnValue({ searchQuery: '', setSearchQuery: jest.fn(), - isSearchVisible: false, - setIsSearchVisible: jest.fn(), - toggleSearchVisibility: jest.fn(), filteredMarkets: mixedMarkets, clearSearch: jest.fn(), }); @@ -861,14 +810,11 @@ describe('usePerpsMarketListView', () => { ).toBe(true); }); - it('ignores category filter when searching', () => { + it('applies category filter when searching', () => { mockUsePerpsSearch.mockReturnValue({ searchQuery: 'BTC', setSearchQuery: jest.fn(), - isSearchVisible: true, - setIsSearchVisible: jest.fn(), - toggleSearchVisibility: jest.fn(), - filteredMarkets: [mixedMarkets[0]], // Only BTC from search + filteredMarkets: [mixedMarkets[0]], // BTC from search clearSearch: jest.fn(), }); @@ -876,9 +822,7 @@ describe('usePerpsMarketListView', () => { usePerpsMarketListView({ defaultMarketTypeFilter: 'forex' }), ); - // When searching, should show search results regardless of category filter - expect(result.current.markets.length).toBe(1); - expect(result.current.markets[0].symbol).toBe('BTC'); + expect(result.current.markets.length).toBe(0); }); it('exposes market type filter state', () => { diff --git a/app/components/UI/Perps/hooks/usePerpsMarketListView.ts b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts index 35ddcb9a85ec..f82653d22439 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketListView.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts @@ -18,11 +18,6 @@ import { import Engine from '../../../../core/Engine'; interface UsePerpsMarketListViewParams { - /** - * Initial search visibility - * @default false - */ - defaultSearchVisible?: boolean; /** * Enable polling for markets data * @default false @@ -56,9 +51,6 @@ interface UsePerpsMarketListViewReturn { searchState: { searchQuery: string; setSearchQuery: (query: string) => void; - isSearchVisible: boolean; - setIsSearchVisible: (visible: boolean) => void; - toggleSearchVisibility: () => void; clearSearch: () => void; }; /** @@ -133,13 +125,11 @@ interface UsePerpsMarketListViewReturn { * isLoading, * error, * } = usePerpsMarketListView({ - * defaultSearchVisible: false, * enablePolling: false, * }); * ``` */ export const usePerpsMarketListView = ({ - defaultSearchVisible = false, enablePolling = false, showWatchlistOnly = false, defaultMarketTypeFilter = 'all', @@ -168,25 +158,13 @@ export const usePerpsMarketListView = ({ defaultMarketTypeFilter, ); - // Use search hook for search state and filtering - // Pass ALL markets to search so it can search across all market types - const searchHook = usePerpsSearch({ - markets: allMarkets, - initialSearchVisible: defaultSearchVisible, - }); + // Use search hook for search state and filtering (search bar always visible in UI) + const searchHook = usePerpsSearch({ markets: allMarkets }); - const { filteredMarkets: searchedMarkets, searchQuery } = searchHook; + const { filteredMarkets: searchedMarkets } = searchHook; - // Apply market type filter AFTER search - // When searching: show all search results across all market types - // When not searching: filter by selected category + // Apply market type filter to search results (search + category work together) const marketTypeFilteredMarkets = useMemo(() => { - // If searching, return search results from all markets (ignore category filter) - if (searchQuery.trim()) { - return searchedMarkets; - } - - // If 'all' selected (no category badge selected), show all markets if (marketTypeFilter === 'all') { return searchedMarkets; } @@ -216,7 +194,7 @@ export const usePerpsMarketListView = ({ // Fallback: return all markets for unknown filter values return searchedMarkets; - }, [searchedMarkets, searchQuery, marketTypeFilter]); + }, [searchedMarkets, marketTypeFilter]); // Use sorting hook for sort state and sorting logic const sortingHook = usePerpsSorting({ @@ -290,9 +268,6 @@ export const usePerpsMarketListView = ({ searchState: { searchQuery: searchHook.searchQuery, setSearchQuery: searchHook.setSearchQuery, - isSearchVisible: searchHook.isSearchVisible, - setIsSearchVisible: searchHook.setIsSearchVisible, - toggleSearchVisibility: searchHook.toggleSearchVisibility, clearSearch: searchHook.clearSearch, }, sortState: { diff --git a/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts b/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts index c349c8ce14e9..0bf8f922457f 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarkets.test.ts @@ -9,8 +9,7 @@ jest.mock('../../../../core/Engine', () => ({ context: { PerpsController: { state: { - cachedMarketData: null, - cachedMarketDataTimestamp: 0, + cachedMarketDataByProvider: {}, }, }, }, diff --git a/app/components/UI/Perps/hooks/usePerpsProvider.test.ts b/app/components/UI/Perps/hooks/usePerpsProvider.test.ts new file mode 100644 index 000000000000..cdbec4e6724d --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsProvider.test.ts @@ -0,0 +1,163 @@ +import { renderHook } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { usePerpsProvider } from './usePerpsProvider'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + context: { + PerpsController: { + switchProvider: jest.fn(), + }, + }, +})); + +const mockUseSelector = useSelector as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + // Default: hyperliquid active, MYX flag off + mockUseSelector.mockImplementation((selector: unknown) => { + const fn = selector as (s: unknown) => unknown; + const fakeState = {}; + // First call = selectPerpsProvider, second = selectPerpsMYXProviderEnabledFlag + const result = fn(fakeState); + return result ?? 'hyperliquid'; + }); +}); + +describe('usePerpsProvider', () => { + describe('availableProviders', () => { + it('includes only hyperliquid when MYX flag is disabled', () => { + mockUseSelector + .mockReturnValueOnce('hyperliquid') // activeProvider + .mockReturnValueOnce(false); // isMYXProviderEnabled + + const { result } = renderHook(() => usePerpsProvider()); + + expect(result.current.availableProviders).toEqual(['hyperliquid']); + }); + + it('includes myx and aggregated when MYX flag is enabled', () => { + mockUseSelector + .mockReturnValueOnce('myx') // activeProvider + .mockReturnValueOnce(true); // isMYXProviderEnabled + + const { result } = renderHook(() => usePerpsProvider()); + + expect(result.current.availableProviders).toEqual([ + 'hyperliquid', + 'myx', + 'aggregated', + ]); + }); + }); + + describe('activeProvider', () => { + it('returns current active provider from selector', () => { + mockUseSelector.mockReturnValueOnce('myx').mockReturnValueOnce(true); + + const { result } = renderHook(() => usePerpsProvider()); + + expect(result.current.activeProvider).toBe('myx'); + }); + }); + + describe('switchProvider', () => { + it('calls PerpsController.switchProvider with the given providerId', async () => { + mockUseSelector + .mockReturnValueOnce('hyperliquid') + .mockReturnValueOnce(false); + ( + Engine.context.PerpsController.switchProvider as jest.Mock + ).mockResolvedValue({ success: true }); + + const { result } = renderHook(() => usePerpsProvider()); + await result.current.switchProvider('myx'); + + expect( + Engine.context.PerpsController.switchProvider, + ).toHaveBeenCalledWith('myx'); + }); + + it('returns the result from PerpsController.switchProvider', async () => { + mockUseSelector + .mockReturnValueOnce('hyperliquid') + .mockReturnValueOnce(false); + const mockResult = { success: false, error: 'Not supported' }; + ( + Engine.context.PerpsController.switchProvider as jest.Mock + ).mockResolvedValue(mockResult); + + const { result } = renderHook(() => usePerpsProvider()); + const response = await result.current.switchProvider('myx'); + + expect(response).toEqual(mockResult); + }); + }); + + describe('isProviderAvailable', () => { + it('returns true for available provider', () => { + mockUseSelector + .mockReturnValueOnce('hyperliquid') + .mockReturnValueOnce(false); + + const { result } = renderHook(() => usePerpsProvider()); + + expect(result.current.isProviderAvailable('hyperliquid')).toBe(true); + }); + + it('returns false for unavailable provider when flag is off', () => { + mockUseSelector + .mockReturnValueOnce('hyperliquid') + .mockReturnValueOnce(false); + + const { result } = renderHook(() => usePerpsProvider()); + + expect(result.current.isProviderAvailable('myx')).toBe(false); + }); + }); + + describe('isMYXProvider / isHyperLiquidProvider / isMultiProviderEnabled', () => { + it('isMYXProvider is true when activeProvider is myx', () => { + mockUseSelector.mockReturnValueOnce('myx').mockReturnValueOnce(true); + + const { result } = renderHook(() => usePerpsProvider()); + + expect(result.current.isMYXProvider).toBe(true); + expect(result.current.isHyperLiquidProvider).toBe(false); + }); + + it('isHyperLiquidProvider is true when activeProvider is hyperliquid', () => { + mockUseSelector + .mockReturnValueOnce('hyperliquid') + .mockReturnValueOnce(false); + + const { result } = renderHook(() => usePerpsProvider()); + + expect(result.current.isHyperLiquidProvider).toBe(true); + expect(result.current.isMYXProvider).toBe(false); + }); + + it('isMultiProviderEnabled is false when only one provider available', () => { + mockUseSelector + .mockReturnValueOnce('hyperliquid') + .mockReturnValueOnce(false); + + const { result } = renderHook(() => usePerpsProvider()); + + expect(result.current.isMultiProviderEnabled).toBe(false); + }); + + it('isMultiProviderEnabled is true when multiple providers available', () => { + mockUseSelector.mockReturnValueOnce('myx').mockReturnValueOnce(true); + + const { result } = renderHook(() => usePerpsProvider()); + + expect(result.current.isMultiProviderEnabled).toBe(true); + }); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsProvider.ts b/app/components/UI/Perps/hooks/usePerpsProvider.ts index fc671d40026c..bec265200e88 100644 --- a/app/components/UI/Perps/hooks/usePerpsProvider.ts +++ b/app/components/UI/Perps/hooks/usePerpsProvider.ts @@ -4,7 +4,6 @@ import Engine from '../../../../core/Engine'; import { selectPerpsProvider } from '../selectors/perpsController'; import { selectPerpsMYXProviderEnabledFlag } from '../selectors/featureFlags'; import type { - PerpsProviderType, PerpsActiveProviderMode, SwitchProviderResult, } from '@metamask/perps-controller'; @@ -24,11 +23,12 @@ export function usePerpsProvider() { /** * Get list of available providers based on feature flags */ - const availableProviders = useMemo((): PerpsProviderType[] => { - const providers: PerpsProviderType[] = ['hyperliquid']; + const availableProviders = useMemo((): PerpsActiveProviderMode[] => { + const providers: PerpsActiveProviderMode[] = ['hyperliquid']; if (isMYXProviderEnabled) { providers.push('myx'); + providers.push('aggregated'); } return providers; @@ -51,7 +51,7 @@ export function usePerpsProvider() { * Check if a specific provider is available */ const isProviderAvailable = useCallback( - (providerId: PerpsProviderType): boolean => + (providerId: PerpsActiveProviderMode): boolean => availableProviders.includes(providerId), [availableProviders], ); diff --git a/app/components/UI/Perps/hooks/usePerpsSearch.test.ts b/app/components/UI/Perps/hooks/usePerpsSearch.test.ts index ec3ff34d013a..e41c30123f33 100644 --- a/app/components/UI/Perps/hooks/usePerpsSearch.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsSearch.test.ts @@ -22,22 +22,13 @@ describe('usePerpsSearch', () => { ]; describe('initialization', () => { - it('returns all markets when search is not visible', () => { + it('returns all markets when search query is empty', () => { const { result } = renderHook(() => usePerpsSearch({ markets: mockMarkets }), ); expect(result.current.filteredMarkets).toEqual(mockMarkets); expect(result.current.searchQuery).toBe(''); - expect(result.current.isSearchVisible).toBe(false); - }); - - it('initializes with search visible when specified', () => { - const { result } = renderHook(() => - usePerpsSearch({ markets: mockMarkets, initialSearchVisible: true }), - ); - - expect(result.current.isSearchVisible).toBe(true); }); it('returns empty array when markets array is empty', () => { @@ -47,62 +38,6 @@ describe('usePerpsSearch', () => { }); }); - describe('search visibility', () => { - it('shows search when setIsSearchVisible is called with true', () => { - const { result } = renderHook(() => - usePerpsSearch({ markets: mockMarkets }), - ); - - act(() => { - result.current.setIsSearchVisible(true); - }); - - expect(result.current.isSearchVisible).toBe(true); - }); - - it('hides search when setIsSearchVisible is called with false', () => { - const { result } = renderHook(() => - usePerpsSearch({ - markets: mockMarkets, - initialSearchVisible: true, - }), - ); - - act(() => { - result.current.setIsSearchVisible(false); - }); - - expect(result.current.isSearchVisible).toBe(false); - }); - - it('toggles search visibility from hidden to visible', () => { - const { result } = renderHook(() => - usePerpsSearch({ markets: mockMarkets }), - ); - - act(() => { - result.current.toggleSearchVisibility(); - }); - - expect(result.current.isSearchVisible).toBe(true); - }); - - it('toggles search visibility from visible to hidden', () => { - const { result } = renderHook(() => - usePerpsSearch({ - markets: mockMarkets, - initialSearchVisible: true, - }), - ); - - act(() => { - result.current.toggleSearchVisibility(); - }); - - expect(result.current.isSearchVisible).toBe(false); - }); - }); - describe('search query management', () => { it('updates search query when setSearchQuery is called', () => { const { result } = renderHook(() => @@ -116,12 +51,9 @@ describe('usePerpsSearch', () => { expect(result.current.searchQuery).toBe('BTC'); }); - it('clears search query and hides search when clearSearch is called', () => { + it('clears search query when clearSearch is called', () => { const { result } = renderHook(() => - usePerpsSearch({ - markets: mockMarkets, - initialSearchVisible: true, - }), + usePerpsSearch({ markets: mockMarkets }), ); act(() => { @@ -133,7 +65,6 @@ describe('usePerpsSearch', () => { }); expect(result.current.searchQuery).toBe(''); - expect(result.current.isSearchVisible).toBe(false); }); }); @@ -144,7 +75,6 @@ describe('usePerpsSearch', () => { ); act(() => { - result.current.setIsSearchVisible(true); result.current.setSearchQuery('BTC'); }); @@ -158,7 +88,6 @@ describe('usePerpsSearch', () => { ); act(() => { - result.current.setIsSearchVisible(true); result.current.setSearchQuery('A'); }); @@ -175,7 +104,6 @@ describe('usePerpsSearch', () => { ); act(() => { - result.current.setIsSearchVisible(true); result.current.setSearchQuery('btc'); }); @@ -191,7 +119,6 @@ describe('usePerpsSearch', () => { ); act(() => { - result.current.setIsSearchVisible(true); result.current.setSearchQuery('Bitcoin'); }); @@ -205,7 +132,6 @@ describe('usePerpsSearch', () => { ); act(() => { - result.current.setIsSearchVisible(true); result.current.setSearchQuery('Ava'); }); @@ -219,7 +145,6 @@ describe('usePerpsSearch', () => { ); act(() => { - result.current.setIsSearchVisible(true); result.current.setSearchQuery('ethereum'); }); @@ -231,10 +156,7 @@ describe('usePerpsSearch', () => { describe('edge cases', () => { it('returns all markets when search query is empty string', () => { const { result } = renderHook(() => - usePerpsSearch({ - markets: mockMarkets, - initialSearchVisible: true, - }), + usePerpsSearch({ markets: mockMarkets }), ); act(() => { @@ -246,10 +168,7 @@ describe('usePerpsSearch', () => { it('returns all markets when search query is only whitespace', () => { const { result } = renderHook(() => - usePerpsSearch({ - markets: mockMarkets, - initialSearchVisible: true, - }), + usePerpsSearch({ markets: mockMarkets }), ); act(() => { @@ -261,10 +180,7 @@ describe('usePerpsSearch', () => { it('returns empty array when no markets match search', () => { const { result } = renderHook(() => - usePerpsSearch({ - markets: mockMarkets, - initialSearchVisible: true, - }), + usePerpsSearch({ markets: mockMarkets }), ); act(() => { @@ -274,25 +190,9 @@ describe('usePerpsSearch', () => { expect(result.current.filteredMarkets).toEqual([]); }); - it('returns all markets when search is hidden regardless of query', () => { - const { result } = renderHook(() => - usePerpsSearch({ markets: mockMarkets }), - ); - - act(() => { - result.current.setIsSearchVisible(false); - result.current.setSearchQuery('BTC'); - }); - - expect(result.current.filteredMarkets).toEqual(mockMarkets); - }); - it('trims whitespace from search query', () => { const { result } = renderHook(() => - usePerpsSearch({ - markets: mockMarkets, - initialSearchVisible: true, - }), + usePerpsSearch({ markets: mockMarkets }), ); act(() => { @@ -313,10 +213,7 @@ describe('usePerpsSearch', () => { ]; const { result } = renderHook(() => - usePerpsSearch({ - markets: marketsWithNullSymbol, - initialSearchVisible: true, - }), + usePerpsSearch({ markets: marketsWithNullSymbol }), ); act(() => { @@ -336,10 +233,7 @@ describe('usePerpsSearch', () => { ]; const { result } = renderHook(() => - usePerpsSearch({ - markets: marketsWithNullName, - initialSearchVisible: true, - }), + usePerpsSearch({ markets: marketsWithNullName }), ); act(() => { @@ -353,10 +247,7 @@ describe('usePerpsSearch', () => { describe('filtering behavior updates', () => { it('updates filtered markets when query changes', () => { const { result } = renderHook(() => - usePerpsSearch({ - markets: mockMarkets, - initialSearchVisible: true, - }), + usePerpsSearch({ markets: mockMarkets }), ); act(() => { @@ -375,8 +266,7 @@ describe('usePerpsSearch', () => { it('updates filtered markets when markets array changes', () => { const { result, rerender } = renderHook( - ({ markets }) => - usePerpsSearch({ markets, initialSearchVisible: true }), + ({ markets }) => usePerpsSearch({ markets }), { initialProps: { markets: mockMarkets } }, ); @@ -395,25 +285,5 @@ describe('usePerpsSearch', () => { expect(result.current.filteredMarkets).toHaveLength(2); }); - - it('updates filtered markets when search visibility changes', () => { - const { result } = renderHook(() => - usePerpsSearch({ markets: mockMarkets }), - ); - - act(() => { - result.current.setSearchQuery('BTC'); - result.current.setIsSearchVisible(false); - }); - - expect(result.current.filteredMarkets).toEqual(mockMarkets); - - act(() => { - result.current.setIsSearchVisible(true); - }); - - expect(result.current.filteredMarkets).toHaveLength(1); - expect(result.current.filteredMarkets[0].symbol).toBe('BTC'); - }); }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsSearch.ts b/app/components/UI/Perps/hooks/usePerpsSearch.ts index 08a52007067e..72f4010e0265 100644 --- a/app/components/UI/Perps/hooks/usePerpsSearch.ts +++ b/app/components/UI/Perps/hooks/usePerpsSearch.ts @@ -9,11 +9,6 @@ interface UsePerpsSearchParams { * Markets to filter */ markets: PerpsMarketData[]; - /** - * Initial search visibility - * @default false - */ - initialSearchVisible?: boolean; } interface UsePerpsSearchReturn { @@ -25,24 +20,12 @@ interface UsePerpsSearchReturn { * Update search query */ setSearchQuery: (query: string) => void; - /** - * Whether search bar is visible - */ - isSearchVisible: boolean; - /** - * Show/hide search bar - */ - setIsSearchVisible: (visible: boolean) => void; - /** - * Toggle search visibility - */ - toggleSearchVisibility: () => void; /** * Markets filtered by search query */ filteredMarkets: PerpsMarketData[]; /** - * Clear search and hide search bar + * Clear search query */ clearSearch: () => void; } @@ -52,7 +35,6 @@ interface UsePerpsSearchReturn { * * Responsibilities: * - Manages search query state - * - Manages search visibility state * - Filters markets by symbol/name * - Type-safe field access * @@ -62,43 +44,30 @@ interface UsePerpsSearchReturn { * const { * searchQuery, * setSearchQuery, - * isSearchVisible, - * toggleSearchVisibility, * filteredMarkets, + * clearSearch, * } = usePerpsSearch({ markets }); * ``` */ export const usePerpsSearch = ({ markets, - initialSearchVisible = false, }: UsePerpsSearchParams): UsePerpsSearchReturn => { const [searchQuery, setSearchQuery] = useState(''); - const [isSearchVisible, setIsSearchVisible] = useState(initialSearchVisible); - - const toggleSearchVisibility = useCallback(() => { - setIsSearchVisible((prev) => !prev); - }, []); const clearSearch = useCallback(() => { setSearchQuery(''); - setIsSearchVisible(false); }, []); - // Filter markets based on search query const filteredMarkets = useMemo(() => { - if (!isSearchVisible || !searchQuery.trim()) { + if (!searchQuery.trim()) { return markets; } - return filterMarketsByQuery(markets, searchQuery); - }, [markets, searchQuery, isSearchVisible]); + }, [markets, searchQuery]); return { searchQuery, setSearchQuery, - isSearchVisible, - setIsSearchVisible, - toggleSearchVisibility, filteredMarkets, clearSearch, }; diff --git a/app/components/UI/Perps/hooks/usePerpsToasts.tsx b/app/components/UI/Perps/hooks/usePerpsToasts.tsx index ec7e5145f69b..f93b5128e89f 100644 --- a/app/components/UI/Perps/hooks/usePerpsToasts.tsx +++ b/app/components/UI/Perps/hooks/usePerpsToasts.tsx @@ -747,7 +747,7 @@ const usePerpsToasts = (): { }} > {' '} - {`${roeValue.toFixed(1)}%`} + {`${roeValue.toFixed(2)}%`} , ), @@ -818,7 +818,7 @@ const usePerpsToasts = (): { }} > {' '} - {`${roeValue.toFixed(1)}%`} + {`${roeValue.toFixed(2)}%`} , ), diff --git a/app/components/UI/Perps/hooks/useWebSocketHealthToast.test.ts b/app/components/UI/Perps/hooks/useWebSocketHealthToast.test.ts index feff6928c230..5c73b38afcd3 100644 --- a/app/components/UI/Perps/hooks/useWebSocketHealthToast.test.ts +++ b/app/components/UI/Perps/hooks/useWebSocketHealthToast.test.ts @@ -40,6 +40,16 @@ jest.mock('../../../../core/Engine', () => ({ }, })); +// Mock PerpsConnectionManager +const mockGetConnectionState = jest + .fn() + .mockReturnValue({ isDisconnecting: false }); +jest.mock('../services/PerpsConnectionManager', () => ({ + PerpsConnectionManager: { + getConnectionState: () => mockGetConnectionState(), + }, +})); + // Auto-retry delay constant (must match the one in the hook) const AUTO_RETRY_DELAY_MS = 10000; // Offline banner delay (must match the one in the hook) @@ -57,6 +67,9 @@ describe('useWebSocketHealthToast', () => { jest.useFakeTimers(); mockUnsubscribe = jest.fn(); + // Default: not an intentional reconnect + mockGetConnectionState.mockReturnValue({ isDisconnecting: false }); + // Default mock implementation mockUsePerpsConnection.mockReturnValue({ isConnected: true, @@ -75,44 +88,55 @@ describe('useWebSocketHealthToast', () => { }); describe('Initial mount behavior', () => { - it('should not show toast when initial state is CONNECTED', () => { + it('skips toast when first callback is CONNECTED (happy path)', () => { renderHook(() => useWebSocketHealthToast()); - // Simulate initial callback with CONNECTED state act(() => { connectionStateCallback(WebSocketConnectionState.Connected, 0); }); - // Should not show toast for initial CONNECTED state expect(mockShow).not.toHaveBeenCalled(); }); - it('should show toast when initial state is DISCONNECTED (after delay)', () => { + it('skips toast when first callback is DISCONNECTED (snapshot only, not a real event)', () => { renderHook(() => useWebSocketHealthToast()); - // Simulate initial callback with DISCONNECTED state act(() => { connectionStateCallback(WebSocketConnectionState.Disconnected, 1); }); + act(() => { + jest.advanceTimersByTime(OFFLINE_BANNER_DELAY_MS); + }); + expect(mockShow).not.toHaveBeenCalled(); + }); + + it('skips toast when first callback is CONNECTING (snapshot only, not a real event)', () => { + renderHook(() => useWebSocketHealthToast()); + + act(() => { + connectionStateCallback(WebSocketConnectionState.Connecting, 2); + }); act(() => { jest.advanceTimersByTime(OFFLINE_BANNER_DELAY_MS); }); - expect(mockShow).toHaveBeenCalledWith( - WebSocketConnectionState.Disconnected, - 1, - ); + expect(mockShow).not.toHaveBeenCalled(); }); - it('should show toast when initial state is CONNECTING (after delay)', () => { + it('schedules Connecting banner after Disconnected snapshot when WS starts reconnecting', () => { renderHook(() => useWebSocketHealthToast()); - // Simulate initial callback with CONNECTING state + // First callback: snapshot act(() => { - connectionStateCallback(WebSocketConnectionState.Connecting, 2); + connectionStateCallback(WebSocketConnectionState.Disconnected, 0); + }); + + // Second callback: real transition + act(() => { + connectionStateCallback(WebSocketConnectionState.Connecting, 1); }); expect(mockShow).not.toHaveBeenCalled(); @@ -123,22 +147,20 @@ describe('useWebSocketHealthToast', () => { expect(mockShow).toHaveBeenCalledWith( WebSocketConnectionState.Connecting, - 2, + 1, ); }); }); describe('State transitions', () => { - it('should show disconnected toast on CONNECTED → DISCONNECTED transition (after delay)', () => { + it('shows Disconnected toast after CONNECTED → DISCONNECTED transition', () => { renderHook(() => useWebSocketHealthToast()); - // First callback: CONNECTED (initial state) act(() => { connectionStateCallback(WebSocketConnectionState.Connected, 0); }); mockShow.mockClear(); - // Second callback: DISCONNECTED (transition - schedules show after delay) act(() => { connectionStateCallback(WebSocketConnectionState.Disconnected, 1); }); @@ -155,10 +177,10 @@ describe('useWebSocketHealthToast', () => { ); }); - it('should show connecting toast on DISCONNECTED → CONNECTING transition (after delay)', () => { + it('shows Connecting toast after DISCONNECTED → CONNECTING transition', () => { renderHook(() => useWebSocketHealthToast()); - // First callback: DISCONNECTED (initial - marks as experienced disconnection) + // First callback: snapshot act(() => { connectionStateCallback(WebSocketConnectionState.Disconnected, 1); }); @@ -167,7 +189,6 @@ describe('useWebSocketHealthToast', () => { }); mockShow.mockClear(); - // Second callback: CONNECTING (transition - schedules show after delay) act(() => { connectionStateCallback(WebSocketConnectionState.Connecting, 2); }); @@ -184,27 +205,24 @@ describe('useWebSocketHealthToast', () => { ); }); - it('should show success toast on reconnection (DISCONNECTED → CONNECTING → CONNECTED)', () => { + it('shows Connected toast on reconnection after DISCONNECTED → CONNECTING → CONNECTED', () => { renderHook(() => useWebSocketHealthToast()); - // Initial: CONNECTED + // Snapshot act(() => { connectionStateCallback(WebSocketConnectionState.Connected, 0); }); - // Disconnected (schedules show after delay; we reconnect before delay) act(() => { connectionStateCallback(WebSocketConnectionState.Disconnected, 1); }); mockShow.mockClear(); - // Reconnecting (schedules show after delay; we reconnect before delay) act(() => { connectionStateCallback(WebSocketConnectionState.Connecting, 2); }); mockShow.mockClear(); - // Reconnected successfully (clears delay, shows Connected immediately) act(() => { connectionStateCallback(WebSocketConnectionState.Connected, 0); }); @@ -215,10 +233,9 @@ describe('useWebSocketHealthToast', () => { ); }); - it('should NOT show success toast on initial connection (no prior disconnection)', () => { + it('omits Connected toast on initial connection with no prior disconnection', () => { renderHook(() => useWebSocketHealthToast()); - // Initial: CONNECTED - should NOT show toast act(() => { connectionStateCallback(WebSocketConnectionState.Connected, 0); }); @@ -228,19 +245,17 @@ describe('useWebSocketHealthToast', () => { }); describe('Retry callback', () => { - it('should register retry callback on mount', () => { + it('registers retry callback on mount', () => { renderHook(() => useWebSocketHealthToast()); expect(mockSetOnRetry).toHaveBeenCalledWith(expect.any(Function)); }); - it('should call PerpsController.reconnect when retry is invoked', () => { + it('calls PerpsController.reconnect when retry callback is invoked', () => { renderHook(() => useWebSocketHealthToast()); - // Get the retry callback that was registered const retryCallback = mockSetOnRetry.mock.calls[0][0]; - // Invoke the retry callback act(() => { retryCallback(); }); @@ -250,7 +265,7 @@ describe('useWebSocketHealthToast', () => { }); describe('Cleanup on unmount', () => { - it('should unsubscribe and hide toast on unmount', () => { + it('calls unsubscribe and hide on unmount', () => { const { unmount } = renderHook(() => useWebSocketHealthToast()); unmount(); @@ -261,15 +276,13 @@ describe('useWebSocketHealthToast', () => { }); describe('Reconnection attempt tracking', () => { - it('should pass reconnection attempt number to show()', () => { + it('passes reconnection attempt number to show()', () => { renderHook(() => useWebSocketHealthToast()); - // Initial: CONNECTED act(() => { connectionStateCallback(WebSocketConnectionState.Connected, 0); }); - // Disconnected act(() => { connectionStateCallback(WebSocketConnectionState.Disconnected, 1); }); @@ -278,7 +291,6 @@ describe('useWebSocketHealthToast', () => { }); mockShow.mockClear(); - // Reconnecting with attempt 3 (schedules show after delay) act(() => { connectionStateCallback(WebSocketConnectionState.Connecting, 3); }); @@ -297,7 +309,7 @@ describe('useWebSocketHealthToast', () => { }); describe('Subscription behavior', () => { - it('should not subscribe when isConnected is false', () => { + it('skips subscription when isConnected is false', () => { mockUsePerpsConnection.mockReturnValue({ isConnected: false, isInitialized: true, @@ -310,7 +322,7 @@ describe('useWebSocketHealthToast', () => { expect(mockSubscribeToConnectionState).not.toHaveBeenCalled(); }); - it('should not subscribe when isInitialized is false', () => { + it('skips subscription when isInitialized is false', () => { mockUsePerpsConnection.mockReturnValue({ isConnected: true, isInitialized: false, @@ -323,7 +335,7 @@ describe('useWebSocketHealthToast', () => { expect(mockSubscribeToConnectionState).not.toHaveBeenCalled(); }); - it('should subscribe when both isConnected and isInitialized are true', () => { + it('subscribes when both isConnected and isInitialized are true', () => { mockUsePerpsConnection.mockReturnValue({ isConnected: true, isInitialized: true, @@ -338,23 +350,21 @@ describe('useWebSocketHealthToast', () => { }); describe('Offline banner delay (flicker prevention)', () => { - it('should NOT show offline banner if reconnected within delay', () => { + it('omits offline banner when connection restores within the delay window', () => { renderHook(() => useWebSocketHealthToast()); - // Initial: CONNECTED act(() => { connectionStateCallback(WebSocketConnectionState.Connected, 0); }); mockShow.mockClear(); - // Disconnected (schedules show after 1s) act(() => { connectionStateCallback(WebSocketConnectionState.Disconnected, 1); }); expect(mockShow).not.toHaveBeenCalled(); - // Reconnect before delay expires + // Restore before delay expires act(() => { connectionStateCallback(WebSocketConnectionState.Connecting, 1); }); @@ -362,13 +372,11 @@ describe('useWebSocketHealthToast', () => { connectionStateCallback(WebSocketConnectionState.Connected, 0); }); - // Advance past the banner delay - show was never scheduled for Disconnected/Connecting - // because we cleared the timer when we got Connected act(() => { jest.advanceTimersByTime(OFFLINE_BANNER_DELAY_MS); }); - // Should only have shown Connected (reconnection success), not Disconnected + // Only the Connected (back-online) toast, not Disconnected expect(mockShow).toHaveBeenCalledTimes(1); expect(mockShow).toHaveBeenCalledWith( WebSocketConnectionState.Connected, @@ -378,16 +386,14 @@ describe('useWebSocketHealthToast', () => { }); describe('DISCONNECTING state', () => { - it('should not show toast for DISCONNECTING state', () => { + it('omits toast for DISCONNECTING state', () => { renderHook(() => useWebSocketHealthToast()); - // Initial: CONNECTED act(() => { connectionStateCallback(WebSocketConnectionState.Connected, 0); }); mockShow.mockClear(); - // DISCONNECTING - no toast act(() => { connectionStateCallback(WebSocketConnectionState.Disconnecting, 0); }); @@ -397,42 +403,40 @@ describe('useWebSocketHealthToast', () => { }); describe('Auto-retry behavior', () => { - it('should schedule auto-retry when entering DISCONNECTED state', () => { + it('fires reconnect after auto-retry delay on CONNECTED → DISCONNECTED transition', () => { renderHook(() => useWebSocketHealthToast()); - // Initial: CONNECTED act(() => { connectionStateCallback(WebSocketConnectionState.Connected, 0); }); - // Transition to DISCONNECTED act(() => { connectionStateCallback(WebSocketConnectionState.Disconnected, 1); }); - // reconnect should not be called yet expect(mockReconnect).not.toHaveBeenCalled(); - // Advance timers by auto-retry delay act(() => { jest.advanceTimersByTime(AUTO_RETRY_DELAY_MS); }); - // reconnect should now be called expect(mockReconnect).toHaveBeenCalledTimes(1); }); - it('should schedule auto-retry when initial state is DISCONNECTED', () => { + it('fires reconnect after auto-retry delay when WS transitions to DISCONNECTED after snapshot', () => { renderHook(() => useWebSocketHealthToast()); - // Initial callback with DISCONNECTED state + // First callback: snapshot (no auto-retry) + act(() => { + connectionStateCallback(WebSocketConnectionState.Connected, 0); + }); + act(() => { connectionStateCallback(WebSocketConnectionState.Disconnected, 1); }); expect(mockReconnect).not.toHaveBeenCalled(); - // Advance timers by auto-retry delay act(() => { jest.advanceTimersByTime(AUTO_RETRY_DELAY_MS); }); @@ -440,100 +444,202 @@ describe('useWebSocketHealthToast', () => { expect(mockReconnect).toHaveBeenCalledTimes(1); }); - it('should cancel auto-retry when entering CONNECTING state', () => { + it('cancels pending auto-retry on DISCONNECTED → CONNECTING transition', () => { renderHook(() => useWebSocketHealthToast()); - // Initial: CONNECTED act(() => { connectionStateCallback(WebSocketConnectionState.Connected, 0); }); - // Transition to DISCONNECTED (schedules auto-retry) act(() => { connectionStateCallback(WebSocketConnectionState.Disconnected, 1); }); - // Transition to CONNECTING (should cancel auto-retry) act(() => { connectionStateCallback(WebSocketConnectionState.Connecting, 2); }); - // Advance timers past auto-retry delay act(() => { jest.advanceTimersByTime(AUTO_RETRY_DELAY_MS + 1000); }); - // reconnect should NOT have been called (auto-retry was cancelled) expect(mockReconnect).not.toHaveBeenCalled(); }); - it('should cancel auto-retry when entering CONNECTED state', () => { + it('cancels pending auto-retry on DISCONNECTED → CONNECTED transition', () => { renderHook(() => useWebSocketHealthToast()); - // Initial: DISCONNECTED (schedules auto-retry) + // Snapshot + act(() => { + connectionStateCallback(WebSocketConnectionState.Connected, 0); + }); + act(() => { connectionStateCallback(WebSocketConnectionState.Disconnected, 1); }); - // Transition to CONNECTED (should cancel auto-retry) act(() => { connectionStateCallback(WebSocketConnectionState.Connected, 0); }); - // Advance timers past auto-retry delay act(() => { jest.advanceTimersByTime(AUTO_RETRY_DELAY_MS + 1000); }); - // reconnect should NOT have been called (auto-retry was cancelled) expect(mockReconnect).not.toHaveBeenCalled(); }); - it('should cancel auto-retry when manual retry is triggered', () => { + it('cancels pending auto-retry when manual retry is triggered', () => { renderHook(() => useWebSocketHealthToast()); - // Initial: DISCONNECTED (schedules auto-retry) + // Snapshot + act(() => { + connectionStateCallback(WebSocketConnectionState.Connected, 0); + }); + act(() => { connectionStateCallback(WebSocketConnectionState.Disconnected, 1); }); - // Get the retry callback and invoke it (manual retry) const retryCallback = mockSetOnRetry.mock.calls[0][0]; act(() => { retryCallback(); }); - // Manual retry should have called reconnect expect(mockReconnect).toHaveBeenCalledTimes(1); mockReconnect.mockClear(); - // Advance timers past auto-retry delay act(() => { jest.advanceTimersByTime(AUTO_RETRY_DELAY_MS + 1000); }); - // reconnect should NOT have been called again (auto-retry was cancelled by manual retry) expect(mockReconnect).not.toHaveBeenCalled(); }); - it('should cancel auto-retry on unmount', () => { + it('cancels pending auto-retry on unmount', () => { const { unmount } = renderHook(() => useWebSocketHealthToast()); - // Initial: DISCONNECTED (schedules auto-retry) + // Snapshot + act(() => { + connectionStateCallback(WebSocketConnectionState.Connected, 0); + }); + act(() => { connectionStateCallback(WebSocketConnectionState.Disconnected, 1); }); - // Unmount unmount(); - // Advance timers past auto-retry delay act(() => { jest.advanceTimersByTime(AUTO_RETRY_DELAY_MS + 1000); }); - // reconnect should NOT have been called (auto-retry was cancelled on unmount) expect(mockReconnect).not.toHaveBeenCalled(); }); }); + + describe('Intentional reconnect suppression (account/network/provider switch)', () => { + const setupIntentionalReconnect = () => { + renderHook(() => useWebSocketHealthToast()); + // Snapshot + act(() => { + connectionStateCallback(WebSocketConnectionState.Connected, 0); + }); + mockShow.mockClear(); + mockHide.mockClear(); + // Mark as intentional from this point on + mockGetConnectionState.mockReturnValue({ isDisconnecting: true }); + }; + + it('suppresses offline toast on Disconnected during intentional reconnect', () => { + setupIntentionalReconnect(); + + act(() => { + connectionStateCallback(WebSocketConnectionState.Disconnected, 0); + }); + act(() => { + jest.advanceTimersByTime(OFFLINE_BANNER_DELAY_MS); + }); + + expect(mockShow).not.toHaveBeenCalled(); + }); + + it('suppresses reconnecting banner on Connecting during intentional reconnect', () => { + setupIntentionalReconnect(); + + act(() => { + connectionStateCallback(WebSocketConnectionState.Disconnected, 0); + }); + act(() => { + connectionStateCallback(WebSocketConnectionState.Connecting, 0); + }); + act(() => { + jest.advanceTimersByTime(OFFLINE_BANNER_DELAY_MS); + }); + + expect(mockShow).not.toHaveBeenCalled(); + expect(mockHide).not.toHaveBeenCalled(); + }); + + it('suppresses back-online toast on Connected after intentional reconnect', () => { + setupIntentionalReconnect(); + + act(() => { + connectionStateCallback(WebSocketConnectionState.Disconnected, 0); + }); + act(() => { + connectionStateCallback(WebSocketConnectionState.Connecting, 0); + }); + // Connection succeeds — isDisconnecting is cleared on the singleton by now + mockGetConnectionState.mockReturnValue({ isDisconnecting: false }); + act(() => { + connectionStateCallback(WebSocketConnectionState.Connected, 0); + }); + + expect(mockShow).not.toHaveBeenCalled(); + }); + + it('does not schedule auto-retry during intentional reconnect', () => { + setupIntentionalReconnect(); + + act(() => { + connectionStateCallback(WebSocketConnectionState.Disconnected, 0); + }); + act(() => { + jest.advanceTimersByTime(AUTO_RETRY_DELAY_MS + 1000); + }); + + expect(mockReconnect).not.toHaveBeenCalled(); + }); + + it('still shows toasts for a real disconnect after a completed intentional reconnect', () => { + setupIntentionalReconnect(); + + // Intentional cycle + act(() => { + connectionStateCallback(WebSocketConnectionState.Disconnected, 0); + }); + act(() => { + connectionStateCallback(WebSocketConnectionState.Connecting, 0); + }); + mockGetConnectionState.mockReturnValue({ isDisconnecting: false }); + act(() => { + connectionStateCallback(WebSocketConnectionState.Connected, 0); + }); + mockShow.mockClear(); + + // Now a real disconnect + act(() => { + connectionStateCallback(WebSocketConnectionState.Disconnected, 1); + }); + act(() => { + jest.advanceTimersByTime(OFFLINE_BANNER_DELAY_MS); + }); + + expect(mockShow).toHaveBeenCalledWith( + WebSocketConnectionState.Disconnected, + 1, + ); + }); + }); }); diff --git a/app/components/UI/Perps/hooks/useWebSocketHealthToast.ts b/app/components/UI/Perps/hooks/useWebSocketHealthToast.ts index ac7389b95494..d3608993f5ce 100644 --- a/app/components/UI/Perps/hooks/useWebSocketHealthToast.ts +++ b/app/components/UI/Perps/hooks/useWebSocketHealthToast.ts @@ -3,6 +3,7 @@ import { usePerpsConnection } from './usePerpsConnection'; import Engine from '../../../../core/Engine'; import { WebSocketConnectionState } from '@metamask/perps-controller'; import { useWebSocketHealthToastContext } from '../components/PerpsWebSocketHealthToast'; +import { PerpsConnectionManager } from '../services/PerpsConnectionManager'; /** Delay before automatically attempting to reconnect after disconnection */ const AUTO_RETRY_DELAY_MS = 10000; @@ -34,9 +35,13 @@ export function useWebSocketHealthToast(): void { // Track the previous WebSocket state for transition detection const previousWsStateRef = useRef(null); - // Track if we've experienced a disconnection after being connected - // This is used to distinguish initial connection from reconnection + // Track if we've experienced a disconnection — used to gate the "back online" toast + // so we only show it after a real outage, not on initial connection. const hasExperiencedDisconnectionRef = useRef(false); + // Track if the current disconnect/reconnect cycle was intentional (account/network switch). + // isDisconnecting is cleared on the singleton before the Connected event fires, so we + // latch the value here when the cycle starts and clear it on Connected. + const isIntentionalReconnectRef = useRef(false); // Timer for auto-retry const autoRetryTimeoutRef = useRef | null>( null, @@ -111,56 +116,63 @@ export function useWebSocketHealthToast(): void { const isNowConnected = newState === WebSocketConnectionState.Connected; - // Handle first callback after mount/remount + // Handle first callback after mount/remount. + // The subscription (BehaviorSubject-style) fires immediately with the current + // internal state, which begins as Disconnected and only becomes Connected once + // the WS handshake completes. We must not show a toast on this initial snapshot + // because we have no prior context — we don't know if this is a genuine offline + // state or just a stale initial value. Wait for a real state transition instead. if (previousWsState === null) { previousWsStateRef.current = newState; - - // If we mount/remount and the connection is already in a problematic state, - // show the toast after a delay to avoid flicker on quick reconnects. - if (newState === WebSocketConnectionState.Disconnected) { - hasExperiencedDisconnectionRef.current = true; - scheduleShowBanner( - WebSocketConnectionState.Disconnected, - attempt, - ); - // Schedule auto-retry for disconnected state - scheduleAutoRetry(); - } else if (newState === WebSocketConnectionState.Connecting) { - hasExperiencedDisconnectionRef.current = true; - scheduleShowBanner(WebSocketConnectionState.Connecting, attempt); - // Clear auto-retry when reconnecting (connection attempt in progress) - clearAutoRetryTimer(); - } - // If CONNECTED on mount, this is normal initial state - no toast needed + // If already Connected on mount, that's the happy path — no toast needed. + // If Disconnected/Connecting on mount, we also skip the banner here; a real + // state change will trigger the switch-case below once the WS settles. return; } - // Detect any transition away from CONNECTED as a disconnection event - if (wasWsConnected && !isNowConnected) { + // Read isDisconnecting directly from the singleton — always live, + // no React render cycle delay. + const isIntentionalReconnect = + PerpsConnectionManager.getConnectionState().isDisconnecting; + + // Latch intentional reconnect flag when the cycle starts (Disconnected/Connecting). + // isDisconnecting is cleared on the singleton before Connected fires, so we + // preserve the value in a ref for the Connected handler. + if (isIntentionalReconnect) { + isIntentionalReconnectRef.current = true; + } + + // Track any transition away from Connected so we can show "back online" later, + // but only for unintentional disconnects. + if (wasWsConnected && !isNowConnected && !isIntentionalReconnect) { hasExperiencedDisconnectionRef.current = true; } // Handle state transitions switch (newState) { case WebSocketConnectionState.Disconnected: - // Show disconnected toast after delay if: - // 1. We were previously connected (direct disconnect), OR - // 2. We've been trying to reconnect and gave up (max attempts reached) - if (wasWsConnected || hasExperiencedDisconnectionRef.current) { - scheduleShowBanner( - WebSocketConnectionState.Disconnected, - attempt, - ); - // Schedule auto-retry for disconnected state - scheduleAutoRetry(); + if (isIntentionalReconnect) { + // Intentional reconnect (account/network/provider switch) — skip offline toast. + // performReconnection() manages its own timeout; no auto-retry needed here. + clearShowBannerDelayTimer(); + break; } + // Any post-snapshot Disconnected state is worth showing — whether it's a + // direct drop from Connected or a failed reconnection attempt. + hasExperiencedDisconnectionRef.current = true; + scheduleShowBanner( + WebSocketConnectionState.Disconnected, + attempt, + ); + scheduleAutoRetry(); break; case WebSocketConnectionState.Connecting: - // Clear auto-retry when reconnecting (connection attempt in progress) clearAutoRetryTimer(); - // Show connecting toast after delay when reconnecting (after a disconnection) - if (hasExperiencedDisconnectionRef.current) { + if (!isIntentionalReconnect) { + // Only arm "back online", hide any existing toast, and show banner for unintentional reconnects + hide(); + hasExperiencedDisconnectionRef.current = true; scheduleShowBanner( WebSocketConnectionState.Connecting, attempt, @@ -173,12 +185,16 @@ export function useWebSocketHealthToast(): void { clearShowBannerDelayTimer(); // Clear auto-retry when connected clearAutoRetryTimer(); - // Show connected toast only if we've experienced a disconnection before - if (hasExperiencedDisconnectionRef.current) { + // Show connected toast only after a real (unintentional) outage + if ( + hasExperiencedDisconnectionRef.current && + !isIntentionalReconnectRef.current + ) { show(WebSocketConnectionState.Connected, attempt); - // Reset the flag after successful reconnection - hasExperiencedDisconnectionRef.current = false; } + // Reset flags after connection restored + hasExperiencedDisconnectionRef.current = false; + isIntentionalReconnectRef.current = false; break; default: diff --git a/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx b/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx index c6d52c51d44d..0177c030c3bc 100644 --- a/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx +++ b/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx @@ -123,6 +123,9 @@ describe('PerpsConnectionProvider', () => { (PerpsConnectionManager.disconnect as jest.Mock) = mockDisconnect; (PerpsConnectionManager.reconnectWithNewContext as jest.Mock) = mockReconnectWithNewContext; + (PerpsConnectionManager.getActiveProviderName as jest.Mock) = jest + .fn() + .mockReturnValue('hyperliquid'); }); afterEach(() => { diff --git a/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx b/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx index e08abed6b59f..19795571ec65 100644 --- a/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx +++ b/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx @@ -6,6 +6,7 @@ import React, { useState, useRef, } from 'react'; +import { addBreadcrumb } from '@sentry/react-native'; import { PerpsConnectionManager } from '../services/PerpsConnectionManager'; import { usePerpsConnectionLifecycle } from '../hooks/usePerpsConnectionLifecycle'; import { isE2E } from '../../../../util/test/utils'; @@ -109,9 +110,15 @@ export const PerpsConnectionProvider: React.FC< try { await PerpsConnectionManager.connect(); } catch (err) { + const providerName = PerpsConnectionManager.getActiveProviderName(); Logger.error(ensureError(err, 'PerpsConnectionProvider.connect'), { - message: 'PerpsConnectionProvider: Error during connect', - context: 'PerpsConnectionProvider.connect', + tags: { + feature: PERPS_CONSTANTS.FeatureName, + component: 'PerpsConnectionManager', + action: 'connection_connection', + ...(providerName && { provider: providerName }), + }, + context: { name: 'PerpsConnectionProvider.connect', data: {} }, }); } // Always update state after connect attempt @@ -137,9 +144,15 @@ export const PerpsConnectionProvider: React.FC< try { await PerpsConnectionManager.disconnect(); } catch (err) { + const providerName = PerpsConnectionManager.getActiveProviderName(); Logger.error(ensureError(err, 'PerpsConnectionProvider.disconnect'), { - message: 'PerpsConnectionProvider: Error during disconnect', - context: 'PerpsConnectionProvider.disconnect', + tags: { + feature: PERPS_CONSTANTS.FeatureName, + component: 'PerpsConnectionManager', + action: 'connection_disconnection', + ...(providerName && { provider: providerName }), + }, + context: { name: 'PerpsConnectionProvider.disconnect', data: {} }, }); } // Always update state after disconnect attempt @@ -175,11 +188,20 @@ export const PerpsConnectionProvider: React.FC< // Use the existing reconnectWithNewContext method from the singleton await PerpsConnectionManager.reconnectWithNewContext(options); } catch (err) { + const providerName = PerpsConnectionManager.getActiveProviderName(); Logger.error( ensureError(err, 'PerpsConnectionProvider.reconnectWithNewContext'), { - message: 'PerpsConnectionProvider: Error during reconnect', - context: 'PerpsConnectionProvider.reconnectWithNewContext', + tags: { + feature: PERPS_CONSTANTS.FeatureName, + component: 'PerpsConnectionManager', + action: 'connection_reconnection', + ...(providerName && { provider: providerName }), + }, + context: { + name: 'PerpsConnectionProvider.reconnectWithNewContext', + data: {}, + }, }, ); } @@ -197,12 +219,20 @@ export const PerpsConnectionProvider: React.FC< try { await PerpsConnectionManager.connect(); } catch (err) { + const providerName = PerpsConnectionManager.getActiveProviderName(); Logger.error( ensureError(err, 'PerpsConnectionProvider.lifecycle.onConnect'), { - message: 'PerpsConnectionProvider: Error in lifecycle onConnect', - context: - 'PerpsConnectionProvider.usePerpsConnectionLifecycle.onConnect', + tags: { + feature: PERPS_CONSTANTS.FeatureName, + component: 'PerpsConnectionManager', + action: 'connection_connection', + ...(providerName && { provider: providerName }), + }, + context: { + name: 'PerpsConnectionProvider.lifecycle.onConnect', + data: {}, + }, }, ); } @@ -213,12 +243,20 @@ export const PerpsConnectionProvider: React.FC< try { await PerpsConnectionManager.disconnect(); } catch (err) { + const providerName = PerpsConnectionManager.getActiveProviderName(); Logger.error( ensureError(err, 'PerpsConnectionProvider.lifecycle.onDisconnect'), { - message: 'PerpsConnectionProvider: Error in lifecycle onDisconnect', - context: - 'PerpsConnectionProvider.usePerpsConnectionLifecycle.onDisconnect', + tags: { + feature: PERPS_CONSTANTS.FeatureName, + component: 'PerpsConnectionManager', + action: 'connection_disconnection', + ...(providerName && { provider: providerName }), + }, + context: { + name: 'PerpsConnectionProvider.lifecycle.onDisconnect', + data: {}, + }, }, ); } @@ -257,6 +295,25 @@ export const PerpsConnectionProvider: React.FC< ], ); + // Sentry breadcrumb: makes error screen appearance visible in issue timelines + // Placed in useEffect to avoid firing on every re-render (polling is 100ms) + // retryAttempts intentionally excluded — breadcrumb should fire once per error + // appearance, not on every retry (retries are tracked in handleRetry breadcrumb) + useEffect(() => { + if (connectionState.error) { + addBreadcrumb({ + category: 'perps.connection', + message: 'PerpsConnectionErrorView shown', + level: 'error', + data: { + errorCode: connectionState.error, + retryAttempts, + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connectionState.error]); + // Environment-level error handling - show error screen if connection failed // This ensures NO Perps screen can render when there's a connection error // When suppressErrorView is true, skip the error view and render children normally @@ -280,17 +337,17 @@ export const PerpsConnectionProvider: React.FC< // Reset retry attempts and let polling update the state setRetryAttempts(0); } catch (err) { - // Keep retry attempts count for showing back button after failed attempts - Logger.error( - ensureError(err, 'PerpsConnectionProvider.initializePerps'), - { - tags: { feature: PERPS_CONSTANTS.FeatureName }, - context: { - name: 'PerpsConnectionProvider.initializePerps', - data: { retryAttempts }, - }, + // Breadcrumb only — avoid flooding Sentry with a new event on every retry + addBreadcrumb({ + category: 'perps.connection', + message: 'Retry failed', + level: 'warning', + data: { + error: ensureError(err, 'PerpsConnectionProvider.handleRetry') + .message, + retryAttempts, }, - ); + }); } // Force update to get the latest error state diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx index a4b2f3e17e40..16e8aa08bf02 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx @@ -92,14 +92,11 @@ describe('PerpsStreamManager', () => { getMarkets: jest .fn() .mockResolvedValue([{ name: 'BTC-PERP' }, { name: 'ETH-PERP' }]), + getCachedMarketDataForActiveProvider: jest.fn().mockReturnValue(null), + getCachedUserDataForActiveProvider: jest.fn().mockReturnValue(null), state: { - cachedMarketData: null, - cachedMarketDataTimestamp: 0, - cachedPositions: null, - cachedOrders: null, - cachedAccountState: null, - cachedUserDataTimestamp: 0, - cachedUserDataAddress: null, + cachedMarketDataByProvider: {}, + cachedUserDataByProvider: {}, }, } as unknown as typeof mockEngine.context.PerpsController; @@ -1680,12 +1677,16 @@ describe('PerpsStreamManager', () => { it('uses controller preloaded cache when fresh', async () => { const callback = jest.fn(); - // Set up controller with fresh cached market data + // Set up controller with fresh cached market data via per-provider helper + ( + mockEngine.context.PerpsController as unknown as Record + ).getCachedMarketDataForActiveProvider = jest + .fn() + .mockReturnValue(mockMarketData); ( mockEngine.context.PerpsController as unknown as Record ).state = { - cachedMarketData: mockMarketData, - cachedMarketDataTimestamp: Date.now(), + cachedMarketDataByProvider: {}, activeProvider: 'hyperliquid', }; @@ -1710,11 +1711,13 @@ describe('PerpsStreamManager', () => { unsubscribe(); // Reset state for other tests + ( + mockEngine.context.PerpsController as unknown as Record + ).getCachedMarketDataForActiveProvider = jest.fn().mockReturnValue(null); ( mockEngine.context.PerpsController as unknown as Record ).state = { - cachedMarketData: null, - cachedMarketDataTimestamp: 0, + cachedMarketDataByProvider: {}, }; }); @@ -1725,12 +1728,16 @@ describe('PerpsStreamManager', () => { callTimings.push({ data, callIndex: callTimings.length }); }); - // Set up controller with fresh cached market data + // Set up controller with fresh cached market data via per-provider helper + ( + mockEngine.context.PerpsController as unknown as Record + ).getCachedMarketDataForActiveProvider = jest + .fn() + .mockReturnValue(mockMarketData); ( mockEngine.context.PerpsController as unknown as Record ).state = { - cachedMarketData: mockMarketData, - cachedMarketDataTimestamp: Date.now(), + cachedMarketDataByProvider: {}, activeProvider: 'hyperliquid', }; @@ -1755,23 +1762,27 @@ describe('PerpsStreamManager', () => { unsubscribe(); // Reset state for other tests + ( + mockEngine.context.PerpsController as unknown as Record + ).getCachedMarketDataForActiveProvider = jest.fn().mockReturnValue(null); ( mockEngine.context.PerpsController as unknown as Record ).state = { - cachedMarketData: null, - cachedMarketDataTimestamp: 0, + cachedMarketDataByProvider: {}, }; }); it('fetches from API when controller cache is stale', async () => { const callback = jest.fn(); - // Set up controller with stale cached market data (very old timestamp) + // getCachedMarketDataForActiveProvider returns null when cache is stale + ( + mockEngine.context.PerpsController as unknown as Record + ).getCachedMarketDataForActiveProvider = jest.fn().mockReturnValue(null); ( mockEngine.context.PerpsController as unknown as Record ).state = { - cachedMarketData: mockMarketData, - cachedMarketDataTimestamp: 0, // epoch = very stale + cachedMarketDataByProvider: {}, activeProvider: 'hyperliquid', }; @@ -1798,11 +1809,13 @@ describe('PerpsStreamManager', () => { unsubscribe(); // Reset state for other tests + ( + mockEngine.context.PerpsController as unknown as Record + ).getCachedMarketDataForActiveProvider = jest.fn().mockReturnValue(null); ( mockEngine.context.PerpsController as unknown as Record ).state = { - cachedMarketData: null, - cachedMarketDataTimestamp: 0, + cachedMarketDataByProvider: {}, }; }); }); @@ -1951,10 +1964,10 @@ describe('PerpsStreamManager', () => { // Assert - DevLogger should log the discard expect(mockDevLogger.log).toHaveBeenCalledWith( - 'PerpsStreamManager: Provider changed during fetch, discarding data', + 'PerpsStreamManager: Provider/network changed during fetch, discarding data', expect.objectContaining({ - fetchedFor: 'providerA', - currentProvider: 'providerB', + fetchedFor: 'providerA:mainnet', + current: 'providerB:mainnet', }), ); @@ -3266,14 +3279,14 @@ describe('PerpsStreamManager', () => { error: null, }); - // Set up controller with fresh cached orders + // Set up controller to return fresh cached orders via helper ( mockEngine.context.PerpsController as unknown as Record - ).state = { - ...mockEngine.context.PerpsController.state, - cachedOrders: mockOrders, - cachedUserDataTimestamp: Date.now(), - }; + ).getCachedUserDataForActiveProvider = jest.fn().mockReturnValue({ + positions: [], + orders: mockOrders, + accountState: null, + }); const streamManager = new PerpsStreamManager(); const callback = jest.fn(); @@ -3300,14 +3313,14 @@ describe('PerpsStreamManager', () => { error: null, }); - // Set up controller with fresh cached positions + // Set up controller to return fresh cached positions via helper ( mockEngine.context.PerpsController as unknown as Record - ).state = { - ...mockEngine.context.PerpsController.state, - cachedPositions: mockPositions, - cachedUserDataTimestamp: Date.now(), - }; + ).getCachedUserDataForActiveProvider = jest.fn().mockReturnValue({ + positions: mockPositions, + orders: [], + accountState: null, + }); const streamManager = new PerpsStreamManager(); const callback = jest.fn(); @@ -3333,14 +3346,14 @@ describe('PerpsStreamManager', () => { error: null, }); - // Set up controller with fresh cached account state + // Set up controller to return fresh cached account state via helper ( mockEngine.context.PerpsController as unknown as Record - ).state = { - ...mockEngine.context.PerpsController.state, - cachedAccountState: mockAccountState, - cachedUserDataTimestamp: Date.now(), - }; + ).getCachedUserDataForActiveProvider = jest.fn().mockReturnValue({ + positions: [], + orders: [], + accountState: mockAccountState, + }); const streamManager = new PerpsStreamManager(); const callback = jest.fn(); @@ -3356,14 +3369,10 @@ describe('PerpsStreamManager', () => { }); it('does not use stale cached orders', () => { - // Set up controller with stale cached orders (very old timestamp) + // Controller helper returns null when data is stale ( mockEngine.context.PerpsController as unknown as Record - ).state = { - ...mockEngine.context.PerpsController.state, - cachedOrders: mockOrders, - cachedUserDataTimestamp: 0, // epoch = very stale - }; + ).getCachedUserDataForActiveProvider = jest.fn().mockReturnValue(null); const streamManager = new PerpsStreamManager(); const callback = jest.fn(); @@ -3380,14 +3389,14 @@ describe('PerpsStreamManager', () => { }); it('uses empty cached orders as valid cache', () => { - // Set up controller with empty cached orders — [] means "fetched, user has none" + // Controller helper returns empty orders — [] means "fetched, user has none" ( mockEngine.context.PerpsController as unknown as Record - ).state = { - ...mockEngine.context.PerpsController.state, - cachedOrders: [], - cachedUserDataTimestamp: Date.now(), - }; + ).getCachedUserDataForActiveProvider = jest.fn().mockReturnValue({ + positions: [], + orders: [], + accountState: null, + }); const streamManager = new PerpsStreamManager(); const callback = jest.fn(); @@ -3402,14 +3411,14 @@ describe('PerpsStreamManager', () => { }); it('prefers channel cache over controller cache', () => { - // Set up controller with cached orders + // Set up controller to return cached orders via helper ( mockEngine.context.PerpsController as unknown as Record - ).state = { - ...mockEngine.context.PerpsController.state, - cachedOrders: mockOrders, - cachedUserDataTimestamp: Date.now(), - }; + ).getCachedUserDataForActiveProvider = jest.fn().mockReturnValue({ + positions: [], + orders: mockOrders, + accountState: null, + }); const streamManager = new PerpsStreamManager(); const callback1 = jest.fn(); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index 59c9818892b1..1db87d36515c 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -96,15 +96,13 @@ abstract class StreamChannel { // This ensures callbacks fire at most once per throttleMs interval // WITHOUT resetting the countdown on every update (which would be debouncing) // The conditional check prevents timer accumulation - no memory leaks - if (!subscriber.timer) { - subscriber.timer = setTimeout(() => { - if (subscriber.pendingUpdate) { - subscriber.callback(subscriber.pendingUpdate); - subscriber.pendingUpdate = undefined; - } - subscriber.timer = undefined; - }, subscriber.throttleMs); - } + subscriber.timer ??= setTimeout(() => { + if (subscriber.pendingUpdate) { + subscriber.callback(subscriber.pendingUpdate); + subscriber.pendingUpdate = undefined; + } + subscriber.timer = undefined; + }, subscriber.throttleMs); }); } @@ -224,7 +222,7 @@ abstract class StreamChannel { const noop = () => { /* sentinel timer */ }; - const sentinel = setTimeout(noop, 0) as ReturnType; + const sentinel = setTimeout(noop, 0); this.deferConnectTimer = sentinel; PerpsConnectionManager.waitForConnection() @@ -349,7 +347,7 @@ abstract class StreamChannel { // Specific channel for prices class PriceStreamChannel extends StreamChannel> { - private symbols = new Set(); + private readonly symbols = new Set(); private prewarmUnsubscribe?: () => void; private actualPriceUnsubscribe?: () => void; private allMarketSymbols: string[] = []; @@ -484,109 +482,99 @@ class PriceStreamChannel extends StreamChannel> { return this.prewarmUnsubscribe; } - try { - const controller = Engine.context.PerpsController; - - // Increment cycle ID to detect stale promises from previous prewarm cycles - // This prevents subscription leaks when user navigates: Perps → away → back quickly - this.prewarmCycleId++; - const currentCycleId = this.prewarmCycleId; - - // Start market fetch in background (non-blocking) - // We need the symbols to register subscribers, but we can return immediately - const marketsPromise = controller.getMarkets(); - - // Set up subscription once markets arrive (fire-and-forget) - marketsPromise - .then((markets) => { - // If this promise is from a stale cycle, don't set up subscription - // This prevents leaks when prewarm is called multiple times rapidly - if (currentCycleId !== this.prewarmCycleId) { - DevLogger.log('PriceStreamChannel: Skipping stale prewarm cycle', { - currentCycleId, - activeCycleId: this.prewarmCycleId, - }); - return; - } + const controller = Engine.context.PerpsController; + if (!controller) return () => undefined; + + // Increment cycle ID to detect stale promises from previous prewarm cycles + // This prevents subscription leaks when user navigates: Perps → away → back quickly + this.prewarmCycleId++; + const currentCycleId = this.prewarmCycleId; + + // Start market fetch in background (non-blocking) + // We need the symbols to register subscribers, but we can return immediately + controller + .getMarkets() + .then((markets) => { + // If this promise is from a stale cycle, don't set up subscription + // This prevents leaks when prewarm is called multiple times rapidly + if (currentCycleId !== this.prewarmCycleId) { + DevLogger.log('PriceStreamChannel: Skipping stale prewarm cycle', { + currentCycleId, + activeCycleId: this.prewarmCycleId, + }); + return; + } - // If already cleaned up, don't set up subscription - if (this.prewarmUnsubscribe === undefined) { - return; - } + // If already cleaned up, don't set up subscription + if (this.prewarmUnsubscribe === undefined) { + return; + } - this.allMarketSymbols = markets.map((market) => market.name); + this.allMarketSymbols = markets.map((market) => market.name); - DevLogger.log( - 'PriceStreamChannel: Pre-warming with all market symbols', - { - symbolCount: this.allMarketSymbols.length, - symbols: this.allMarketSymbols.slice(0, 10), - }, - ); + DevLogger.log( + 'PriceStreamChannel: Pre-warming with all market symbols', + { + symbolCount: this.allMarketSymbols.length, + symbols: this.allMarketSymbols.slice(0, 10), + }, + ); - // Subscribe to all market prices - const unsub = controller.subscribeToPrices({ - symbols: this.allMarketSymbols, - callback: (updates: PriceUpdate[]) => { - const priceMap: Record = {}; - updates.forEach((update) => { - const priceUpdate: PriceUpdate = { - symbol: update.symbol, - price: update.price, - timestamp: Date.now(), - percentChange24h: update.percentChange24h, - bestBid: update.bestBid, - bestAsk: update.bestAsk, - spread: update.spread, - markPrice: update.markPrice, - funding: update.funding, - openInterest: update.openInterest, - volume24h: update.volume24h, - }; - this.priceCache.set(update.symbol, priceUpdate); - priceMap[update.symbol] = priceUpdate; - }); - - if (this.subscribers.size > 0) { - this.notifySubscribers(priceMap); - } - }, - }); + // Subscribe to all market prices + const unsub = controller.subscribeToPrices({ + symbols: this.allMarketSymbols, + callback: (updates: PriceUpdate[]) => { + const priceMap: Record = {}; + updates.forEach((update) => { + const priceUpdate: PriceUpdate = { + symbol: update.symbol, + price: update.price, + timestamp: Date.now(), + percentChange24h: update.percentChange24h, + bestBid: update.bestBid, + bestAsk: update.bestAsk, + spread: update.spread, + markPrice: update.markPrice, + funding: update.funding, + openInterest: update.openInterest, + volume24h: update.volume24h, + }; + this.priceCache.set(update.symbol, priceUpdate); + priceMap[update.symbol] = priceUpdate; + }); - // Store the actual unsubscribe function - this.actualPriceUnsubscribe = unsub; - }) - .catch((error) => { - Logger.error( - ensureError(error, 'PriceStreamChannel.prewarm.backgroundFetch'), - { - context: 'PriceStreamChannel.prewarm.backgroundFetch', - }, - ); - // Reset state so subsequent prewarm/connect calls can recover - this.prewarmUnsubscribe = undefined; - this.allMarketSymbols = []; - // Reconnect waiting subscribers that were skipped because prewarm was pending - if (this.subscribers.size > 0) { - this.connect(); - } + if (this.subscribers.size > 0) { + this.notifySubscribers(priceMap); + } + }, }); - // Return cleanup function immediately (before markets load) - this.prewarmUnsubscribe = () => { - DevLogger.log('PriceStreamChannel: Cleaning up prewarm subscription'); - this.cleanupPrewarm(); - }; - - return this.prewarmUnsubscribe; - } catch (error) { - Logger.error(ensureError(error, 'PriceStreamChannel.prewarm'), { - context: 'PriceStreamChannel.prewarm', + // Store the actual unsubscribe function + this.actualPriceUnsubscribe = unsub; + }) + .catch((error) => { + Logger.error( + ensureError(error, 'PriceStreamChannel.prewarm.backgroundFetch'), + { + context: 'PriceStreamChannel.prewarm.backgroundFetch', + }, + ); + // Reset state so subsequent prewarm/connect calls can recover + this.prewarmUnsubscribe = undefined; + this.allMarketSymbols = []; + // Reconnect waiting subscribers that were skipped because prewarm was pending + if (this.subscribers.size > 0) { + this.connect(); + } }); - return () => { - // No-op - }; - } + + // Return cleanup function immediately (before markets load) + this.prewarmUnsubscribe = () => { + DevLogger.log('PriceStreamChannel: Cleaning up prewarm subscription'); + this.cleanupPrewarm(); + }; + + return this.prewarmUnsubscribe; } /** @@ -1163,7 +1151,7 @@ class OICapStreamChannel extends StreamChannel { protected getCachedData(): string[] | null { // Return null if no cache exists to distinguish from empty array const cached = this.cache.get('oiCaps'); - return cached !== undefined ? cached : null; + return cached ?? null; } protected getClearedData(): string[] { @@ -1324,18 +1312,22 @@ class MarketDataChannel extends StreamChannel { return; } - // Get current provider ID + // Get current provider ID + network as a composite key. + // Network changes (testnet toggle) must also invalidate the market cache. const controller = Engine.context.PerpsController; const currentProviderId = controller.state?.activeProvider || PROVIDER_CONFIG.DefaultProvider; + // Note: uses state.isTestnet directly. If PROVIDER_CONFIG.MYX_TESTNET_ONLY is + // ever re-enabled, this key would diverge from PerpsController.#providerIsTestnet(). + const currentNetworkKey = `${currentProviderId}:${controller.state?.isTestnet ? 'testnet' : 'mainnet'}`; - // Invalidate cache if provider changed - if (this.cachedProviderId && this.cachedProviderId !== currentProviderId) { + // Invalidate cache if provider OR network changed + if (this.cachedProviderId && this.cachedProviderId !== currentNetworkKey) { DevLogger.log( - 'PerpsStreamManager: Provider changed, invalidating cache', + 'PerpsStreamManager: Provider/network changed, invalidating cache', { from: this.cachedProviderId, - to: currentProviderId, + to: currentNetworkKey, }, ); this.cache.delete('markets'); @@ -1388,31 +1380,25 @@ class MarketDataChannel extends StreamChannel { // One-time read of controller-level preloaded cache (REST snapshot). // This avoids an HTTP round-trip when the controller already has fresh data. - // Only use cache if it belongs to the currently active provider. - const controllerProviderId = - controller.state?.activeProvider || PROVIDER_CONFIG.DefaultProvider; - const cached = controller.state?.cachedMarketData; - const cacheAge = - Date.now() - (controller.state?.cachedMarketDataTimestamp ?? 0); + const controllerNetworkKey = `${controller.state?.activeProvider || PROVIDER_CONFIG.DefaultProvider}:${controller.state?.isTestnet ? 'testnet' : 'mainnet'}`; + const cachedForProvider = + controller.getCachedMarketDataForActiveProvider?.(); if ( - cached && - cached.length > 0 && - cacheAge < this.CACHE_DURATION && + cachedForProvider && + cachedForProvider.length > 0 && (!this.cachedProviderId || - this.cachedProviderId === controllerProviderId) + this.cachedProviderId === controllerNetworkKey) ) { DevLogger.log( 'PerpsStreamManager: Using controller preloaded market data', { - marketCount: cached.length, - cacheAgeMs: cacheAge, + marketCount: cachedForProvider.length, }, ); - this.cache.set('markets', cached); + this.cache.set('markets', cachedForProvider); this.lastFetchTime = Date.now(); - this.cachedProviderId = - controller.state?.activeProvider || PROVIDER_CONFIG.DefaultProvider; - this.notifySubscribers(cached); + this.cachedProviderId = controllerNetworkKey; + this.notifySubscribers(cachedForProvider); return; } @@ -1420,33 +1406,31 @@ class MarketDataChannel extends StreamChannel { 'PerpsStreamManager: Fetching fresh market data from API', ); - // Snapshot provider ID BEFORE the async call to avoid race conditions. - // If the user switches providers while getMarketDataWithPrices() is - // in-flight, we must not tag the returned data with the new provider's ID. - const preFetchProviderId = - controller.state?.activeProvider || PROVIDER_CONFIG.DefaultProvider; + // Snapshot provider + network BEFORE the async call to avoid race conditions. + // If the user switches providers or toggles testnet while getMarketDataWithPrices() + // is in-flight, we must not tag the returned data with the new network key. + const preFetchNetworkKey = `${controller.state?.activeProvider || PROVIDER_CONFIG.DefaultProvider}:${controller.state?.isTestnet ? 'testnet' : 'mainnet'}`; const data = await controller.getMarketDataWithPrices(); const fetchTime = Date.now() - fetchStartTime; - // If provider changed during fetch, discard stale data - const currentProviderId = - controller.state?.activeProvider || PROVIDER_CONFIG.DefaultProvider; - if (preFetchProviderId !== currentProviderId) { + // If provider or network changed during fetch, discard stale data + const postFetchNetworkKey = `${controller.state?.activeProvider || PROVIDER_CONFIG.DefaultProvider}:${controller.state?.isTestnet ? 'testnet' : 'mainnet'}`; + if (preFetchNetworkKey !== postFetchNetworkKey) { DevLogger.log( - 'PerpsStreamManager: Provider changed during fetch, discarding data', + 'PerpsStreamManager: Provider/network changed during fetch, discarding data', { - fetchedFor: preFetchProviderId, - currentProvider: currentProviderId, + fetchedFor: preFetchNetworkKey, + current: postFetchNetworkKey, }, ); return; } - // Update cache and track which provider this data came from + // Update cache and track which provider+network this data came from this.cache.set('markets', data); this.lastFetchTime = Date.now(); - this.cachedProviderId = preFetchProviderId; + this.cachedProviderId = preFetchNetworkKey; // Notify all subscribers this.notifySubscribers(data); @@ -1511,22 +1495,9 @@ class MarketDataChannel extends StreamChannel { const cached = this.cache.get('markets'); if (cached !== undefined) return cached; - // Fallback: read controller preloaded cache (from REST preload) + // Fallback: read per-provider cache via helper const controller = Engine.context.PerpsController; - const currentProviderId = - controller.state?.activeProvider || PROVIDER_CONFIG.DefaultProvider; - // Reject preloaded cache if it belongs to a different provider - if (this.cachedProviderId && this.cachedProviderId !== currentProviderId) { - return null; - } - const preloaded = controller.state?.cachedMarketData; - const cacheAge = - Date.now() - (controller.state?.cachedMarketDataTimestamp ?? 0); - if (preloaded && preloaded.length > 0 && cacheAge < this.CACHE_DURATION) { - return preloaded; - } - - return null; + return controller.getCachedMarketDataForActiveProvider?.() ?? null; } protected getClearedData(): PerpsMarketData[] { diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index 1a3eb4e6eb54..1cfc3c83370c 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -269,7 +269,6 @@ const PerpsScreenStack = () => { title: strings('perps.home.markets'), showBalanceActions: false, showBottomNav: false, - defaultSearchVisible: false, }} /> diff --git a/app/components/UI/Perps/selectors/featureFlags/index.test.ts b/app/components/UI/Perps/selectors/featureFlags/index.test.ts index 6163f50d87ce..179b31f357cf 100644 --- a/app/components/UI/Perps/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Perps/selectors/featureFlags/index.test.ts @@ -1667,7 +1667,7 @@ describe('Perps Feature Flag Selectors', () => { expect(result).toBe(true); }); - it('uses remote flag when valid but disabled', () => { + it('local flag overrides remote flag when local is true', () => { mockHasMinimumRequiredVersion.mockReturnValue(true); process.env.MM_PERPS_MYX_PROVIDER_ENABLED = 'true'; @@ -1690,10 +1690,10 @@ describe('Perps Feature Flag Selectors', () => { const result = selectPerpsMYXProviderEnabledFlag( stateWithDisabledRemoteFlag, ); - expect(result).toBe(false); + expect(result).toBe(true); }); - it('uses remote flag (false) when enabled but version check fails', () => { + it('local flag overrides remote flag even when version check fails', () => { mockHasMinimumRequiredVersion.mockReturnValue(false); process.env.MM_PERPS_MYX_PROVIDER_ENABLED = 'true'; @@ -1716,6 +1716,32 @@ describe('Perps Feature Flag Selectors', () => { const result = selectPerpsMYXProviderEnabledFlag( stateWithVersionCheckFailure, ); + expect(result).toBe(true); + }); + + it('uses remote flag when local is not set', () => { + mockHasMinimumRequiredVersion.mockReturnValue(true); + delete process.env.MM_PERPS_MYX_PROVIDER_ENABLED; + + const stateWithDisabledRemoteFlag = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + perpsMyxProviderEnabled: { + enabled: false, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPerpsMYXProviderEnabledFlag( + stateWithDisabledRemoteFlag, + ); expect(result).toBe(false); }); diff --git a/app/components/UI/Perps/selectors/featureFlags/index.ts b/app/components/UI/Perps/selectors/featureFlags/index.ts index 3811c217cf95..c7b7648478a2 100644 --- a/app/components/UI/Perps/selectors/featureFlags/index.ts +++ b/app/components/UI/Perps/selectors/featureFlags/index.ts @@ -26,6 +26,7 @@ export const selectPerpsEnabledFlag = createSelector( const remoteFlag = remoteFeatureFlags?.perpsPerpTradingEnabled as unknown as VersionGatedFeatureFlag; + // Fallback to local flag if remote flag is not available return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; }, ); @@ -38,6 +39,7 @@ export const selectPerpsServiceInterruptionBannerEnabledFlag = createSelector( const remoteFlag = remoteFeatureFlags?.perpsPerpTradingServiceInterruptionBannerEnabled as unknown as VersionGatedFeatureFlag; + // Fallback to local flag if remote flag is not available return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; }, ); @@ -49,6 +51,7 @@ export const selectPerpsGtmOnboardingModalEnabledFlag = createSelector( const remoteFlag = remoteFeatureFlags?.perpsPerpGtmOnboardingModalEnabled as unknown as VersionGatedFeatureFlag; + // Fallback to local flag if remote flag is not available return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; }, ); @@ -62,6 +65,7 @@ export const selectPerpsGtmOnboardingModalEnabledFlag = createSelector( export const selectPerpsOrderBookEnabledFlag = createSelector( selectRemoteFeatureFlags, (remoteFeatureFlags) => { + // Default to false if no flag is set (disabled by default) const localFlag = process.env.MM_PERPS_ORDER_BOOK_ENABLED === 'true'; const remoteFlag = remoteFeatureFlags?.perpsOrderBookEnabled as unknown as VersionGatedFeatureFlag; @@ -243,15 +247,25 @@ export const selectPerpsRewardsReferralCodeEnabledFlag = createSelector( * Pure utility so that both the Redux selector and the controller * (which reads RemoteFeatureFlagController state directly) share * the same logic. + * + * Local env var takes priority — if set to "true", MYX is always enabled + * regardless of remote flag. Remote flag only used as fallback when + * local is not explicitly enabled. */ export function resolvePerpsMyxProviderEnabled( remoteFeatureFlags: Record | undefined, ): boolean { const localFlag = process.env.MM_PERPS_MYX_PROVIDER_ENABLED === 'true'; + + // Local override always wins + if (localFlag) { + return true; + } + const remoteFlag = remoteFeatureFlags?.perpsMyxProviderEnabled as VersionGatedFeatureFlag; - return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; } /** diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts index 66c5b080726f..12d356325d8d 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts @@ -27,6 +27,7 @@ jest.mock('../../../../core/Engine', () => ({ getActiveProvider: jest.fn(() => ({ ping: jest.fn().mockResolvedValue(undefined), })), + isCurrentlyReinitializing: jest.fn(() => false), }, }, })); @@ -688,6 +689,64 @@ describe('PerpsConnectionManager', () => { ); }); + it('debounces rapid state changes into a single reconnection', async () => { + // Arrange + jest.useFakeTimers(); + mockPerpsController.init.mockResolvedValue(); + mockPerpsController.getAccountState.mockResolvedValue({}); + await PerpsConnectionManager.connect(); + + const storeCallback = storeCallbacks[storeCallbacks.length - 1]; + + // Simulate two rapid state changes within 50ms + (selectPerpsNetwork as unknown as jest.Mock).mockReturnValue('testnet'); + storeCallback(); + ( + selectSelectedInternalAccountByScope as unknown as jest.Mock + ).mockReturnValue(() => ({ address: '0xnew' })); + storeCallback(); + + // Advance past the 50ms debounce window + jest.advanceTimersByTime(60); + await Promise.resolve(); + + // Assert: reconnect was called once, not twice (debounced) + const initCallCount = mockPerpsController.init.mock.calls.length; + // init was called once for connect(); any debounced reconnect fires one more time + expect(initCallCount).toBeGreaterThanOrEqual(1); + + jest.useRealTimers(); + }); + + it('clears pending debounce timer when cleanupStateMonitoring is called', async () => { + // Arrange: arm the debounce timer via a state change + jest.useFakeTimers(); + mockPerpsController.init.mockResolvedValue(); + mockPerpsController.getAccountState.mockResolvedValue({}); + await PerpsConnectionManager.connect(); + + const storeCallback = storeCallbacks[storeCallbacks.length - 1]; + (selectPerpsNetwork as unknown as jest.Mock).mockReturnValue('testnet'); + storeCallback(); + + const m = PerpsConnectionManager as unknown as { + stateChangeDebounceTimer: ReturnType | null; + cleanupStateMonitoring: () => void; + }; + expect(m.stateChangeDebounceTimer).not.toBeNull(); + + // Act: invoke teardown directly to cover the timer-clearing branch + m.cleanupStateMonitoring(); + + // Assert: timer is cleared and no reconnect fires + expect(m.stateChangeDebounceTimer).toBeNull(); + jest.advanceTimersByTime(100); + // init was only called once (for connect), not again from the cancelled debounce + expect(mockPerpsController.init).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); + }); + it('continues monitoring state changes during grace period', async () => { // Setup but don't connect mockPerpsController.init.mockResolvedValue(); @@ -761,6 +820,33 @@ describe('PerpsConnectionManager', () => { expect(mockPerpsController.init).toHaveBeenCalled(); }); + it('waits for concurrent controller reinit before health-check ping', async () => { + // Arrange: controller reports reinitializing on first call, ready on second + mockPerpsController.init.mockResolvedValue(); + const isReinitializing = ( + Engine.context.PerpsController as unknown as { + isCurrentlyReinitializing: jest.Mock; + } + ).isCurrentlyReinitializing; + isReinitializing + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValue(false); + + // Act + await ( + PerpsConnectionManager as unknown as { + reconnectWithNewContext: () => Promise; + } + ).reconnectWithNewContext(); + + // Assert: polled at least twice before calling getActiveProvider + expect(isReinitializing.mock.calls.length).toBeGreaterThanOrEqual(2); + expect( + Engine.context.PerpsController.getActiveProvider, + ).toHaveBeenCalled(); + }); + it('logs error and resets connecting flag when reconnection fails', async () => { const error = new Error('Reconnection failed'); mockPerpsController.init.mockRejectedValueOnce(error); @@ -1146,4 +1232,45 @@ describe('PerpsConnectionManager', () => { expect(m.wasOffline).toBe(true); }); }); + + describe('getActiveProviderName', () => { + it('returns activeProvider from PerpsController state', () => { + // Arrange + ( + Engine.context.PerpsController as unknown as Record + ).state = { + activeProvider: 'hyperliquid', + }; + + // Act + const result = PerpsConnectionManager.getActiveProviderName(); + + // Assert + expect(result).toBe('hyperliquid'); + }); + + it('returns undefined when Engine access throws', () => { + // Arrange — remove PerpsController so property access throws + const original = Engine.context.PerpsController; + Object.defineProperty(Engine.context, 'PerpsController', { + get: () => { + throw new Error('Engine not initialized'); + }, + configurable: true, + }); + + // Act + const result = PerpsConnectionManager.getActiveProviderName(); + + // Assert + expect(result).toBeUndefined(); + + // Cleanup + Object.defineProperty(Engine.context, 'PerpsController', { + value: original, + configurable: true, + writable: true, + }); + }); + }); }); diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts index cf01bbce0267..ec39079be55d 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts @@ -2,7 +2,7 @@ import { addEventListener as netInfoAddEventListener, type NetInfoState, } from '@react-native-community/netinfo'; -import { captureException, setMeasurement } from '@sentry/react-native'; +import { setMeasurement } from '@sentry/react-native'; import BackgroundTimer from 'react-native-background-timer'; import performance from 'react-native-performance'; import { v4 as uuidv4 } from 'uuid'; @@ -62,6 +62,7 @@ class PerpsConnectionManagerClass { private isInGracePeriod = false; private pendingReconnectPromise: Promise | null = null; private connectionTimeoutRef: ReturnType | null = null; + private stateChangeDebounceTimer: ReturnType | null = null; private netInfoUnsubscribe: (() => void) | null = null; private wasOffline = false; private networkRestoreRetryTimer: ReturnType | null = null; @@ -161,23 +162,29 @@ class PerpsConnectionManagerClass { streamManager.topOfBook.clearCache(); streamManager.candles.clearCache(); - // Force the controller to reconnect with new account - // This ensures proper WebSocket reconnection at the controller level - this.reconnectWithNewContext().catch((error) => { - Logger.error( - ensureError(error, 'PerpsConnectionManager.setupStateMonitoring'), - { - tags: { feature: PERPS_CONSTANTS.FeatureName }, - context: { - name: 'PerpsConnectionManager.setupStateMonitoring', - data: { - message: - 'Error reconnecting with new account/network context', + // Debounce: coalesce rapid state changes (e.g. provider switch + network + // toggle in the same tick) into a single reconnection attempt. + if (this.stateChangeDebounceTimer !== null) { + clearTimeout(this.stateChangeDebounceTimer); + } + this.stateChangeDebounceTimer = setTimeout(() => { + this.stateChangeDebounceTimer = null; + this.reconnectWithNewContext().catch((error) => { + Logger.error( + ensureError(error, 'PerpsConnectionManager.setupStateMonitoring'), + { + tags: { feature: PERPS_CONSTANTS.FeatureName }, + context: { + name: 'PerpsConnectionManager.setupStateMonitoring', + data: { + message: + 'Error reconnecting with new account/network context', + }, }, }, - }, - ); - }); + ); + }); + }, 50); } // Update tracked values @@ -321,6 +328,10 @@ class PerpsConnectionManagerClass { this.wasOffline = false; } this.cancelNetworkRestoreRetry(); + if (this.stateChangeDebounceTimer !== null) { + clearTimeout(this.stateChangeDebounceTimer); + this.stateChangeDebounceTimer = null; + } if (this.unsubscribeFromStore) { this.unsubscribeFromStore(); this.unsubscribeFromStore = null; @@ -747,25 +758,6 @@ class PerpsConnectionManagerClass { // Clear connection timeout on error this.clearConnectionTimeout(); - // Capture exception with connection context - captureException(ensureError(error, 'PerpsConnectionManager.connect'), { - tags: { - component: 'PerpsConnectionManager', - action: 'connection_connection', - operation: 'connection_management', - provider: 'hyperliquid', - }, - extra: { - connectionContext: { - provider: 'hyperliquid', - timestamp: new Date().toISOString(), - isTestnet: - Engine.context.PerpsController?.getCurrentNetwork?.() === - 'testnet', - }, - }, - }); - traceData = { success: false, error: ensureError(error, 'PerpsConnectionManager.connect').message, @@ -894,6 +886,8 @@ class PerpsConnectionManagerClass { this.isInitialized = false; this.hasPreloaded = false; this.isPreloading = false; + // Flag intentional teardown so WS state events can be suppressed in the UI + this.isDisconnecting = true; // Clear previous errors when starting reconnection attempt this.clearError(); @@ -923,6 +917,27 @@ class PerpsConnectionManagerClass { : PERPS_CONSTANTS.ReconnectionDelayIosMs; await wait(reconnectionDelay); + // Wait for any concurrent controller reinit (e.g. toggleTestnet) to finish + // before calling getActiveProvider(), which throws CLIENT_REINITIALIZING while + // isReinitializing is true. + { + const maxWaitMs = 2000; + const pollIntervalMs = 50; + let waited = 0; + while ( + Engine.context.PerpsController.isCurrentlyReinitializing() && + waited < maxWaitMs + ) { + await wait(pollIntervalMs); + waited += pollIntervalMs; + } + if (waited > 0) { + DevLogger.log( + `PerpsConnectionManager: Waited ${waited}ms for concurrent reinit to complete`, + ); + } + } + // Validate connection with WebSocket health check ping before marking as connected // This ensures the WebSocket connection is actually responsive after reconnection without expensive API calls DevLogger.log( @@ -955,6 +970,7 @@ class PerpsConnectionManagerClass { // No need to explicitly call getAccountState() - preloadSubscriptions() handles account data this.isConnected = true; this.isInitialized = true; + this.isDisconnecting = false; // Clear errors on successful reconnection this.clearError(); @@ -1019,8 +1035,9 @@ class PerpsConnectionManagerClass { id: traceId, data: traceData, }); - // Always clear connecting state when done + // Always clear connecting/disconnecting state when done this.isConnecting = false; + this.isDisconnecting = false; } } @@ -1207,6 +1224,18 @@ class PerpsConnectionManagerClass { ); } + /** + * Returns the active provider name from the PerpsController state. + * Used for consistent error/breadcrumb tagging without coupling callers to Engine. + */ + getActiveProviderName(): string | undefined { + try { + return Engine.context.PerpsController.state.activeProvider; + } catch { + return undefined; + } + } + /** * Check if the manager is currently connecting */ diff --git a/app/components/UI/Perps/types/navigation.ts b/app/components/UI/Perps/types/navigation.ts index 1862788381d9..8eab990c0e09 100644 --- a/app/components/UI/Perps/types/navigation.ts +++ b/app/components/UI/Perps/types/navigation.ts @@ -77,7 +77,6 @@ export interface PerpsNavigationParamList extends ParamListBase { title?: string; showBalanceActions?: boolean; showBottomNav?: boolean; - defaultSearchVisible?: boolean; showWatchlistOnly?: boolean; defaultMarketTypeFilter?: | 'all' diff --git a/app/components/UI/Perps/utils/accountUtils.test.ts b/app/components/UI/Perps/utils/accountUtils.test.ts index 060db48acc8d..f3bf3b73c6ea 100644 --- a/app/components/UI/Perps/utils/accountUtils.test.ts +++ b/app/components/UI/Perps/utils/accountUtils.test.ts @@ -8,7 +8,6 @@ import { getEvmAccountFromAccountGroup, getSelectedEvmAccount, calculateWeightedReturnOnEquity, - PerpsControllerMessenger, } from '@metamask/perps-controller'; describe('accountUtils', () => { @@ -242,7 +241,7 @@ describe('accountUtils', () => { }); describe('getSelectedEvmAccount', () => { - it('returns EVM account when messenger returns accounts with EVM', () => { + it('returns EVM account when accounts array contains EVM account', () => { const mockAccounts = [ { address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', @@ -259,27 +258,14 @@ describe('accountUtils', () => { }, ] as unknown as InternalAccount[]; - const mockMessenger = { - call: jest.fn().mockReturnValue(mockAccounts) as jest.MockedFunction< - ( - action: 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ) => InternalAccount[] - >, - }; - - const result = getSelectedEvmAccount( - mockMessenger as unknown as PerpsControllerMessenger, - ); - - expect(mockMessenger.call).toHaveBeenCalledWith( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ); + const result = getSelectedEvmAccount(mockAccounts); + expect(result).toEqual({ address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', }); }); - it('returns undefined when no EVM account in selected group', () => { + it('returns undefined when no EVM account in accounts array', () => { const mockAccounts = [ { address: '0x1234567890123456789012345678901234567890', @@ -296,33 +282,13 @@ describe('accountUtils', () => { }, ] as unknown as InternalAccount[]; - const mockMessenger = { - call: jest.fn().mockReturnValue(mockAccounts) as jest.MockedFunction< - ( - action: 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ) => InternalAccount[] - >, - }; - - const result = getSelectedEvmAccount( - mockMessenger as unknown as PerpsControllerMessenger, - ); + const result = getSelectedEvmAccount(mockAccounts); expect(result).toBeUndefined(); }); - it('returns undefined when messenger returns empty accounts', () => { - const mockMessenger = { - call: jest.fn().mockReturnValue([]) as jest.MockedFunction< - ( - action: 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ) => InternalAccount[] - >, - }; - - const result = getSelectedEvmAccount( - mockMessenger as unknown as PerpsControllerMessenger, - ); + it('returns undefined when accounts array is empty', () => { + const result = getSelectedEvmAccount([]); expect(result).toBeUndefined(); }); diff --git a/app/components/UI/Perps/utils/rewardsUtils.test.ts b/app/components/UI/Perps/utils/rewardsUtils.test.ts index 0697848fca3e..d490a5b46b60 100644 --- a/app/components/UI/Perps/utils/rewardsUtils.test.ts +++ b/app/components/UI/Perps/utils/rewardsUtils.test.ts @@ -8,11 +8,9 @@ import { handleRewardsError, } from '@metamask/perps-controller'; import { toCaipAccountId, parseCaipChainId } from '@metamask/utils'; -import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { toChecksumHexAddress } from '@metamask/controller-utils'; jest.mock('@metamask/utils'); -jest.mock('@metamask/bridge-controller'); jest.mock('@metamask/controller-utils'); const mockToCaipAccountId = toCaipAccountId as jest.MockedFunction< @@ -21,9 +19,6 @@ const mockToCaipAccountId = toCaipAccountId as jest.MockedFunction< const mockParseCaipChainId = parseCaipChainId as jest.MockedFunction< typeof parseCaipChainId >; -const mockFormatChainIdToCaip = formatChainIdToCaip as jest.MockedFunction< - typeof formatChainIdToCaip ->; const mockToChecksumHexAddress = toChecksumHexAddress as jest.MockedFunction< typeof toChecksumHexAddress >; @@ -41,7 +36,6 @@ describe('rewardsUtils', () => { 'eip155:42161:0x1234567890123456789012345678901234567890'; beforeEach(() => { - mockFormatChainIdToCaip.mockReturnValue(mockCaipChainId); mockParseCaipChainId.mockReturnValue({ namespace: 'eip155', reference: '42161', @@ -57,7 +51,6 @@ describe('rewardsUtils', () => { // Assert expect(result).toBe(mockCaipAccountId); - expect(mockFormatChainIdToCaip).toHaveBeenCalledWith(mockChainId); expect(mockParseCaipChainId).toHaveBeenCalledWith(mockCaipChainId); expect(mockToCaipAccountId).toHaveBeenCalledWith( 'eip155', @@ -66,13 +59,7 @@ describe('rewardsUtils', () => { ); }); - it('returns null when formatChainIdToCaip throws', () => { - // Arrange - const error = new Error('Invalid chain ID format'); - mockFormatChainIdToCaip.mockImplementation(() => { - throw error; - }); - + it('returns null when chain ID is invalid (NaN)', () => { // Act const result = formatAccountToCaipAccountId(mockAddress, 'invalid'); @@ -127,10 +114,8 @@ describe('rewardsUtils', () => { const checksummedAddress = '0x316BDE155acd07609872a56Bc32CcfB0B13201fA'; const mixedCaseAddress = '0x316BdE155AcD07609872a56bC32CcFb0b13201Fa'; const chainId = '1'; - const caipChainId = 'eip155:1'; beforeEach(() => { - mockFormatChainIdToCaip.mockReturnValue(caipChainId); mockParseCaipChainId.mockReturnValue({ namespace: 'eip155', reference: '1', @@ -238,9 +223,8 @@ describe('rewardsUtils', () => { const chainId = '1'; beforeEach(() => { - mockFormatChainIdToCaip.mockReturnValue( - 'bip122:000000000019d6689c085ae165831e93', - ); + // parseCaipChainId receives 'eip155:1' from inlined formatChainIdToCaip, + // but we mock it to return a non-eip155 namespace to test the branch mockParseCaipChainId.mockReturnValue({ namespace: 'bip122', reference: '000000000019d6689c085ae165831e93', diff --git a/app/components/UI/Perps/utils/transactionTransforms.ts b/app/components/UI/Perps/utils/transactionTransforms.ts index 86c0d98ee51f..0a5d1f269572 100644 --- a/app/components/UI/Perps/utils/transactionTransforms.ts +++ b/app/components/UI/Perps/utils/transactionTransforms.ts @@ -538,7 +538,9 @@ export function transformFundingToTransactions( isPositive, fee: amountUSDC, feeNumber: parseFloat(amountUsd), - rate: `${BigNumber(rate).multipliedBy(100).toString()}%`, + rate: `${BigNumber(rate ?? '0') + .multipliedBy(100) + .toString()}%`, }, }; }); diff --git a/app/components/UI/PhishingModal/__snapshots__/index.test.tsx.snap b/app/components/UI/PhishingModal/__snapshots__/index.test.tsx.snap index 42d4bccd7e5c..8edc23826c05 100644 --- a/app/components/UI/PhishingModal/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/PhishingModal/__snapshots__/index.test.tsx.snap @@ -4,7 +4,7 @@ exports[`PhishingModal should render correctly 1`] = ` ({ if (key === 'predict.fee_summary.close') { return 'Close'; } + if (key === 'predict.fee_summary.fak_partial_fill_note') { + return 'Prices shown assume your order is fully filled. Actual amounts may vary if the order is only partially filled.'; + } return key; }), })); @@ -325,4 +328,55 @@ describe('PredictFeeBreakdownSheet', () => { render(); }); }); + + describe('FAK partial fill note', () => { + it('displays partial fill note when fakOrdersEnabled is true', () => { + const TestComponent = () => { + const ref = useRef(null); + return ( + + ); + }; + + const { getByTestId } = render(); + + expect(getByTestId('predict-fak-partial-fill-note')).toBeOnTheScreen(); + }); + + it('does not display partial fill note when fakOrdersEnabled is false', () => { + const TestComponent = () => { + const ref = useRef(null); + return ( + + ); + }; + + const { queryByTestId } = render(); + + expect( + queryByTestId('predict-fak-partial-fill-note'), + ).not.toBeOnTheScreen(); + }); + + it('does not display partial fill note by default', () => { + const TestComponent = () => { + const ref = useRef(null); + return ; + }; + + const { queryByTestId } = render(); + + expect( + queryByTestId('predict-fak-partial-fill-note'), + ).not.toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.tsx b/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.tsx index 6e18323635cb..f2b57f844c19 100644 --- a/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.tsx +++ b/app/components/UI/Predict/components/PredictFeeBreakdownSheet/PredictFeeBreakdownSheet.tsx @@ -1,17 +1,44 @@ import React, { forwardRef } from 'react'; -import { Box } from '@metamask/design-system-react-native'; +import { + Box, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; import SheetHeader from '../../../../../component-library/components/Sheet/SheetHeader'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; import { strings } from '../../../../../../locales/i18n'; import { formatPrice } from '../../utils/format'; import { SLIPPAGE_BUY } from '../../providers/polymarket/constants'; +interface FeeRowProps { + title: string; + description: string; + amount: string; +} + +const FeeRow = ({ title, description, amount }: FeeRowProps) => ( + <> + + + + {title} + + + {description} + + + + {amount} + + + + +); + interface PredictFeeBreakdownSheetProps { providerFee: number; metamaskFee: number; @@ -20,6 +47,7 @@ interface PredictFeeBreakdownSheetProps { betAmount: number; total: number; onClose?: () => void; + fakOrdersEnabled?: boolean; } const PredictFeeBreakdownSheet = forwardRef< @@ -35,72 +63,65 @@ const PredictFeeBreakdownSheet = forwardRef< betAmount, total, onClose, + fakOrdersEnabled = false, }, ref, ) => ( - - - - {strings('predict.fee_summary.prediction_order')} - - - {strings('predict.fee_summary.prediction_order_description', { - count: contractCount.toFixed(2), - price: formatPrice(sharePrice, { maximumDecimals: 2 }), - slippage: Math.round(SLIPPAGE_BUY * 100), - })} - - - - {formatPrice(betAmount, { maximumDecimals: 2 })} - - - - - - - - - {strings('predict.fee_summary.metamask_fee')} - - - {strings('predict.fee_summary.metamask_fee_description')} - - - - {formatPrice(metamaskFee, { maximumDecimals: 2 })} - - + - + - - - - {strings('predict.fee_summary.exchange_fee')} - - - {strings('predict.fee_summary.exchange_fee_description')} - - - - {formatPrice(providerFee, { maximumDecimals: 2 })} - - - - + - + {strings('predict.fee_summary.total')} - + {formatPrice(total, { maximumDecimals: 2 })} + + {fakOrdersEnabled && ( + + {strings('predict.fee_summary.fak_partial_fill_note')} + + )} ), diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/__snapshots__/PredictGameDetailsContent.test.tsx.snap b/app/components/UI/Predict/components/PredictGameDetailsContent/__snapshots__/PredictGameDetailsContent.test.tsx.snap index 65a9bf8c1a7f..800a611b94c1 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsContent/__snapshots__/PredictGameDetailsContent.test.tsx.snap +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/__snapshots__/PredictGameDetailsContent.test.tsx.snap @@ -83,7 +83,7 @@ exports[`PredictGameDetailsContent matches snapshot 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -113,9 +113,9 @@ exports[`PredictGameDetailsContent matches snapshot 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", - "fontSize": 18, + "fontSize": 20, "fontWeight": 700, "letterSpacing": 0, "lineHeight": 24, diff --git a/app/components/UI/Predict/components/PredictMarketSkeleton/__snapshots__/PredictMarketSkeleton.test.tsx.snap b/app/components/UI/Predict/components/PredictMarketSkeleton/__snapshots__/PredictMarketSkeleton.test.tsx.snap index 5ef0a29689f5..67b45c750348 100644 --- a/app/components/UI/Predict/components/PredictMarketSkeleton/__snapshots__/PredictMarketSkeleton.test.tsx.snap +++ b/app/components/UI/Predict/components/PredictMarketSkeleton/__snapshots__/PredictMarketSkeleton.test.tsx.snap @@ -5,7 +5,7 @@ exports[`PredictMarketSkeleton matches snapshot 1`] = ` style={ [ { - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderRadius": 12, "display": "flex", "marginBottom": 8, @@ -50,7 +50,7 @@ exports[`PredictMarketSkeleton matches snapshot 1`] = ` pointerEvents="none" style={ { - "backgroundColor": "#686e7d", + "backgroundColor": "#66676a", "borderRadius": 4, "bottom": 0, "left": 0, @@ -91,7 +91,7 @@ exports[`PredictMarketSkeleton matches snapshot 1`] = ` pointerEvents="none" style={ { - "backgroundColor": "#686e7d", + "backgroundColor": "#66676a", "borderRadius": 4, "bottom": 0, "left": 0, @@ -122,7 +122,7 @@ exports[`PredictMarketSkeleton matches snapshot 1`] = ` pointerEvents="none" style={ { - "backgroundColor": "#686e7d", + "backgroundColor": "#66676a", "borderRadius": 4, "bottom": 0, "left": 0, @@ -150,7 +150,7 @@ exports[`PredictMarketSkeleton matches snapshot 1`] = ` pointerEvents="none" style={ { - "backgroundColor": "#686e7d", + "backgroundColor": "#66676a", "borderRadius": 4, "bottom": 0, "left": 0, diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx index 06da69f9df1f..8fa4281d4ea0 100644 --- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx +++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx @@ -76,6 +76,15 @@ jest.mock('@tanstack/react-query', () => ({ }), })); +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + }), + useFocusEffect: jest.fn(), +})); + const mockExecuteGuardedAction = jest.fn(async (action) => await action()); jest.mock('../../hooks/usePredictActionGuard', () => ({ usePredictActionGuard: () => ({ @@ -87,7 +96,7 @@ jest.mock('../../hooks/usePredictActionGuard', () => ({ const mockRefetchClaimablePositions = jest.fn(); jest.mock('../../hooks/usePredictPositions', () => ({ usePredictPositions: () => ({ - data: [], + data: [{ id: 'position-1' }], isLoading: false, error: null, refetch: mockRefetchClaimablePositions, @@ -104,14 +113,6 @@ jest.mock('../../hooks/usePredictClaim', () => ({ }), })); -const mockNavigate = jest.fn(); -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: mockNavigate, - }), -})); - jest.mock('../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string, params?: Record) => { const mockStrings: Record = { @@ -191,16 +192,15 @@ describe('MarketsWonCard', () => { mockBalanceResult.isLoading = false; mockUseUnrealizedPnL.mockReturnValue({ - unrealizedPnL: { + data: { user: '0x1234567890123456789012345678901234567890', cashUpnl: 8.63, percentUpnl: 3.9, }, isLoading: false, - isRefreshing: false, + isFetching: false, error: null, - loadUnrealizedPnL: jest.fn(), - }); + } as unknown as ReturnType); }); afterEach(() => { @@ -265,19 +265,7 @@ describe('MarketsWonCard', () => { }); describe('refresh', () => { - it('reloads balance and unrealized P&L when refresh is called', async () => { - const mockLoadUnrealizedPnL = jest.fn(); - mockUseUnrealizedPnL.mockReturnValue({ - unrealizedPnL: { - user: '0x1234567890123456789012345678901234567890', - cashUpnl: 8.63, - percentUpnl: 3.9, - }, - isLoading: false, - isRefreshing: false, - error: null, - loadUnrealizedPnL: mockLoadUnrealizedPnL, - }); + it('invalidates balance and unrealized P&L queries when refresh is called', async () => { const ref = React.createRef<{ refresh: () => Promise }>(); const state = createTestState(100.5); @@ -285,7 +273,7 @@ describe('MarketsWonCard', () => { await ref.current?.refresh(); - expect(mockLoadUnrealizedPnL).toHaveBeenCalledWith({ isRefresh: true }); + expect(mockInvalidateQueries).toHaveBeenCalled(); }); }); @@ -303,16 +291,15 @@ describe('MarketsWonCard', () => { it('displays skeleton loader when unrealized P&L is loading', () => { mockUseUnrealizedPnL.mockReturnValue({ - unrealizedPnL: { + data: { user: '0x1234567890123456789012345678901234567890', cashUpnl: 0, percentUpnl: 0, }, isLoading: true, - isRefreshing: false, + isFetching: true, error: null, - loadUnrealizedPnL: jest.fn(), - }); + } as unknown as ReturnType); const state = createTestState(100.5); renderWithProvider(, { state }); @@ -326,12 +313,11 @@ describe('MarketsWonCard', () => { mockBalanceResult.data = undefined; mockBalanceResult.isLoading = false; mockUseUnrealizedPnL.mockReturnValue({ - unrealizedPnL: null, + data: undefined, isLoading: false, - isRefreshing: false, + isFetching: false, error: null, - loadUnrealizedPnL: jest.fn(), - }); + } as unknown as ReturnType); const state = createTestState(); const { toJSON } = renderWithProvider(, { state }); @@ -357,16 +343,15 @@ describe('MarketsWonCard', () => { mockBalanceResult.error = null; mockBalanceResult.data = 100.5; mockUseUnrealizedPnL.mockReturnValue({ - unrealizedPnL: { + data: { user: '0x1234567890123456789012345678901234567890', cashUpnl: 8.63, percentUpnl: 3.9, }, isLoading: false, - isRefreshing: false, - error: 'P&L fetch failed', - loadUnrealizedPnL: jest.fn(), - }); + isFetching: false, + error: new Error('P&L fetch failed'), + } as unknown as ReturnType); const state = createTestState(100.5); renderWithProvider(, { state }); @@ -379,16 +364,15 @@ describe('MarketsWonCard', () => { mockBalanceResult.error = { message: 'Balance error' }; mockBalanceResult.data = 100.5; mockUseUnrealizedPnL.mockReturnValue({ - unrealizedPnL: { + data: { user: '0x1234567890123456789012345678901234567890', cashUpnl: 8.63, percentUpnl: 3.9, }, isLoading: false, - isRefreshing: false, - error: 'P&L error', - loadUnrealizedPnL: jest.fn(), - }); + isFetching: false, + error: new Error('P&L error'), + } as unknown as ReturnType); const state = createTestState(100.5); renderWithProvider(, { state }); diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx index 339e05cbfb66..c03850645334 100644 --- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx +++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx @@ -8,9 +8,14 @@ import { ButtonSize as ButtonSizeHero, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { + NavigationProp, + useNavigation, + useFocusEffect, +} from '@react-navigation/native'; import React, { forwardRef, + useCallback, useEffect, useImperativeHandle, useMemo, @@ -41,6 +46,7 @@ import { usePredictClaim } from '../../hooks/usePredictClaim'; 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 { PredictNavigationParamList } from '../../types/navigation'; @@ -89,16 +95,32 @@ const PredictPositionsHeader = forwardRef< selectPredictWonPositions({ address: selectedAddress }), ); + const { data: activePositions } = usePredictPositions({ claimable: false }); + const hasPositions = (activePositions?.length ?? 0) > 0; + const { - unrealizedPnL, + data: pnlData, isLoading: isUnrealizedPnLLoading, - loadUnrealizedPnL, error: pnlError, } = useUnrealizedPnL(); + // Only show P&L when the user has active (non-claimable) positions + const unrealizedPnL = hasPositions ? (pnlData ?? null) : null; + + // Invalidate unrealized P&L query when screen comes into focus + const pnlQueryKey = useMemo( + () => predictQueries.unrealizedPnL.keys.byAddress(selectedAddress), + [selectedAddress], + ); + useFocusEffect( + useCallback(() => { + queryClient.invalidateQueries({ queryKey: pnlQueryKey }); + }, [queryClient, pnlQueryKey]), + ); + // Notify parent of errors while keeping state isolated useEffect(() => { - const combinedError = balanceError?.message ?? pnlError ?? null; + const combinedError = balanceError?.message ?? pnlError?.message ?? null; onError?.(combinedError); }, [balanceError, pnlError, onError]); @@ -122,7 +144,7 @@ const PredictPositionsHeader = forwardRef< useImperativeHandle(ref, () => ({ refresh: async () => { await Promise.all([ - loadUnrealizedPnL({ isRefresh: true }), + queryClient.invalidateQueries({ queryKey: pnlQueryKey }), queryClient.invalidateQueries({ queryKey: predictQueries.balance.keys.all(), }), diff --git a/app/components/UI/Predict/constants/eventNames.ts b/app/components/UI/Predict/constants/eventNames.ts index de26b01602cc..937ad44eea31 100644 --- a/app/components/UI/Predict/constants/eventNames.ts +++ b/app/components/UI/Predict/constants/eventNames.ts @@ -22,6 +22,7 @@ export const PredictEventProperties = { // Trade specific MARKET_TYPE: 'market_type', OUTCOME: 'outcome', + ORDER_TYPE: 'order_type', // Sensitive properties AMOUNT_USD: 'amount_usd', diff --git a/app/components/UI/Predict/constants/flags.ts b/app/components/UI/Predict/constants/flags.ts index d81c3d60b1b2..6590daaecc03 100644 --- a/app/components/UI/Predict/constants/flags.ts +++ b/app/components/UI/Predict/constants/flags.ts @@ -14,6 +14,8 @@ export const DEFAULT_FEE_COLLECTION_FLAG = { metamaskFee: 0.02, // 2% providerFee: 0.02, // 2% waiveList: [], + executors: [], + permit2Enabled: false, } satisfies PredictFeeCollection; export const DEFAULT_LIVE_SPORTS_FLAG: PredictLiveSportsFlag = { diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index 19add5bbd30a..630ce4b6ff79 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -498,7 +498,6 @@ describe('PredictController', () => { expect(controller.state.lastUpdateTimestamp).toBeGreaterThan(0); expect(mockPolymarketProvider.getMarketDetails).toHaveBeenCalledWith({ marketId: 'market-1', - liveSportsLeagues: [], }); }); }); @@ -522,7 +521,6 @@ describe('PredictController', () => { expect(result).toEqual(mockMarket); expect(mockPolymarketProvider.getMarketDetails).toHaveBeenCalledWith({ marketId: 'market-2', - liveSportsLeagues: [], }); }); }); @@ -580,7 +578,6 @@ describe('PredictController', () => { expect(result).toEqual(mockMarket); expect(mockPolymarketProvider.getMarketDetails).toHaveBeenCalledWith({ marketId: '123', - liveSportsLeagues: [], }); }); }); @@ -1403,10 +1400,10 @@ describe('PredictController', () => { expect(result[1].id).toBe('highlight-2'); expect(result[2].id).toBe('regular-1'); expect(result[3].id).toBe('regular-2'); - expect(mockPolymarketProvider.getMarketsByIds).toHaveBeenCalledWith( - ['highlight-1', 'highlight-2'], - [], - ); + expect(mockPolymarketProvider.getMarketsByIds).toHaveBeenCalledWith([ + 'highlight-1', + 'highlight-2', + ]); }, { mocks: { @@ -4201,12 +4198,6 @@ describe('PredictController', () => { signTypedMessage: expect.any(Function), signPersonalMessage: expect.any(Function), }), - feeCollection: expect.objectContaining({ - enabled: true, - collector: expect.any(String), - metamaskFee: expect.any(Number), - providerFee: expect.any(Number), - }), }), ); }); @@ -5872,6 +5863,36 @@ describe('PredictController', () => { }); }); + it('includes orderType in analytics properties when provided', async () => { + await withController(async ({ controller }) => { + await controller.trackPredictOrderEvent({ + status: 'submitted', + analyticsProperties: { marketId: 'test' }, + orderType: 'FAK', + }); + + expect(analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + order_type: 'FAK', + }), + }), + ); + }); + }); + + it('omits orderType from analytics properties when not provided', async () => { + await withController(async ({ controller }) => { + await controller.trackPredictOrderEvent({ + status: 'submitted', + analyticsProperties: { marketId: 'test' }, + }); + + const eventArg = (analytics.trackEvent as jest.Mock).mock.calls[0][0]; + expect(eventArg.properties).not.toHaveProperty('order_type'); + }); + }); + it('calls analytics.trackEvent for trackMarketDetailsOpened', () => { withController(({ controller }) => { controller.trackMarketDetailsOpened({ diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 21c79df49f01..e11c74d78e11 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -79,6 +79,7 @@ import { PredictClaim, PredictClaimStatus, PredictMarket, + PredictOrderType, PredictPosition, PredictPositionStatus, PredictPriceHistoryPoint, @@ -106,7 +107,7 @@ import { } from '../constants/flags'; import { filterSupportedLeagues } from '../constants/sports'; import { - PredictFeeCollection, + PredictFeatureFlags, PredictLiveSportsFlag, PredictMarketHighlightsFlag, } from '../types/flags'; @@ -327,7 +328,9 @@ export class PredictController extends BaseController< state: { ...getDefaultPredictControllerState(), ...state }, }); - this.provider = new PolymarketProvider(); + this.provider = new PolymarketProvider({ + getFeatureFlags: () => this.resolveFeatureFlags(), + }); this.messenger.subscribe( 'TransactionController:transactionStatusUpdated', @@ -410,6 +413,51 @@ export class PredictController extends BaseController< }; } + private resolveFeatureFlags(): PredictFeatureFlags { + const remoteFeatureFlagState = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + const flags = remoteFeatureFlagState.remoteFeatureFlags; + + const liveSportsFlag = + unwrapRemoteFeatureFlag(flags.predictLiveSports) ?? + DEFAULT_LIVE_SPORTS_FLAG; + const liveSportsLeagues = liveSportsFlag.enabled + ? filterSupportedLeagues(liveSportsFlag.leagues ?? []) + : []; + + const rawMarketHighlightsFlag = + unwrapRemoteFeatureFlag( + flags.predictMarketHighlights, + ); + const isHighlightsFlagValid = validatedVersionGatedFeatureFlag( + rawMarketHighlightsFlag as unknown as VersionGatedFeatureFlag, + ); + const marketHighlightsFlag = + isHighlightsFlagValid && rawMarketHighlightsFlag + ? rawMarketHighlightsFlag + : DEFAULT_MARKET_HIGHLIGHTS_FLAG; + + const feeCollection = + unwrapRemoteFeatureFlag( + flags.predictFeeCollection, + ) ?? DEFAULT_FEE_COLLECTION_FLAG; + + const fakOrdersEnabled = + validatedVersionGatedFeatureFlag( + unwrapRemoteFeatureFlag( + flags.predictFakOrders, + ), + ) ?? false; + + return { + feeCollection, + liveSportsLeagues, + marketHighlightsFlag, + fakOrdersEnabled, + }; + } + private getEvmAccountAddress(): string { const accounts = this.messenger.call( 'AccountTreeController:getAccountsFromSelectedAccountGroup', @@ -467,58 +515,28 @@ export class PredictController extends BaseController< }); try { - const remoteFeatureFlagState = this.messenger.call( - 'RemoteFeatureFlagController:getState', - ); - const liveSportsFlag = - unwrapRemoteFeatureFlag( - remoteFeatureFlagState.remoteFeatureFlags.predictLiveSports, - ) ?? DEFAULT_LIVE_SPORTS_FLAG; - - const liveSportsLeagues = liveSportsFlag.enabled - ? filterSupportedLeagues(liveSportsFlag.leagues ?? []) - : []; - - const rawMarketHighlightsFlag = - unwrapRemoteFeatureFlag( - remoteFeatureFlagState.remoteFeatureFlags.predictMarketHighlights, - ); - - const isHighlightsFlagValid = validatedVersionGatedFeatureFlag( - rawMarketHighlightsFlag as unknown as VersionGatedFeatureFlag, - ); + const featureFlags = this.resolveFeatureFlags(); - const marketHighlightsFlag: PredictMarketHighlightsFlag = - isHighlightsFlagValid && rawMarketHighlightsFlag - ? rawMarketHighlightsFlag - : DEFAULT_MARKET_HIGHLIGHTS_FLAG; - - const paramsWithLiveSports = { ...params, liveSportsLeagues }; - - const allMarkets = await this.provider.getMarkets(paramsWithLiveSports); + const allMarkets = await this.provider.getMarkets(params); let markets = allMarkets.filter( (market): market is PredictMarket => market !== undefined, ); const isFirstPage = !params.offset || params.offset === 0; + const highlights = featureFlags.marketHighlightsFlag.highlights ?? []; const shouldFetchHighlights = - isHighlightsFlagValid && isFirstPage && params.category && !params.q; + highlights.length > 0 && isFirstPage && params.category && !params.q; if (shouldFetchHighlights) { const highlightedMarketIds = - (marketHighlightsFlag.highlights ?? []).find( - (h) => h.category === params.category, - )?.markets ?? []; + highlights.find((h) => h.category === params.category)?.markets ?? []; if (highlightedMarketIds.length > 0) { const provider = this.provider; const fetchedHighlightedMarkets = - (await provider.getMarketsByIds?.( - highlightedMarketIds, - liveSportsLeagues, - )) ?? []; + (await provider.getMarketsByIds?.(highlightedMarketIds)) ?? []; const highlightedMarkets = fetchedHighlightedMarkets.filter( (market) => market.status === 'open', @@ -608,21 +626,8 @@ export class PredictController extends BaseController< try { const provider = this.provider; - - const remoteFeatureFlagState = this.messenger.call( - 'RemoteFeatureFlagController:getState', - ); - const liveSportsFlag = - unwrapRemoteFeatureFlag( - remoteFeatureFlagState.remoteFeatureFlags.predictLiveSports, - ) ?? DEFAULT_LIVE_SPORTS_FLAG; - const liveSportsLeagues = liveSportsFlag.enabled - ? filterSupportedLeagues(liveSportsFlag.leagues ?? []) - : []; - const market = await provider.getMarketDetails({ marketId: resolvedMarketId, - liveSportsLeagues, }); this.update((state) => { @@ -1042,6 +1047,7 @@ export class PredictController extends BaseController< failureReason, sharePrice, pnl, + orderType, }: { status: PredictTradeStatusValue; amountUsd?: number; @@ -1050,6 +1056,7 @@ export class PredictController extends BaseController< failureReason?: string; sharePrice?: number; pnl?: number; + orderType?: PredictOrderType; }): Promise { if (!analyticsProperties) { return; @@ -1103,6 +1110,9 @@ export class PredictController extends BaseController< ...(analyticsProperties.gameClock && { [PredictEventProperties.GAME_CLOCK]: analyticsProperties.gameClock, }), + ...(orderType && { + [PredictEventProperties.ORDER_TYPE]: orderType, + }), }; // Build sensitive properties @@ -1370,17 +1380,9 @@ export class PredictController extends BaseController< try { const provider = this.provider; - const remoteFeatureFlagState = this.messenger.call( - 'RemoteFeatureFlagController:getState', - ); - const feeCollection = - unwrapRemoteFeatureFlag( - remoteFeatureFlagState.remoteFeatureFlags.predictFeeCollection, - ) ?? DEFAULT_FEE_COLLECTION_FLAG; - const signer = this.getSigner(); - return provider.previewOrder({ ...params, signer, feeCollection }); + return provider.previewOrder({ ...params, signer }); } catch (error) { // Log to Sentry with preview context (no sensitive amounts) Logger.error( @@ -1440,6 +1442,7 @@ export class PredictController extends BaseController< amountUsd, analyticsProperties, sharePrice, + orderType: preview.orderType, }); // Invalidate query cache (to avoid nonce issues) @@ -1500,6 +1503,7 @@ export class PredictController extends BaseController< analyticsProperties, completionDuration, sharePrice: realSharePrice, + orderType: preview.orderType, }); traceData = { success: true, side: preview.side }; @@ -1519,6 +1523,7 @@ export class PredictController extends BaseController< sharePrice, completionDuration, failureReason: errorMessage, + orderType: preview.orderType, }); // Update error state for Sentry integration diff --git a/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts b/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts index d6b9c75ad537..d3a902e9de90 100644 --- a/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts +++ b/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts @@ -194,6 +194,10 @@ export function usePredictPlaceOrder( queryKey: predictQueries.activity.keys.all(), }); + queryClient.invalidateQueries({ + queryKey: predictQueries.unrealizedPnL.keys.all(), + }); + if (side === Side.BUY) { showOrderPlacedToast(); } else { diff --git a/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx b/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx index 2c3401d2218e..ef8a52f8623d 100644 --- a/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx +++ b/app/components/UI/Predict/hooks/usePredictToastRegistrations.test.tsx @@ -189,6 +189,11 @@ describe('usePredictToastRegistrations', () => { queryKey: ['predict', 'balance'], }), ); + expect(mockInvalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'unrealizedPnL'], + }), + ); }); it('uses account ready fallback when deposit confirmed amount is missing', () => { @@ -335,6 +340,11 @@ describe('usePredictToastRegistrations', () => { queryKey: ['predict', 'balance'], }), ); + expect(mockInvalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'unrealizedPnL'], + }), + ); }); it('shows error toast with retry on failed status', async () => { @@ -429,6 +439,11 @@ describe('usePredictToastRegistrations', () => { queryKey: ['predict', 'balance'], }), ); + expect(mockInvalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: ['predict', 'unrealizedPnL'], + }), + ); }); it('uses payload amount for withdraw success toast when state amount is unavailable', () => { diff --git a/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx b/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx index cc82bb50ee0a..1e31f256e3f0 100644 --- a/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx +++ b/app/components/UI/Predict/hooks/usePredictToastRegistrations.tsx @@ -161,6 +161,10 @@ export const usePredictToastRegistrations = (): ToastRegistration[] => { queryClient.invalidateQueries({ queryKey: predictQueries.activity.keys.all(), }); + + queryClient.invalidateQueries({ + queryKey: predictQueries.unrealizedPnL.keys.all(), + }); } if (type === 'deposit') { diff --git a/app/components/UI/Predict/hooks/useUnrealizedPnL.test.tsx b/app/components/UI/Predict/hooks/useUnrealizedPnL.test.tsx index d7fb53f8e6cc..2bc2f950ad42 100644 --- a/app/components/UI/Predict/hooks/useUnrealizedPnL.test.tsx +++ b/app/components/UI/Predict/hooks/useUnrealizedPnL.test.tsx @@ -1,20 +1,15 @@ -import { act, renderHook, waitFor } 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 { useUnrealizedPnL } from './useUnrealizedPnL'; import { UnrealizedPnL } from '../types'; -import { usePredictPositions } from './usePredictPositions'; const mockSelectedAddress = '0x1234567890123456789012345678901234567890'; -jest.mock('react-redux', () => ({ - useSelector: jest.fn(() => mockSelectedAddress), -})); -jest.mock('@react-navigation/native', () => ({ - useFocusEffect: jest.fn(), +jest.mock('react-redux', () => ({ + useSelector: jest.fn(() => 'mock-account-group-id'), })); -jest.mock('./usePredictPositions'); -const mockUsePredictPositions = usePredictPositions as jest.Mock; - const mockGetUnrealizedPnL = jest.fn(); jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({ @@ -44,6 +39,15 @@ jest.mock('../../../../core/Engine', () => ({ }, })); +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + return { Wrapper, queryClient }; +}; + describe('useUnrealizedPnL', () => { const basePnL: UnrealizedPnL = { user: '0x1111111111111111111111111111111111111111', @@ -53,255 +57,86 @@ describe('useUnrealizedPnL', () => { beforeEach(() => { jest.clearAllMocks(); - mockUsePredictPositions.mockReturnValue({ - data: [{ id: 'position-1' }], - }); }); - afterEach(() => { - jest.clearAllMocks(); - }); - - it('returns initial state when disabled', () => { - const { result } = renderHook(() => - useUnrealizedPnL({ loadOnMount: false }), - ); + it('does not fetch when enabled is false', () => { + const { Wrapper } = createWrapper(); + const { result } = renderHook(() => useUnrealizedPnL({ enabled: false }), { + wrapper: Wrapper, + }); - expect(result.current.unrealizedPnL).toBeNull(); - expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + expect(result.current.isLoading).toBe(false); expect(result.current.error).toBeNull(); - expect(typeof result.current.loadUnrealizedPnL).toBe('function'); expect(mockGetUnrealizedPnL).not.toHaveBeenCalled(); }); it('fetches unrealized P&L successfully with default options', async () => { + const { Wrapper } = createWrapper(); mockGetUnrealizedPnL.mockResolvedValue(basePnL); - const { result } = renderHook(() => useUnrealizedPnL()); + const { result } = renderHook(() => useUnrealizedPnL(), { + wrapper: Wrapper, + }); await waitFor(() => { expect(result.current.isLoading).toBe(false); - expect(result.current.unrealizedPnL).toEqual(basePnL); - expect(result.current.error).toBeNull(); }); + expect(result.current.data).toEqual(basePnL); + expect(result.current.error).toBeNull(); expect(mockGetUnrealizedPnL).toHaveBeenCalledWith({ address: mockSelectedAddress, }); }); - it('passes provided options to getUnrealizedPnL', async () => { + it('uses provided address instead of selected account', async () => { + const { Wrapper } = createWrapper(); mockGetUnrealizedPnL.mockResolvedValue(basePnL); + const customAddress = '0x2222222222222222222222222222222222222222'; - const { result } = renderHook(() => - useUnrealizedPnL({ - address: '0x2222222222222222222222222222222222222222', - }), + const { result } = renderHook( + () => useUnrealizedPnL({ address: customAddress }), + { wrapper: Wrapper }, ); await waitFor(() => { - expect(result.current.unrealizedPnL).toEqual(basePnL); + expect(result.current.data).toEqual(basePnL); }); expect(mockGetUnrealizedPnL).toHaveBeenCalledWith({ - address: '0x2222222222222222222222222222222222222222', + address: customAddress, }); }); - it('handles null responses by clearing the data', async () => { + it('handles null responses', async () => { + const { Wrapper } = createWrapper(); mockGetUnrealizedPnL.mockResolvedValue(null); - const { result } = renderHook(() => useUnrealizedPnL()); - - await waitFor(() => { - expect(result.current.unrealizedPnL).toBeNull(); - expect(result.current.error).toBeNull(); - expect(result.current.isLoading).toBe(false); + const { result } = renderHook(() => useUnrealizedPnL(), { + wrapper: Wrapper, }); - }); - - it('surfaces errors thrown by getUnrealizedPnL', async () => { - mockGetUnrealizedPnL.mockRejectedValue(new Error('Network error')); - - const { result } = renderHook(() => useUnrealizedPnL()); await waitFor(() => { - expect(result.current.error).toBe('Network error'); - expect(result.current.unrealizedPnL).toBeNull(); expect(result.current.isLoading).toBe(false); }); - }); - - it('maps non-Error rejections to a generic message', async () => { - mockGetUnrealizedPnL.mockRejectedValue('bad times'); - - const { result } = renderHook(() => useUnrealizedPnL()); - await waitFor(() => { - expect(result.current.error).toBe('Failed to fetch unrealized P&L'); - }); - }); - - it('supports manual refetching', async () => { - mockGetUnrealizedPnL.mockResolvedValue(basePnL); - - const updatedPnL: UnrealizedPnL = { - user: '0x9999999999999999999999999999999999999999', - cashUpnl: -5, - percentUpnl: -2, - }; - - const { result } = renderHook(() => useUnrealizedPnL()); - - await waitFor(() => { - expect(result.current.unrealizedPnL).toEqual(basePnL); - }); - - mockGetUnrealizedPnL.mockResolvedValue(updatedPnL); - - await act(async () => { - await result.current.loadUnrealizedPnL(); - }); - - await waitFor(() => { - expect(result.current.unrealizedPnL).toEqual(updatedPnL); - expect(result.current.error).toBeNull(); - }); - - expect(mockGetUnrealizedPnL).toHaveBeenCalledTimes(2); - }); - - it('loads data when loadOnMount changes from false to true', async () => { - mockGetUnrealizedPnL.mockResolvedValue(basePnL); - - const { result, rerender } = renderHook( - ({ loadOnMount }) => useUnrealizedPnL({ loadOnMount }), - { - initialProps: { loadOnMount: false }, - }, - ); - - expect(mockGetUnrealizedPnL).not.toHaveBeenCalled(); - - rerender({ loadOnMount: true }); - - await waitFor(() => { - expect(result.current.unrealizedPnL).toEqual(basePnL); - expect(result.current.isLoading).toBe(false); - }); + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); }); - it('refetches when dependencies change', async () => { - mockGetUnrealizedPnL.mockResolvedValue(basePnL); - - const { rerender } = renderHook( - ({ address }: { address?: string }) => useUnrealizedPnL({ address }), - { - initialProps: { - address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - }, - }, - ); - - await waitFor(() => { - expect(mockGetUnrealizedPnL).toHaveBeenCalledTimes(1); - }); + it('surfaces errors thrown by getUnrealizedPnL', async () => { + const { Wrapper } = createWrapper(); + mockGetUnrealizedPnL.mockRejectedValue(new Error('Network error')); - rerender({ - address: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + const { result } = renderHook(() => useUnrealizedPnL(), { + wrapper: Wrapper, }); await waitFor(() => { - expect(mockGetUnrealizedPnL).toHaveBeenCalledTimes(2); - }); - - expect(mockGetUnrealizedPnL).toHaveBeenNthCalledWith(1, { - address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - }); - expect(mockGetUnrealizedPnL).toHaveBeenNthCalledWith(2, { - address: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - }); - }); - - describe('positions-based visibility', () => { - it('returns null when user has no positions', async () => { - mockUsePredictPositions.mockReturnValue({ data: [] }); - mockGetUnrealizedPnL.mockResolvedValue(basePnL); - - const { result } = renderHook(() => useUnrealizedPnL()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.unrealizedPnL).toBeNull(); - expect(result.current.error).toBeNull(); - }); - - it('returns unrealized P&L when user has positions', async () => { - mockUsePredictPositions.mockReturnValue({ - data: [{ id: 'position-1' }, { id: 'position-2' }], - }); - mockGetUnrealizedPnL.mockResolvedValue(basePnL); - - const { result } = renderHook(() => useUnrealizedPnL()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.unrealizedPnL).toEqual(basePnL); - expect(result.current.error).toBeNull(); - }); - - it('calls usePredictPositions with claimable: false', () => { - mockGetUnrealizedPnL.mockResolvedValue(basePnL); - - renderHook(() => useUnrealizedPnL()); - - expect(mockUsePredictPositions).toHaveBeenCalledWith({ - claimable: false, - }); - }); - - it('returns null when getUnrealizedPnL returns null and user has positions', async () => { - mockGetUnrealizedPnL.mockResolvedValue(null); - - const { result } = renderHook(() => useUnrealizedPnL()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.unrealizedPnL).toBeNull(); - expect(result.current.error).toBeNull(); - }); - - it('returns null when both getUnrealizedPnL returns null and no positions', async () => { - mockUsePredictPositions.mockReturnValue({ data: [] }); - mockGetUnrealizedPnL.mockResolvedValue(null); - - const { result } = renderHook(() => useUnrealizedPnL()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.unrealizedPnL).toBeNull(); - expect(result.current.error).toBeNull(); - }); - - it('returns null when positions data is undefined', async () => { - mockUsePredictPositions.mockReturnValue({ data: undefined }); - mockGetUnrealizedPnL.mockResolvedValue(basePnL); - - const { result } = renderHook(() => useUnrealizedPnL()); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.unrealizedPnL).toBeNull(); + expect(result.current.error?.message).toBe('Network error'); }); + expect(result.current.data).toBeUndefined(); + expect(result.current.isLoading).toBe(false); }); }); diff --git a/app/components/UI/Predict/hooks/useUnrealizedPnL.tsx b/app/components/UI/Predict/hooks/useUnrealizedPnL.tsx index ebd8639288e6..8edf311c57c8 100644 --- a/app/components/UI/Predict/hooks/useUnrealizedPnL.tsx +++ b/app/components/UI/Predict/hooks/useUnrealizedPnL.tsx @@ -1,156 +1,63 @@ -import { useFocusEffect } from '@react-navigation/native'; +import { useEffect } from 'react'; +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import Logger from '../../../../util/Logger'; -import Engine from '../../../../core/Engine'; -import { UnrealizedPnL } from '../types'; -import { getEvmAccountFromSelectedAccountGroup } from '../utils/accounts'; import { PREDICT_CONSTANTS } from '../constants/errors'; -import { ensureError } from '../utils/predictErrorHandler'; +import { getEvmAccountFromSelectedAccountGroup } from '../utils/accounts'; +import { predictQueries } from '../queries'; import { selectSelectedAccountGroupId } from '../../../../selectors/multichainAccounts/accountTreeController'; -import { usePredictPositions } from './usePredictPositions'; +import type { UnrealizedPnL } from '../types'; +import { ensureError } from '../utils/predictErrorHandler'; -export interface UseUnrealizedPnLOptions { +interface UseUnrealizedPnLOptions { /** - * The address to fetch unrealized P&L for + * The address to fetch unrealized P&L for. + * Defaults to the EVM address from the currently selected account group. */ address?: string; /** - * Whether to load unrealized P&L on mount - * @default true - */ - loadOnMount?: boolean; - /** - * Whether to refresh unrealized P&L when screen comes into focus + * Whether to enable the query. * @default true */ - refreshOnFocus?: boolean; -} - -export interface UseUnrealizedPnLResult { - unrealizedPnL: UnrealizedPnL | null; - isLoading: boolean; - isRefreshing: boolean; - error: string | null; - loadUnrealizedPnL: (options?: { isRefresh?: boolean }) => Promise; + enabled?: boolean; } /** - * Hook for managing unrealized P&L data with loading states - * @param options Configuration options for the hook - * @returns Unrealized P&L data and loading utilities + * Hook to fetch unrealized P&L data for the current account */ -export const useUnrealizedPnL = ( +export function useUnrealizedPnL( options: UseUnrealizedPnLOptions = {}, -): UseUnrealizedPnLResult => { - const { address, loadOnMount = true, refreshOnFocus = true } = options; - - const [pnlData, setPnlData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isRefreshing, setIsRefreshing] = useState(false); - const [error, setError] = useState(null); - const isInitialMount = useRef(true); +): UseQueryResult { + const { address, enabled = true } = options; // Subscribe to account group changes so the hook re-renders when the user switches accounts useSelector(selectSelectedAccountGroupId); const evmAccount = getEvmAccountFromSelectedAccountGroup(); - const selectedInternalAccountAddress = evmAccount?.address ?? '0x0'; - - const { data: activePositions } = usePredictPositions({ claimable: false }); - const hasPositions = (activePositions?.length ?? 0) > 0; - - const loadUnrealizedPnL = useCallback( - async (loadOptions?: { isRefresh?: boolean }) => { - const { isRefresh = false } = loadOptions || {}; + const resolvedAddress = address ?? evmAccount?.address ?? '0x0'; - try { - if (isRefresh) { - setIsRefreshing(true); - } else { - setIsLoading(true); - } - setError(null); + const queryResult = useQuery({ + ...predictQueries.unrealizedPnL.options({ address: resolvedAddress }), + enabled, + }); - const unrealizedPnLResult = - await Engine.context.PredictController.getUnrealizedPnL({ - address: address ?? selectedInternalAccountAddress, - }); - - setPnlData(unrealizedPnLResult ?? null); - - DevLogger.log('useUnrealizedPnL: Loaded unrealized P&L', { - unrealizedPnL: unrealizedPnLResult, - }); - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : 'Failed to fetch unrealized P&L'; - setError(errorMessage); - setPnlData(null); - DevLogger.log('useUnrealizedPnL: Error loading unrealized P&L', err); - - Logger.error(ensureError(err), { - tags: { - feature: PREDICT_CONSTANTS.FEATURE_NAME, - component: 'useUnrealizedPnL', - }, - context: { - name: 'useUnrealizedPnL', - data: { - method: 'loadUnrealizedPnL', - action: 'unrealized_pnl_load', - operation: 'data_fetching', - }, - }, - }); - } finally { - setIsLoading(false); - setIsRefreshing(false); - } - }, - [address, selectedInternalAccountAddress], - ); - - // Load unrealized P&L on mount if enabled useEffect(() => { - if (loadOnMount) { - loadUnrealizedPnL(); - } - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loadOnMount]); - - // Refresh unrealized P&L when screen comes into focus if enabled - useFocusEffect( - useCallback(() => { - if (refreshOnFocus) { - loadUnrealizedPnL({ isRefresh: true }); - } - }, [refreshOnFocus, loadUnrealizedPnL]), - ); - - // Reset and reload data when address changes (but not on initial mount) - useEffect(() => { - if (isInitialMount.current) { - isInitialMount.current = false; - return; - } - - setPnlData(null); - setError(null); - loadUnrealizedPnL(); - }, [address, loadUnrealizedPnL]); - - const unrealizedPnL = useMemo( - () => (hasPositions ? pnlData : null), - [hasPositions, pnlData], - ); - - return { - unrealizedPnL, - isLoading, - isRefreshing, - error, - loadUnrealizedPnL, - }; -}; + if (!queryResult.error) return; + + Logger.error(ensureError(queryResult.error), { + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + component: 'useUnrealizedPnL', + }, + context: { + name: 'useUnrealizedPnL', + data: { + method: 'queryFn', + action: 'unrealized_pnl_load', + operation: 'data_fetching', + }, + }, + }); + }, [queryResult.error]); + + return queryResult; +} diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index 566c54511d5f..39026559732a 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -40,16 +40,19 @@ import { } from '../../types'; import { PREDICT_ERROR_CODES } from '../../constants/errors'; import { DEFAULT_FEE_COLLECTION_FLAG } from '../../constants/flags'; +import type { PredictFeatureFlags } from '../../types/flags'; import { OrderPreview, PlaceOrderParams } from '../types'; import { PolymarketProvider } from './PolymarketProvider'; import { computeProxyAddress, + createPermit2FeeAuthorization, createSafeFeeAuthorization, getClaimTransaction, getDeployProxyWalletTransaction, getProxyWalletAllowancesTransaction, hasAllowances, } from './safe/utils'; +import { PERMIT2_ADDRESS } from './safe/constants'; import { createApiKey, encodeClaim, @@ -114,6 +117,7 @@ jest.mock('./utils', () => { jest.mock('./safe/utils', () => ({ computeProxyAddress: jest.fn(), + createPermit2FeeAuthorization: jest.fn(), createSafeFeeAuthorization: jest.fn(), getClaimTransaction: jest.fn(), getDeployProxyWalletTransaction: jest.fn(), @@ -208,6 +212,8 @@ const mockCreateApiKey = createApiKey as jest.Mock; const mockSubmitClobOrder = submitClobOrder as jest.Mock; const mockEncodeClaim = encodeClaim as jest.Mock; const mockComputeProxyAddress = computeProxyAddress as jest.Mock; +const mockCreatePermit2FeeAuthorization = + createPermit2FeeAuthorization as jest.Mock; const mockCreateSafeFeeAuthorization = createSafeFeeAuthorization as jest.Mock; const mockGetClaimTransaction = getClaimTransaction as jest.Mock; const mockHasAllowances = hasAllowances as jest.Mock; @@ -216,7 +222,25 @@ const mockPreviewOrder = previewOrder as jest.Mock; const mockGetBalance = getBalance as jest.Mock; describe('PolymarketProvider', () => { - const createProvider = () => new PolymarketProvider(); + const defaultFeatureFlags: PredictFeatureFlags = { + feeCollection: DEFAULT_FEE_COLLECTION_FLAG, + liveSportsLeagues: [], + marketHighlightsFlag: { + enabled: false, + highlights: [], + minimumVersion: '7.64.0', + }, + fakOrdersEnabled: false, + }; + const createProvider = ( + featureFlagsOverride?: Partial, + ) => + new PolymarketProvider({ + getFeatureFlags: () => ({ + ...defaultFeatureFlags, + ...featureFlagsOverride, + }), + }); it('exposes the correct providerId', () => { const provider = createProvider(); @@ -224,8 +248,6 @@ describe('PolymarketProvider', () => { }); it('getMarkets returns an array with some length', async () => { - const provider = createProvider(); - const mockMarkets = [ { id: 'market-1', @@ -279,7 +301,9 @@ describe('PolymarketProvider', () => { mockGetMarketsFromPolymarketApi.mockResolvedValue(mockMarkets); - const markets = await provider.getMarkets({ liveSportsLeagues: ['nfl'] }); + const markets = await createProvider({ + liveSportsLeagues: ['nfl'], + }).getMarkets(); expect(Array.isArray(markets)).toBe(true); expect(markets.length).toBeGreaterThan(0); expect(markets.length).toBe(2); @@ -289,11 +313,12 @@ describe('PolymarketProvider', () => { }); it('getMarkets returns empty array when API fails', async () => { - const provider = createProvider(); const apiError = new Error('API request failed'); mockGetMarketsFromPolymarketApi.mockRejectedValue(apiError); - const result = await provider.getMarkets({ liveSportsLeagues: ['nfl'] }); + const result = await createProvider({ + liveSportsLeagues: ['nfl'], + }).getMarkets(); expect(result).toEqual([]); expect(mockGetMarketsFromPolymarketApi).toHaveBeenCalledWith( @@ -810,7 +835,9 @@ describe('PolymarketProvider', () => { } // Helper function to setup place order test environment - function setupPlaceOrderTest() { + function setupPlaceOrderTest( + featureFlagsOverride?: Partial, + ) { const mockAddress = '0x1234567890123456789012345678901234567890'; const mockSigner = { address: mockAddress, @@ -818,7 +845,7 @@ describe('PolymarketProvider', () => { signPersonalMessage: mockSignPersonalMessage, }; - const provider = createProvider(); + const provider = createProvider(featureFlagsOverride); const mockMarket = { id: 'market-1', @@ -860,6 +887,21 @@ describe('PolymarketProvider', () => { sig: '0xsig', }, }); + mockCreatePermit2FeeAuthorization.mockResolvedValue({ + type: 'safe-permit2', + authorization: { + permit: { + permitted: { + token: '0xCollateralAddress', + amount: '40000', + }, + nonce: '0', + deadline: '1700000000', + }, + spender: '0x1111111111111111111111111111111111111111', + signature: '0xpermit2sig', + }, + }); mockPriceValid.mockReturnValue(true); @@ -1317,26 +1359,90 @@ describe('PolymarketProvider', () => { }); describe('previewOrder', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const createPreviewSigner = () => ({ + address: '0x1234567890123456789012345678901234567890', + signTypedMessage: jest.fn(), + signPersonalMessage: jest.fn(), + }); + + const createPreviewOrderParams = () => ({ + marketId: 'market-123', + outcomeId: 'outcome-456', + outcomeTokenId: 'token-789', + side: Side.BUY, + size: 100, + signer: createPreviewSigner(), + }); + + const createPermit2PreviewProvider = (fakOrdersEnabled: boolean) => + createProvider({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + fakOrdersEnabled, + }); + + const mockPreviewOrderWithFees = () => { + mockPreviewOrder.mockResolvedValue({ + fees: { + totalFee: 1, + metamaskFee: 0.5, + providerFee: 0.5, + totalFeePercentage: 1, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + }, + }); + }; + it('calls previewOrder utility function with correct parameters', async () => { const provider = createProvider(); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; const mockParams = { - marketId: 'market-123', - outcomeId: 'outcome-456', - outcomeTokenId: 'token-789', - side: Side.BUY, + ...createPreviewOrderParams(), amount: 100, - size: 100, - signer: mockSigner, }; await provider.previewOrder(mockParams); - expect(mockPreviewOrder).toHaveBeenCalledWith(mockParams); + expect(mockPreviewOrder).toHaveBeenCalledWith({ + ...mockParams, + feeCollection: DEFAULT_FEE_COLLECTION_FLAG, + }); + }); + it('returns FOK orderType by default', async () => { + const provider = createProvider(); + const result = await provider.previewOrder(createPreviewOrderParams()); + + expect(result.orderType).toBe('FOK'); + }); + + it.each([ + { fakOrdersEnabled: true, expectedOrderType: 'FAK' }, + { fakOrdersEnabled: false, expectedOrderType: 'FOK' }, + ] as const)( + 'returns $expectedOrderType orderType when fakOrdersEnabled=$fakOrdersEnabled and permit2 config is active', + async ({ fakOrdersEnabled, expectedOrderType }) => { + mockPreviewOrderWithFees(); + const provider = createPermit2PreviewProvider(fakOrdersEnabled); + + const result = await provider.previewOrder(createPreviewOrderParams()); + + expect(result.orderType).toBe(expectedOrderType); + }, + ); + + it('returns FAK orderType when fees are absent and FAK flags are enabled', async () => { + mockPreviewOrder.mockResolvedValue({}); + const provider = createPermit2PreviewProvider(true); + + const result = await provider.previewOrder(createPreviewOrderParams()); + + expect(result.orderType).toBe('FAK'); }); }); @@ -1358,7 +1464,7 @@ describe('PolymarketProvider', () => { signPersonalMessage: mockSignPersonalMessage, }; - const provider = createProvider(); + const provider = createProvider({ liveSportsLeagues: ['nfl'] }); // Setup minimal mocks needed for placeOrder mockSignTypedMessage.mockResolvedValue('0xsignature'); @@ -1601,6 +1707,474 @@ describe('PolymarketProvider', () => { }), ); }); + + it('uses Permit2 fee authorization when permit2Enabled and allowance is set', async () => { + jest.clearAllMocks(); + const { provider, mockSigner } = setupPlaceOrderTest(); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + executors: ['0x1111111111111111111111111111111111111111'], + permit2Enabled: true, + }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockCreatePermit2FeeAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + safeAddress: '0x9999999999999999999999999999999999999999', + spender: '0x1111111111111111111111111111111111111111', + }), + ); + expect(mockCreateSafeFeeAuthorization).not.toHaveBeenCalled(); + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + executor: '0x1111111111111111111111111111111111111111', + feeAuthorization: expect.objectContaining({ type: 'safe-permit2' }), + }), + ); + }); + + it('uses Permit2 fee authorization even when Permit2 allowance is not yet set on-chain', async () => { + jest.clearAllMocks(); + const { provider, mockSigner } = setupPlaceOrderTest(); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + executors: ['0x1111111111111111111111111111111111111111'], + permit2Enabled: true, + }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockCreatePermit2FeeAuthorization).toHaveBeenCalled(); + expect(mockCreateSafeFeeAuthorization).not.toHaveBeenCalled(); + }); + + it('falls back to Safe fee authorization when permit2Enabled is false', async () => { + jest.clearAllMocks(); + const { provider, mockSigner } = setupPlaceOrderTest(); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + executors: ['0x1111111111111111111111111111111111111111'], + permit2Enabled: false, + }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockCreatePermit2FeeAuthorization).not.toHaveBeenCalled(); + expect(mockCreateSafeFeeAuthorization).toHaveBeenCalled(); + }); + + it('falls back to Safe fee authorization when executors are missing', async () => { + jest.clearAllMocks(); + const { provider, mockSigner } = setupPlaceOrderTest(); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + executors: [], + permit2Enabled: true, + }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockCreatePermit2FeeAuthorization).not.toHaveBeenCalled(); + expect(mockCreateSafeFeeAuthorization).toHaveBeenCalled(); + }); + + it('submits FOK order type when fakOrdersEnabled is false', async () => { + jest.clearAllMocks(); + const { provider, mockSigner } = setupPlaceOrderTest(); + const preview = createMockOrderPreview({ side: Side.BUY }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + clobOrder: expect.objectContaining({ orderType: 'FOK' }), + }), + ); + }); + + it('submits FAK order type when Permit2 is used and fakOrdersEnabled is true', async () => { + jest.clearAllMocks(); + const { provider, mockSigner } = setupPlaceOrderTest({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + fakOrdersEnabled: true, + }); + mockHasAllowances.mockResolvedValue(true); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + executors: ['0xexecutor1'], + permit2Enabled: true, + }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + clobOrder: expect.objectContaining({ orderType: 'FAK' }), + }), + ); + }); + + it('submits FOK order type when Permit2 is used but fakOrdersEnabled is false', async () => { + jest.clearAllMocks(); + const { provider, mockSigner } = setupPlaceOrderTest({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + fakOrdersEnabled: false, + }); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + executors: ['0xexecutor1'], + permit2Enabled: true, + }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + clobOrder: expect.objectContaining({ orderType: 'FOK' }), + }), + ); + }); + + it('submits FAK order type when Permit2 fee auth and allowance are ready', async () => { + jest.clearAllMocks(); + const { provider, mockSigner } = setupPlaceOrderTest({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + fakOrdersEnabled: true, + }); + mockHasAllowances.mockResolvedValue(true); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + executors: ['0xexecutor1'], + permit2Enabled: true, + }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + clobOrder: expect.objectContaining({ orderType: 'FAK' }), + }), + ); + }); + }); + + describe('placeOrder with allowancesTx', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + function setupAllowancesTxTest(overrides?: { + permit2Enabled?: boolean; + hasAllowances?: boolean; + executors?: string[]; + }) { + const result = setupPlaceOrderTest({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: overrides?.permit2Enabled ?? true, + executors: overrides?.executors ?? ['0xexecutor1'], + }, + }); + mockComputeProxyAddress.mockReturnValue('0xSafeAddress'); + (isSmartContractAddress as jest.Mock).mockResolvedValue(true); + mockHasAllowances.mockResolvedValue(overrides?.hasAllowances ?? false); + mockFindNetworkClientIdByChainId.mockReturnValue('polygon'); + mockGetNetworkClientById.mockReturnValue({ provider: {} }); + return result; + } + + it('attaches allowancesTx when proxy wallet lacks allowances with fees', async () => { + const { provider, mockSigner } = setupAllowancesTxTest(); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + }); + (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ + params: { to: '0xSafe', data: '0xallowances' }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + allowancesTx: { to: '0xSafe', data: '0xallowances' }, + }), + ); + }); + + it('attaches allowancesTx when proxy wallet lacks allowances without fees', async () => { + const { provider, mockSigner } = setupAllowancesTxTest(); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0, + providerFee: 0, + totalFee: 0, + totalFeePercentage: 0, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + }); + (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ + params: { to: '0xSafe', data: '0xallowances' }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + allowancesTx: { to: '0xSafe', data: '0xallowances' }, + }), + ); + }); + + it('attaches allowancesTx regardless of Permit2 on-chain allowance status', async () => { + const { provider, mockSigner } = setupAllowancesTxTest(); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + }); + (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ + params: { to: '0xSafe', data: '0xallowances' }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + allowancesTx: { to: '0xSafe', data: '0xallowances' }, + }), + ); + }); + + it('does not attach allowancesTx when hasAllowances is true', async () => { + const { provider, mockSigner } = setupAllowancesTxTest({ + hasAllowances: true, + }); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + allowancesTx: undefined, + }), + ); + }); + + it('does not attach allowancesTx when permit2 is disabled', async () => { + const { provider, mockSigner } = setupAllowancesTxTest({ + permit2Enabled: false, + executors: [], + }); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + allowancesTx: undefined, + }), + ); + expect(getProxyWalletAllowancesTransaction).not.toHaveBeenCalled(); + }); + + it('continues order placement when getProxyWalletAllowancesTransaction throws', async () => { + const { provider, mockSigner } = setupAllowancesTxTest(); + const preview = createMockOrderPreview({ + side: Side.BUY, + fees: { + metamaskFee: 0.02, + providerFee: 0.02, + totalFee: 0.04, + totalFeePercentage: 0.04, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + }); + (getProxyWalletAllowancesTransaction as jest.Mock).mockRejectedValue( + new Error('TX generation failed'), + ); + + const result = await provider.placeOrder({ preview, signer: mockSigner }); + + expect(result.success).toBe(true); + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + allowancesTx: undefined, + }), + ); + }); + + it('attaches allowancesTx for SELL orders', async () => { + const { provider, mockSigner } = setupAllowancesTxTest(); + const preview = createMockOrderPreview({ + side: Side.SELL, + fees: undefined, + }); + (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ + params: { to: '0xSafe', data: '0xallowances' }, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(getProxyWalletAllowancesTransaction).toHaveBeenCalled(); + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + allowancesTx: { to: '0xSafe', data: '0xallowances' }, + }), + ); + }); + }); + + describe('placeOrder FAK order type for sell orders', () => { + it('submits FAK order type for sell order without fees when FAK is enabled', async () => { + jest.clearAllMocks(); + const { provider, mockSigner } = setupPlaceOrderTest({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + fakOrdersEnabled: true, + }); + const preview = createMockOrderPreview({ + side: Side.SELL, + fees: undefined, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + clobOrder: expect.objectContaining({ orderType: 'FAK' }), + }), + ); + }); + + it('submits FOK order type for sell order without fees when FAK is disabled', async () => { + jest.clearAllMocks(); + const { provider, mockSigner } = setupPlaceOrderTest({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + fakOrdersEnabled: false, + }); + const preview = createMockOrderPreview({ + side: Side.SELL, + fees: undefined, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + clobOrder: expect.objectContaining({ orderType: 'FOK' }), + }), + ); + }); }); describe('placeOrder edge cases', () => { @@ -2463,13 +3037,12 @@ describe('PolymarketProvider', () => { }; it('get market details successfully', async () => { - const provider = createProvider(); + const provider = createProvider({ liveSportsLeagues: ['nfl'] }); mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); mockParsePolymarketEvents.mockReturnValue([mockParsedMarket]); const result = await provider.getMarketDetails({ marketId: 'market-1', - liveSportsLeagues: ['nfl'], }); expect(result).toEqual(mockParsedMarket); @@ -2643,10 +3216,9 @@ describe('PolymarketProvider', () => { expect(result[0].id).toBe('market-1'); }); - it('passes liveSportsLeagues to getMarketDetails for each market', async () => { - const provider = createProvider(); + it('calls getMarketDetails for each market id', async () => { + const provider = createProvider({ liveSportsLeagues: ['nfl'] }); const marketIds = ['market-1', 'market-2']; - const liveSportsLeagues = ['nfl']; const getMarketDetailsSpy = jest.spyOn(provider, 'getMarketDetails'); mockGetMarketDetailsFromGammaApi.mockImplementation(({ marketId }) => @@ -2656,22 +3228,20 @@ describe('PolymarketProvider', () => { events.map((event: { id: string }) => createMockParsedMarket(event.id)), ); - await provider.getMarketsByIds(marketIds, liveSportsLeagues); + await provider.getMarketsByIds(marketIds); expect(getMarketDetailsSpy).toHaveBeenCalledTimes(2); expect(getMarketDetailsSpy).toHaveBeenCalledWith({ marketId: 'market-1', - liveSportsLeagues: ['nfl'], }); expect(getMarketDetailsSpy).toHaveBeenCalledWith({ marketId: 'market-2', - liveSportsLeagues: ['nfl'], }); getMarketDetailsSpy.mockRestore(); }); - it('uses empty liveSportsLeagues by default', async () => { + it('calls getMarketDetails without extra params by default', async () => { const provider = createProvider(); const marketIds = ['market-1']; @@ -2687,7 +3257,6 @@ describe('PolymarketProvider', () => { expect(getMarketDetailsSpy).toHaveBeenCalledWith({ marketId: 'market-1', - liveSportsLeagues: [], }); getMarketDetailsSpy.mockRestore(); @@ -3609,6 +4178,27 @@ describe('PolymarketProvider', () => { expect(result.transactions[1].type).toBe('predictDeposit'); }); + it('passes Permit2 spender when creating allowance transaction and permit2Enabled is true', async () => { + const provider = createProvider({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + }, + }); + (isSmartContractAddress as jest.Mock).mockResolvedValue(true); + (hasAllowances as jest.Mock).mockResolvedValue(false); + (getProxyWalletAllowancesTransaction as jest.Mock).mockResolvedValue({ + params: { to: '0xSafe', data: '0xallowances' }, + }); + + await provider.prepareDeposit({ signer: mockSigner }); + + expect(getProxyWalletAllowancesTransaction).toHaveBeenCalledWith({ + signer: mockSigner, + extraUsdcSpenders: [PERMIT2_ADDRESS], + }); + }); + it('prepares only deposit transaction when wallet deployed and has allowances', async () => { // Given a fully set up wallet const provider = createProvider(); @@ -4025,6 +4615,25 @@ describe('PolymarketProvider', () => { ); expect(hasAllowances).toHaveBeenCalledWith({ address: '0xSafeAddress', + extraUsdcSpenders: [], + }); + }); + + it('passes Permit2 spender to hasAllowances when permit2Enabled is true', async () => { + const provider = createProvider({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + }, + }); + (isSmartContractAddress as jest.Mock).mockResolvedValue(true); + (hasAllowances as jest.Mock).mockResolvedValue(true); + + await provider.getAccountState({ ownerAddress: '0x123' }); + + expect(hasAllowances).toHaveBeenCalledWith({ + address: '0xSafeAddress', + extraUsdcSpenders: [PERMIT2_ADDRESS], }); }); @@ -6619,19 +7228,19 @@ describe('PolymarketProvider', () => { describe('provider interface properties', () => { it('exposes chainId property with value 137', () => { - const provider = new PolymarketProvider(); + const provider = createProvider(); expect(provider.chainId).toBe(137); }); it('exposes name property with value Polymarket', () => { - const provider = new PolymarketProvider(); + const provider = createProvider(); expect(provider.name).toBe('Polymarket'); }); it('exposes providerId property with value polymarket', () => { - const provider = new PolymarketProvider(); + const provider = createProvider(); expect(provider.providerId).toBe(POLYMARKET_PROVIDER_ID); }); @@ -6649,23 +7258,23 @@ describe('PolymarketProvider', () => { }); describe('getMarkets', () => { - it('applies GameCache overlay to fetched markets when liveSportsLeagues is provided', async () => { - const provider = new PolymarketProvider(); + it('applies GameCache overlay to fetched markets when live sports are enabled', async () => { + const provider = createProvider({ liveSportsLeagues: ['nfl'] }); const mockMarkets = [ { id: 'market-1', title: 'Test Market 1' }, { id: 'market-2', title: 'Test Market 2' }, ]; mockGetMarketsFromPolymarketApi.mockResolvedValue(mockMarkets); - await provider.getMarkets({ liveSportsLeagues: ['nfl'] }); + await provider.getMarkets(); expect(mockGameCacheInstance.overlayOnMarkets).toHaveBeenCalledWith( mockMarkets, ); }); - it('returns markets with cached game data overlay applied when liveSportsLeagues is provided', async () => { - const provider = new PolymarketProvider(); + it('returns markets with cached game data overlay applied when live sports are enabled', async () => { + const provider = createProvider({ liveSportsLeagues: ['nfl'] }); const mockMarkets = [{ id: 'market-1', title: 'Test Market' }]; const overlaidMarkets = [ { @@ -6677,22 +7286,18 @@ describe('PolymarketProvider', () => { mockGetMarketsFromPolymarketApi.mockResolvedValue(mockMarkets); mockGameCacheInstance.overlayOnMarkets.mockReturnValue(overlaidMarkets); - const result = await provider.getMarkets({ - liveSportsLeagues: ['nfl'], - }); + const result = await provider.getMarkets(); expect(result).toEqual(overlaidMarkets); }); it('returns empty array when API fails without calling GameCache overlay', async () => { - const provider = new PolymarketProvider(); + const provider = createProvider({ liveSportsLeagues: ['nfl'] }); mockGetMarketsFromPolymarketApi.mockRejectedValue( new Error('API error'), ); - const result = await provider.getMarkets({ - liveSportsLeagues: ['nfl'], - }); + const result = await provider.getMarkets(); expect(result).toEqual([]); expect(mockGameCacheInstance.overlayOnMarkets).not.toHaveBeenCalled(); @@ -6700,8 +7305,8 @@ describe('PolymarketProvider', () => { }); describe('getMarketDetails', () => { - it('applies GameCache overlay to fetched market details when liveSportsLeagues is provided', async () => { - const provider = new PolymarketProvider(); + it('applies GameCache overlay to fetched market details when live sports are enabled', async () => { + const provider = createProvider({ liveSportsLeagues: ['nfl'] }); const mockEvent = { id: 'market-1', question: 'Test Market?' }; const parsedMarket = { id: 'market-1', @@ -6711,18 +7316,15 @@ describe('PolymarketProvider', () => { mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - await provider.getMarketDetails({ - marketId: 'market-1', - liveSportsLeagues: ['nfl'], - }); + await provider.getMarketDetails({ marketId: 'market-1' }); expect(mockGameCacheInstance.overlayOnMarket).toHaveBeenCalledWith( parsedMarket, ); }); - it('returns market with cached game data overlay applied when liveSportsLeagues is provided', async () => { - const provider = new PolymarketProvider(); + it('returns market with cached game data overlay applied when live sports are enabled', async () => { + const provider = createProvider({ liveSportsLeagues: ['nfl'] }); const mockEvent = { id: 'market-1', question: 'Test Market?' }; const parsedMarket = { id: 'market-1', title: 'Test Market' }; const overlaidMarket = { @@ -6736,22 +7338,18 @@ describe('PolymarketProvider', () => { const result = await provider.getMarketDetails({ marketId: 'market-1', - liveSportsLeagues: ['nfl'], }); expect(result).toEqual(overlaidMarket); }); it('throws error when parsing fails without calling GameCache overlay', async () => { - const provider = new PolymarketProvider(); + const provider = createProvider({ liveSportsLeagues: ['nfl'] }); mockGetMarketDetailsFromGammaApi.mockResolvedValue({}); mockParsePolymarketEvents.mockReturnValue([]); await expect( - provider.getMarketDetails({ - marketId: 'market-1', - liveSportsLeagues: ['nfl'], - }), + provider.getMarketDetails({ marketId: 'market-1' }), ).rejects.toThrow('Failed to parse market details'); expect(mockGameCacheInstance.overlayOnMarket).not.toHaveBeenCalled(); }); @@ -6765,7 +7363,7 @@ describe('PolymarketProvider', () => { describe('subscribeToGameUpdates', () => { it('delegates to WebSocketManager.subscribeToGame', () => { - const provider = new PolymarketProvider(); + const provider = createProvider(); const mockCallback = jest.fn(); const mockUnsubscribe = jest.fn(); mockWebSocketManagerInstance.subscribeToGame.mockReturnValue( @@ -6784,7 +7382,7 @@ describe('PolymarketProvider', () => { }); it('returns unsubscribe function from WebSocketManager', () => { - const provider = new PolymarketProvider(); + const provider = createProvider(); const mockUnsubscribe = jest.fn(); mockWebSocketManagerInstance.subscribeToGame.mockReturnValue( mockUnsubscribe, @@ -6803,7 +7401,7 @@ describe('PolymarketProvider', () => { describe('subscribeToMarketPrices', () => { it('delegates to WebSocketManager.subscribeToMarketPrices', () => { - const provider = new PolymarketProvider(); + const provider = createProvider(); const mockCallback = jest.fn(); const mockUnsubscribe = jest.fn(); mockWebSocketManagerInstance.subscribeToMarketPrices.mockReturnValue( @@ -6822,7 +7420,7 @@ describe('PolymarketProvider', () => { }); it('returns unsubscribe function from WebSocketManager', () => { - const provider = new PolymarketProvider(); + const provider = createProvider(); const mockUnsubscribe = jest.fn(); mockWebSocketManagerInstance.subscribeToMarketPrices.mockReturnValue( mockUnsubscribe, @@ -6841,7 +7439,7 @@ describe('PolymarketProvider', () => { describe('getConnectionStatus', () => { it('returns connection status from WebSocketManager', () => { - const provider = new PolymarketProvider(); + const provider = createProvider(); mockWebSocketManagerInstance.getConnectionStatus.mockReturnValue({ sportsConnected: true, marketConnected: false, @@ -6858,7 +7456,7 @@ describe('PolymarketProvider', () => { }); it('maps WebSocketManager status to ConnectionStatus interface', () => { - const provider = new PolymarketProvider(); + const provider = createProvider(); mockWebSocketManagerInstance.getConnectionStatus.mockReturnValue({ sportsConnected: false, marketConnected: true, @@ -6878,50 +7476,47 @@ describe('PolymarketProvider', () => { }); }); - describe('Live sports disabled (empty liveSportsLeagues)', () => { + describe('Live sports disabled', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('getMarkets', () => { - it('skips TeamsCache loading when liveSportsLeagues is empty', async () => { - const provider = new PolymarketProvider(); + it('skips TeamsCache loading when live sports leagues are empty', async () => { + const provider = createProvider(); mockGetMarketsFromPolymarketApi.mockResolvedValue([]); - await provider.getMarkets({ liveSportsLeagues: [] }); + await provider.getMarkets(); expect( mockTeamsCacheInstance.ensureLeaguesLoaded, ).not.toHaveBeenCalled(); }); - it('skips GameCache overlay when liveSportsLeagues is empty', async () => { - const provider = new PolymarketProvider(); + it('skips GameCache overlay when live sports leagues are empty', async () => { + const provider = createProvider(); const mockMarkets = [{ id: 'market-1', title: 'Test Market' }]; mockGetMarketsFromPolymarketApi.mockResolvedValue(mockMarkets); - const result = await provider.getMarkets({ liveSportsLeagues: [] }); + const result = await provider.getMarkets(); expect(mockGameCacheInstance.overlayOnMarkets).not.toHaveBeenCalled(); expect(result).toEqual(mockMarkets); }); - it('does not pass teamLookup when liveSportsLeagues is empty', async () => { - const provider = new PolymarketProvider(); + it('does not pass teamLookup when live sports leagues are empty', async () => { + const provider = createProvider(); mockGetMarketsFromPolymarketApi.mockResolvedValue([]); - await provider.getMarkets({ - category: 'sports', - liveSportsLeagues: [], - }); + await provider.getMarkets({ category: 'sports' }); expect(mockGetMarketsFromPolymarketApi).toHaveBeenCalledWith( expect.objectContaining({ teamLookup: undefined }), ); }); - it('skips TeamsCache loading when liveSportsLeagues is undefined (default)', async () => { - const provider = new PolymarketProvider(); + it('skips TeamsCache loading when live sports config is defaulted', async () => { + const provider = createProvider(); mockGetMarketsFromPolymarketApi.mockResolvedValue([]); await provider.getMarkets(); @@ -6933,25 +7528,22 @@ describe('PolymarketProvider', () => { }); describe('getMarketDetails', () => { - it('skips TeamsCache loading when liveSportsLeagues is empty', async () => { - const provider = new PolymarketProvider(); + it('skips TeamsCache loading when live sports leagues are empty', async () => { + const provider = createProvider(); const mockEvent = { id: 'market-1', question: 'Test?' }; const parsedMarket = { id: 'market-1', title: 'Test' }; mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - await provider.getMarketDetails({ - marketId: 'market-1', - liveSportsLeagues: [], - }); + await provider.getMarketDetails({ marketId: 'market-1' }); expect( mockTeamsCacheInstance.ensureLeaguesLoaded, ).not.toHaveBeenCalled(); }); - it('skips GameCache overlay when liveSportsLeagues is empty', async () => { - const provider = new PolymarketProvider(); + it('skips GameCache overlay when live sports leagues are empty', async () => { + const provider = createProvider(); const mockEvent = { id: 'market-1', question: 'Test?' }; const parsedMarket = { id: 'market-1', title: 'Test' }; mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); @@ -6959,24 +7551,20 @@ describe('PolymarketProvider', () => { const result = await provider.getMarketDetails({ marketId: 'market-1', - liveSportsLeagues: [], }); expect(mockGameCacheInstance.overlayOnMarket).not.toHaveBeenCalled(); expect(result).toEqual(parsedMarket); }); - it('does not pass teamLookup when liveSportsLeagues is empty', async () => { - const provider = new PolymarketProvider(); + it('does not pass teamLookup when live sports leagues are empty', async () => { + const provider = createProvider(); const mockEvent = { id: 'market-1', question: 'Test?' }; const parsedMarket = { id: 'market-1', title: 'Test' }; mockGetMarketDetailsFromGammaApi.mockResolvedValue(mockEvent); mockParsePolymarketEvents.mockReturnValue([parsedMarket]); - await provider.getMarketDetails({ - marketId: 'market-1', - liveSportsLeagues: [], - }); + await provider.getMarketDetails({ marketId: 'market-1' }); expect(mockParsePolymarketEvents).toHaveBeenCalledWith( [mockEvent], diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 62a8ec9924c9..9ea907de8259 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -63,8 +63,10 @@ import { ROUNDING_CONFIG, SAFE_EXEC_GAS_LIMIT, } from './constants'; +import { PERMIT2_ADDRESS } from './safe/constants'; import { computeProxyAddress, + createPermit2FeeAuthorization, createSafeFeeAuthorization, getClaimTransaction, getDeployProxyWalletTransaction, @@ -73,6 +75,7 @@ import { getWithdrawTransactionCallData, hasAllowances, } from './safe/utils'; +import { Permit2FeeAuthorization, SafeFeeAuthorization } from './safe/types'; import { ApiKeyCreds, OrderData, @@ -101,7 +104,7 @@ import { roundOrderAmount, submitClobOrder, } from './utils'; -import { PredictFeeCollection } from '../../types/flags'; +import { PredictFeatureFlags } from '../../types/flags'; import { GameCache } from './GameCache'; import { TeamsCache } from './TeamsCache'; import { WebSocketManager } from './WebSocketManager'; @@ -135,6 +138,7 @@ export class PolymarketProvider implements PredictProvider { readonly providerId = POLYMARKET_PROVIDER_ID; readonly name = 'Polymarket'; readonly chainId = POLYGON_MAINNET_CHAIN_ID; + readonly #getFeatureFlags: () => PredictFeatureFlags; #apiKeysByAddress: Map = new Map(); #accountStateByAddress: Map = new Map(); @@ -147,6 +151,14 @@ export class PolymarketProvider implements PredictProvider { private static readonly FALLBACK_CATEGORY: PredictCategory = 'trending'; + constructor({ + getFeatureFlags, + }: { + getFeatureFlags: () => PredictFeatureFlags; + }) { + this.#getFeatureFlags = getFeatureFlags; + } + /** * Generate standard error context for Logger.error calls with searchable tags and context. * Enables Sentry dashboard filtering by feature and provider. @@ -174,18 +186,43 @@ export class PolymarketProvider implements PredictProvider { }; } + #hasPermit2Config(params: { + permit2Enabled?: boolean; + executors?: string[]; + }): boolean { + return ( + params.permit2Enabled === true && + Array.isArray(params.executors) && + params.executors.length > 0 + ); + } + + #shouldUseFakOrderType({ + permit2Enabled, + executors, + fakOrdersEnabled, + }: { + permit2Enabled?: boolean; + executors?: string[]; + fakOrdersEnabled: boolean; + }): boolean { + return ( + this.#hasPermit2Config({ permit2Enabled, executors }) && + fakOrdersEnabled === true + ); + } + public async getMarketDetails({ marketId, - liveSportsLeagues = [], }: { marketId: string; - liveSportsLeagues?: string[]; }): Promise { if (!marketId) { throw new Error('marketId is required'); } try { + const { liveSportsLeagues } = this.#getFeatureFlags(); const event = await getMarketDetailsFromGammaApi({ marketId, }); @@ -223,25 +260,20 @@ export class PolymarketProvider implements PredictProvider { } } - public async getMarketsByIds( - marketIds: string[], - liveSportsLeagues: string[] = [], - ): Promise { + public async getMarketsByIds(marketIds: string[]): Promise { if (!marketIds || marketIds.length === 0) { return []; } try { const marketPromises = marketIds.map((marketId) => - this.getMarketDetails({ marketId, liveSportsLeagues }).catch( - (error) => { - DevLogger.log( - `PolymarketProvider: Failed to fetch market ${marketId}`, - error, - ); - return null; - }, - ), + this.getMarketDetails({ marketId }).catch((error) => { + DevLogger.log( + `PolymarketProvider: Failed to fetch market ${marketId}`, + error, + ); + return null; + }), ); const results = await Promise.all(marketPromises); @@ -301,7 +333,7 @@ export class PolymarketProvider implements PredictProvider { public async getMarkets(params?: GetMarketsParams): Promise { try { - const liveSportsLeagues = params?.liveSportsLeagues ?? []; + const { liveSportsLeagues } = this.#getFeatureFlags(); const liveSportsEnabled = liveSportsLeagues.length > 0; if (liveSportsEnabled) { @@ -968,21 +1000,38 @@ export class PolymarketProvider implements PredictProvider { public async previewOrder( params: PreviewOrderParams & { signer: Signer; - feeCollection?: PredictFeeCollection; }, ): Promise { - const basePreview = await previewOrder(params); + const { feeCollection, fakOrdersEnabled } = this.#getFeatureFlags(); + const basePreview = await previewOrder({ ...params, feeCollection }); + + // Determine intended order type from feature flags. + // FAK is used when Permit2 config is active and FAK orders are enabled. + // placeOrder() guarantees the Permit2 allowance is set before submission, + // so we can always use FAK when the config allows it. + let orderType = OrderType.FOK; + + if ( + this.#shouldUseFakOrderType({ + permit2Enabled: feeCollection.permit2Enabled, + executors: feeCollection.executors, + fakOrdersEnabled, + }) + ) { + orderType = OrderType.FAK; + } if (params.signer) { if (this.isRateLimited(params.signer.address)) { return { ...basePreview, + orderType, rateLimited: true, }; } } - return basePreview; + return { ...basePreview, orderType }; } public async placeOrder( @@ -1136,10 +1185,123 @@ export class PolymarketProvider implements PredictProvider { const signerApiKey = await this.getApiKey({ address: signer.address }); + // Determine fees, permit2, and order type BEFORE building clobOrder + // so the HMAC signature covers the correct orderType. + const { feeCollection, fakOrdersEnabled } = this.#getFeatureFlags(); + const shouldUsePermit2 = this.#hasPermit2Config({ + permit2Enabled: fees?.permit2Enabled, + executors: fees?.executors, + }); + + let feeAuthorization: + | SafeFeeAuthorization + | Permit2FeeAuthorization + | undefined; + let executor: string | undefined; + let orderType: OrderType = OrderType.FOK; + let permit2FeeReady = false; + + if (fees !== undefined && fees.totalFee > 0) { + const safeAddress = computeProxyAddress(signer.address); + const feeAmountInUsdc = BigInt( + parseUnits(fees.totalFee.toString(), 6).toString(), + ); + + if (shouldUsePermit2) { + // Always use Permit2 fee authorization when permit2 is enabled. + // The relay will submit the allowancesTx on-chain first (if + // attached) before redeeming the Permit2 authorization. + permit2FeeReady = true; + const executors = fees.executors ?? []; + const randomIndex = new Uint32Array(1); + global.crypto.getRandomValues(randomIndex); + executor = executors[randomIndex[0] % executors.length]; + feeAuthorization = await createPermit2FeeAuthorization({ + safeAddress, + signer, + amount: feeAmountInUsdc, + spender: executor, + }); + } else { + feeAuthorization = await createSafeFeeAuthorization({ + safeAddress, + signer, + amount: feeAmountInUsdc, + to: fees.collector, + }); + } + } + + let allowancesTx: { to: string; data: string } | undefined; + let permit2AllowanceReady = false; + + // When Permit2 is enabled via feature flags, ensure the proxy wallet + // has the required allowances. If not, generate a signed Safe TX that + // the relay will submit on-chain before processing the order. + // This uses the feature flag (not per-order fees) so it works for + // both BUY orders (with fees) and SELL orders (no fees). + // + // IMPORTANT: Skip when a Safe fee authorization was already signed, + // because both transactions read the same on-chain Safe nonce and + // the relay would invalidate one when executing the other. + const hasSafeFeeAuth = feeAuthorization !== undefined && !permit2FeeReady; + + if (feeCollection.permit2Enabled && !hasSafeFeeAuth) { + try { + const accountState = await this.getAccountState({ + ownerAddress: signer.address, + }); + + if (accountState.hasAllowances) { + permit2AllowanceReady = true; + } else { + const allowanceTx = await getProxyWalletAllowancesTransaction({ + signer, + extraUsdcSpenders: [PERMIT2_ADDRESS], + }); + + allowancesTx = allowanceTx.params; + permit2AllowanceReady = true; + } + } catch (allowanceError) { + // Log but don't block the order — the relay will fall back + // to FOK if FAK isn't viable without allowances. + DevLogger.log( + 'PolymarketProvider: Failed to generate allowances transaction', + { error: allowanceError }, + ); + Logger.error( + allowanceError instanceof Error + ? allowanceError + : new Error(String(allowanceError)), + this.getErrorContext('placeOrder:allowancesTx', { + operation: 'generate_allowances_tx', + }), + ); + } + } + + // Determine order type: FAK when Permit2 config is active and + // FAK orders are enabled. For orders with fees, the relay also + // needs Permit2 allowances (either already on-chain or via + // the attached allowancesTx). + if ( + this.#shouldUseFakOrderType({ + permit2Enabled: feeCollection.permit2Enabled, + executors: feeCollection.executors, + fakOrdersEnabled, + }) + ) { + const hasFees = fees !== undefined && fees.totalFee > 0; + if (!hasFees || (permit2FeeReady && permit2AllowanceReady)) { + orderType = OrderType.FAK; + } + } + const clobOrder = { order: { ...signedOrder, side, salt: parseInt(signedOrder.salt) }, owner: signerApiKey.apiKey, - orderType: OrderType.FOK, + orderType, }; const body = JSON.stringify(clobOrder); @@ -1154,24 +1316,12 @@ export class PolymarketProvider implements PredictProvider { apiKey: signerApiKey, }); - let feeAuthorization; - if (fees !== undefined && fees.totalFee > 0) { - const safeAddress = computeProxyAddress(signer.address); - const feeAmountInUsdc = BigInt( - parseUnits(fees.totalFee.toString(), 6).toString(), - ); - feeAuthorization = await createSafeFeeAuthorization({ - safeAddress, - signer, - amount: feeAmountInUsdc, - to: fees.collector, - }); - } - const { success, response, error } = await submitClobOrder({ headers, clobOrder, feeAuthorization, + executor, + allowancesTx, }); if (!success) { @@ -1464,8 +1614,13 @@ export class PolymarketProvider implements PredictProvider { } if (!accountState.hasAllowances) { + const { feeCollection: depositFeeCollection } = this.#getFeatureFlags(); + const extraUsdcSpenders = depositFeeCollection.permit2Enabled + ? [PERMIT2_ADDRESS] + : []; const allowanceTransaction = await getProxyWalletAllowancesTransaction({ signer, + extraUsdcSpenders, }); if (!allowanceTransaction) { @@ -1537,13 +1692,17 @@ export class PolymarketProvider implements PredictProvider { // Check deployment status and allowances let isDeployed: boolean; let hasAllowancesResult: boolean; + const { feeCollection: flagFeeCollection } = this.#getFeatureFlags(); + const extraUsdcSpenders = flagFeeCollection.permit2Enabled + ? [PERMIT2_ADDRESS] + : []; try { [isDeployed, hasAllowancesResult] = await Promise.all([ isSmartContractAddress( address, numberToHex(POLYGON_MAINNET_CHAIN_ID), ), - hasAllowances({ address }), + hasAllowances({ address, extraUsdcSpenders }), ]); } catch (error) { throw new Error( diff --git a/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts b/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts index 3efb0e13f79c..48baaf0f31d3 100644 --- a/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts +++ b/app/components/UI/Predict/providers/polymarket/WebSocketManager.test.ts @@ -217,6 +217,7 @@ describe('WebSocketManager', () => { manager.subscribeToGame('123', callback); mockWebSocketInstances[0].simulateOpen(); + mockGameCacheInstance.updateGame.mockClear(); mockWebSocketInstances[0].simulateMessage({ gameId: 456, score: '7-0', @@ -229,6 +230,25 @@ describe('WebSocketManager', () => { expect(callback).not.toHaveBeenCalled(); }); + it('does not update cache for unrelated gameId', () => { + const manager = WebSocketManager.getInstance(); + const callback = jest.fn(); + + manager.subscribeToGame('123', callback); + mockWebSocketInstances[0].simulateOpen(); + mockGameCacheInstance.updateGame.mockClear(); + mockWebSocketInstances[0].simulateMessage({ + gameId: 456, + score: '7-0', + elapsed: '05:00', + period: 'Q1', + live: true, + ended: false, + }); + + expect(mockGameCacheInstance.updateGame).not.toHaveBeenCalled(); + }); + it('calls multiple callbacks for same gameId', () => { const manager = WebSocketManager.getInstance(); const callback1 = jest.fn(); diff --git a/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts b/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts index 253422feb33c..35b3530f0e2f 100644 --- a/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts +++ b/app/components/UI/Predict/providers/polymarket/WebSocketManager.ts @@ -161,6 +161,11 @@ export class WebSocketManager { const data: SportsWebSocketEvent = JSON.parse(event.data); const gameId = String(data.gameId); + const callbacks = this.gameSubscriptions.get(gameId); + if (!callbacks || callbacks.size === 0) { + return; + } + const update: GameUpdate = { gameId, score: data.score, @@ -171,11 +176,7 @@ export class WebSocketManager { }; GameCache.getInstance().updateGame(gameId, update); - - const callbacks = this.gameSubscriptions.get(gameId); - if (callbacks && callbacks.size > 0) { - callbacks.forEach((callback) => callback(update)); - } + callbacks.forEach((callback) => callback(update)); } catch (error) { DevLogger.log('WebSocketManager: Failed to parse sports message', { error, diff --git a/app/components/UI/Predict/providers/polymarket/safe/constants.ts b/app/components/UI/Predict/providers/polymarket/safe/constants.ts index c442b6c97221..a7c626616226 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/constants.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/constants.ts @@ -11,8 +11,11 @@ export const SAFE_MULTISEND_ADDRESS = // Constants from the contract export const SAFE_TX_TYPEHASH = '0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8'; +export const SAFE_MSG_TYPEHASH = + '0x60b3cbf8b4a223d68d641b3b6ddf9a298e7f33710cf3d3a9d1146b5a6150fbca'; export const DOMAIN_SEPARATOR_TYPEHASH = '0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218'; +export const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3'; export const usdcSpenders = [ MATIC_CONTRACTS.conditionalTokens, // Conditional Tokens Framework diff --git a/app/components/UI/Predict/providers/polymarket/safe/types.ts b/app/components/UI/Predict/providers/polymarket/safe/types.ts index 994d66ca6a54..60ff992776ac 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/types.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/types.ts @@ -23,3 +23,16 @@ export interface SafeFeeAuthorization { sig: string; // Signature of the Safe transaction }; } + +export interface Permit2FeeAuthorization { + type: 'safe-permit2'; + authorization: { + permit: { + permitted: { token: string; amount: string }; + nonce: string; + deadline: string; + }; + spender: string; + signature: string; + }; +} diff --git a/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts b/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts index 010bce4c6e0c..b9c8f65eedb8 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/utils.test.ts @@ -5,10 +5,17 @@ import { POLYGON_MAINNET_CHAIN_ID, POLYMARKET_PROVIDER_ID, } from '../constants'; -import { SAFE_FACTORY_ADDRESS, SAFE_MULTISEND_ADDRESS } from './constants'; +import { + PERMIT2_ADDRESS, + SAFE_FACTORY_ADDRESS, + SAFE_MULTISEND_ADDRESS, + usdcSpenders, +} from './constants'; import { computeProxyAddress, + createPermit2FeeAuthorization, createSafeFeeAuthorization, + getPermit2Nonce, getDeployProxyWalletTypedData, encodeCreateProxy, getDeployProxyWalletTransaction, @@ -18,6 +25,7 @@ import { aggregateTransaction, createAllowancesSafeTransaction, hasAllowances, + hasPermit2Allowance, createClaimSafeTransaction, getSafeTransactionCallData, getProxyWalletAllowancesTransaction, @@ -405,6 +413,72 @@ describe('safe utils', () => { }); }); + describe('getPermit2Nonce', () => { + it('returns a numeric string', async () => { + const nonce = await getPermit2Nonce(); + + expect(nonce).toMatch(/^\d+$/); + }); + + it('generates nonce from crypto.getRandomValues', async () => { + const spy = jest.spyOn(global.crypto, 'getRandomValues'); + + await getPermit2Nonce(); + + expect(spy).toHaveBeenCalledWith(expect.any(Uint32Array)); + spy.mockRestore(); + }); + }); + + describe('hasPermit2Allowance', () => { + it('returns true when Permit2 allowance is greater than zero', async () => { + mockGetAllowance.mockResolvedValueOnce(1n); + + const result = await hasPermit2Allowance({ address: TEST_SAFE_ADDRESS }); + + expect(result).toBe(true); + expect(mockGetAllowance).toHaveBeenCalledWith({ + tokenAddress: MATIC_CONTRACTS.collateral, + owner: TEST_SAFE_ADDRESS, + spender: PERMIT2_ADDRESS, + }); + }); + + it('returns false when Permit2 allowance is zero', async () => { + mockGetAllowance.mockResolvedValueOnce(0n); + + const result = await hasPermit2Allowance({ address: TEST_SAFE_ADDRESS }); + + expect(result).toBe(false); + }); + }); + + describe('createPermit2FeeAuthorization', () => { + it('creates safe-permit2 authorization payload', async () => { + mockSignPersonalMessage.mockResolvedValue( + '0xaabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff0011223344556677889900', + ); + + const authorization = await createPermit2FeeAuthorization({ + safeAddress: TEST_SAFE_ADDRESS, + signer: buildSigner(), + amount: 1_000_000n, + spender: TEST_TO_ADDRESS, + }); + + expect(authorization.type).toBe('safe-permit2'); + expect(authorization.authorization.permit.permitted.token).toBe( + MATIC_CONTRACTS.collateral, + ); + expect(authorization.authorization.permit.permitted.amount).toBe( + '1000000', + ); + expect(authorization.authorization.permit.nonce).toMatch(/^\d+$/); + expect(authorization.authorization.spender).toBe(TEST_TO_ADDRESS); + expect(authorization.authorization.signature).toMatch(/^0x[a-f0-9]+$/); + }); + }); + describe('getDeployProxyWalletTypedData', () => { it('returns correct typed data structure', async () => { const typedData = await getDeployProxyWalletTypedData(); @@ -660,6 +734,18 @@ describe('safe utils', () => { expect(safeTxn.data).toBeDefined(); expect(typeof safeTxn.data).toBe('string'); }); + + it('includes extra USDC spenders when provided', () => { + const defaultSafeTxn = createAllowancesSafeTransaction(); + const safeTxnWithExtra = createAllowancesSafeTransaction({ + extraUsdcSpenders: [PERMIT2_ADDRESS], + }); + + expect(safeTxnWithExtra.data).toBeDefined(); + expect(safeTxnWithExtra.data.length).toBeGreaterThan( + defaultSafeTxn.data.length, + ); + }); }); describe('hasAllowances', () => { @@ -693,6 +779,22 @@ describe('safe utils', () => { expect(result).toBe(false); }); + + it('checks allowances for extra USDC spenders', async () => { + mockGetAllowance.mockResolvedValue(100n); + mockGetIsApprovedForAll.mockResolvedValue(true); + + const result = await hasAllowances({ + address: TEST_ADDRESS, + extraUsdcSpenders: [PERMIT2_ADDRESS], + }); + + expect(result).toBe(true); + expect(mockGetAllowance).toHaveBeenCalledWith( + expect.objectContaining({ spender: PERMIT2_ADDRESS }), + ); + expect(mockGetAllowance).toHaveBeenCalledTimes(usdcSpenders.length + 1); + }); }); describe('createClaimSafeTransaction', () => { diff --git a/app/components/UI/Predict/providers/polymarket/safe/utils.ts b/app/components/UI/Predict/providers/polymarket/safe/utils.ts index b85e2a7002c3..6e121b739016 100644 --- a/app/components/UI/Predict/providers/polymarket/safe/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/safe/utils.ts @@ -44,15 +44,18 @@ import { DOMAIN_SEPARATOR_TYPEHASH, MASTER_COPY_ADDRESS, outcomeTokenSpenders, + PERMIT2_ADDRESS, PROXY_CREATION_CODE, SAFE_FACTORY_ADDRESS, SAFE_FACTORY_NAME, + SAFE_MSG_TYPEHASH, SAFE_MULTISEND_ADDRESS, SAFE_TX_TYPEHASH, usdcSpenders, } from './constants'; import { OperationType, + Permit2FeeAuthorization, SafeFeeAuthorization, SafeTransaction, SplitSignature, @@ -171,6 +174,18 @@ const getNonce = async ({ return BigInt(res); }; +/** + * Generate a random Permit2 nonce. Uses a random 32-bit value instead of + * reading the on-chain nonce bitmap to avoid an RPC round-trip and prevent + * nonce collisions on back-to-back orders whose fee collection hasn't + * settled on-chain yet. + */ +export const getPermit2Nonce = async (): Promise => { + const arr = new Uint32Array(1); + global.crypto.getRandomValues(arr); + return arr[0].toString(); +}; + const getTransactionHash = ({ safeAddress, to, @@ -313,6 +328,91 @@ export const createSafeFeeAuthorization = async ({ }; }; +export const createPermit2FeeAuthorization = async ({ + safeAddress, + signer, + amount, + spender, +}: { + safeAddress: Hex; + signer: Signer; + amount: bigint; + spender: string; +}): Promise => { + const nonce = await getPermit2Nonce(); + const deadline = (Math.floor(Date.now() / 1000) + 3600).toString(); + const token = MATIC_CONTRACTS.collateral; + + const domain = { + name: 'Permit2', + chainId: POLYGON_MAINNET_CHAIN_ID, + verifyingContract: PERMIT2_ADDRESS, + }; + const types = { + PermitTransferFrom: [ + { name: 'permitted', type: 'TokenPermissions' }, + { name: 'spender', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + TokenPermissions: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + }; + const message = { + permitted: { token, amount: amount.toString() }, + spender, + nonce, + deadline, + }; + + const { _TypedDataEncoder } = ethers.utils; + const permit2Hash = _TypedDataEncoder.hash(domain, types, message); + + const safeDomainSeparator = keccak256( + new AbiCoder().encode( + ['bytes32', 'uint256', 'address'], + [DOMAIN_SEPARATOR_TYPEHASH, POLYGON_MAINNET_CHAIN_ID, safeAddress], + ), + ); + + const encodedPermit2Hash = new AbiCoder().encode(['bytes32'], [permit2Hash]); + const safeMessageHash = keccak256( + new AbiCoder().encode( + ['bytes32', 'bytes32'], + [SAFE_MSG_TYPEHASH, keccak256(encodedPermit2Hash)], + ), + ); + + const finalHash = keccak256( + solidityPack( + ['bytes1', 'bytes1', 'bytes32', 'bytes32'], + ['0x19', '0x01', safeDomainSeparator, safeMessageHash], + ), + ) as Hex; + + const rsvSignature = await signTransactionHash(signer, finalHash); + const signature = abiEncodePacked( + { type: 'uint256', value: rsvSignature.r }, + { type: 'uint256', value: rsvSignature.s }, + { type: 'uint8', value: rsvSignature.v }, + ); + + return { + type: 'safe-permit2', + authorization: { + permit: { + permitted: { token, amount: amount.toString() }, + nonce, + deadline, + }, + spender, + signature, + }, + }; +}; + export const getDeployProxyWalletTypedData = () => { const domain = { name: SAFE_FACTORY_NAME, @@ -482,10 +582,16 @@ export const aggregateTransaction = ( return transaction; }; -export const createAllowancesSafeTransaction = () => { +export const createAllowancesSafeTransaction = (options?: { + extraUsdcSpenders?: string[]; +}) => { const safeTxns: SafeTransaction[] = []; + const allUsdcSpenders = [ + ...usdcSpenders, + ...(options?.extraUsdcSpenders ?? []), + ]; - for (const spender of usdcSpenders) { + for (const spender of allUsdcSpenders) { safeTxns.push({ to: MATIC_CONTRACTS.collateral, data: encodeApprove({ @@ -576,12 +682,14 @@ export const getSafeTransactionCallData = async ({ export const getProxyWalletAllowancesTransaction = async ({ signer, + extraUsdcSpenders, }: { signer: Signer; + extraUsdcSpenders?: string[]; }) => { try { const safeAddress = computeProxyAddress(signer.address); - const safeTxn = createAllowancesSafeTransaction(); + const safeTxn = createAllowancesSafeTransaction({ extraUsdcSpenders }); const callData = await getSafeTransactionCallData({ signer, safeAddress, @@ -624,10 +732,17 @@ export const getProxyWalletAllowancesTransaction = async ({ } }; -export const hasAllowances = async ({ address }: { address: string }) => { +export const hasAllowances = async ({ + address, + extraUsdcSpenders = [], +}: { + address: string; + extraUsdcSpenders?: string[]; +}) => { const allowanceCalls = []; const isApprovedForAllCalls = []; - for (const spender of usdcSpenders) { + const allUsdcSpenders = [...usdcSpenders, ...extraUsdcSpenders]; + for (const spender of allUsdcSpenders) { allowanceCalls.push( getAllowance({ tokenAddress: MATIC_CONTRACTS.collateral, @@ -652,6 +767,19 @@ export const hasAllowances = async ({ address }: { address: string }) => { ); }; +export const hasPermit2Allowance = async ({ + address, +}: { + address: string; +}): Promise => { + const allowance = await getAllowance({ + tokenAddress: MATIC_CONTRACTS.collateral, + owner: address, + spender: PERMIT2_ADDRESS, + }); + return allowance > 0; +}; + export const createClaimSafeTransaction = ( positions: PredictPosition[], includeTransfer?: { diff --git a/app/components/UI/Predict/providers/polymarket/types.ts b/app/components/UI/Predict/providers/polymarket/types.ts index 3116bf791e1a..21d5863203a2 100644 --- a/app/components/UI/Predict/providers/polymarket/types.ts +++ b/app/components/UI/Predict/providers/polymarket/types.ts @@ -1,5 +1,5 @@ import { PredictGamePeriod, Side } from '../../types'; -import { SafeFeeAuthorization } from './safe/types'; +import { Permit2FeeAuthorization, SafeFeeAuthorization } from './safe/types'; export interface PolymarketPosition { conditionId: string; @@ -119,7 +119,7 @@ export type ClobHeaders = { export interface PolymarketOffchainTradeParams { clobOrder: ClobOrderObject; headers: ClobHeaders; - feeAuthorization?: SafeFeeAuthorization; + feeAuthorization?: SafeFeeAuthorization | Permit2FeeAuthorization; } // Polymarket API response types diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts index 5839e5d15205..311a40ec4e7b 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts @@ -976,6 +976,81 @@ describe('polymarket utils', () => { }, ); }); + + it('includes executor in request body when provided', async () => { + await submitClobOrder({ + headers: mockHeaders, + clobOrder: mockClobOrder, + executor: '0x1111111111111111111111111111111111111111', + }); + + const callArgs = mockFetch.mock.calls[0]; + const bodyString = callArgs[1].body; + const parsedBody = JSON.parse(bodyString); + + expect(parsedBody.executor).toBe( + '0x1111111111111111111111111111111111111111', + ); + }); + + it('supports Permit2 fee authorization payload in request body', async () => { + const feeAuthorization = { + type: 'safe-permit2' as const, + authorization: { + permit: { + permitted: { + token: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + amount: '1000000', + }, + nonce: '0', + deadline: '1700000000', + }, + spender: '0x1111111111111111111111111111111111111111', + signature: '0xabc', + }, + }; + + await submitClobOrder({ + headers: mockHeaders, + clobOrder: mockClobOrder, + feeAuthorization, + }); + + const callArgs = mockFetch.mock.calls[0]; + const bodyString = callArgs[1].body; + const parsedBody = JSON.parse(bodyString); + + expect(parsedBody.feeAuthorization).toEqual(feeAuthorization); + }); + + it('includes allowancesTx in request body when provided', async () => { + const allowancesTx = { to: '0xSafeAddress', data: '0xallowanceData' }; + + await submitClobOrder({ + headers: mockHeaders, + clobOrder: mockClobOrder, + allowancesTx, + }); + + const callArgs = mockFetch.mock.calls[0]; + const bodyString = callArgs[1].body; + const parsedBody = JSON.parse(bodyString); + + expect(parsedBody.allowancesTx).toEqual(allowancesTx); + }); + + it('omits allowancesTx from request body when not provided', async () => { + await submitClobOrder({ + headers: mockHeaders, + clobOrder: mockClobOrder, + }); + + const callArgs = mockFetch.mock.calls[0]; + const bodyString = callArgs[1].body; + const parsedBody = JSON.parse(bodyString); + + expect(parsedBody).not.toHaveProperty('allowancesTx'); + }); }); describe('parsePolymarketEvents', () => { @@ -2787,6 +2862,8 @@ describe('polymarket utils', () => { expect(fees.metamaskFee).toBe(expectedMetamaskFee); expect(fees.totalFeePercentage).toBe(totalFeePercentage); expect(fees.collector).toBe(feeCollection.collector); + expect(fees.executors).toEqual(feeCollection.executors ?? []); + expect(fees.permit2Enabled).toBe(feeCollection.permit2Enabled ?? false); }); it('calculates fees correctly for various amounts', async () => { @@ -2863,6 +2940,8 @@ describe('polymarket utils', () => { expect(fees.totalFee).toBe(0); expect(fees.totalFeePercentage).toBe(0); expect(fees.collector).toBe('0x0'); + expect(fees.executors).toEqual([]); + expect(fees.permit2Enabled).toBe(false); }); it('waives fees for markets in waiveList', async () => { @@ -2893,6 +2972,27 @@ describe('polymarket utils', () => { expect(fees.totalFee).toBe(0); expect(fees.totalFeePercentage).toBe(0); expect(fees.collector).toBe('0x0'); + expect(fees.executors).toEqual([]); + expect(fees.permit2Enabled).toBe(false); + }); + + it('returns executors and permit2Enabled from feeCollection config', async () => { + const params = { + feeCollection: { + ...feeCollection, + executors: ['0x1111111111111111111111111111111111111111'], + permit2Enabled: true, + }, + marketId: 'market-1', + userBetAmount: 100, + }; + + const fees = await calculateFees(params); + + expect(fees.executors).toEqual([ + '0x1111111111111111111111111111111111111111', + ]); + expect(fees.permit2Enabled).toBe(true); }); }); diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index c94f9b590df4..a881b50aa4a9 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -45,7 +45,7 @@ import { SLIPPAGE_SELL, POLYMARKET_PROVIDER_ID, } from './constants'; -import { SafeFeeAuthorization } from './safe/types'; +import { Permit2FeeAuthorization, SafeFeeAuthorization } from './safe/types'; import { ApiKeyCreds, ClobHeaders, @@ -394,16 +394,26 @@ export const submitClobOrder = async ({ headers, clobOrder, feeAuthorization, + executor, + allowancesTx, }: { headers: ClobHeaders; clobOrder: ClobOrderObject; - feeAuthorization?: SafeFeeAuthorization; + feeAuthorization?: SafeFeeAuthorization | Permit2FeeAuthorization; + executor?: string; + allowancesTx?: { to: string; data: string }; }): Promise> => { const { CLOB_RELAYER } = getPolymarketEndpoints(); const url = `${CLOB_RELAYER}/order`; - const body: ClobOrderObject & { feeAuthorization?: SafeFeeAuthorization } = { + const body: ClobOrderObject & { + feeAuthorization?: SafeFeeAuthorization | Permit2FeeAuthorization; + executor?: string; + allowancesTx?: { to: string; data: string }; + } = { ...clobOrder, feeAuthorization, + ...(executor && { executor }), + ...(allowancesTx && { allowancesTx }), }; // For our relayer, we need to replace the underscores with dashes @@ -734,7 +744,7 @@ export const parsePolymarketEvents = ( recurrence: getRecurrence(event.series), endDate: event.endDate, category, - tags: tags.map((t) => t.label), + tags: tags.map((t) => t.slug), outcomes: markets.map((market: PolymarketApiMarket) => parsePolymarketMarket(market, event), ), @@ -1069,7 +1079,7 @@ async function waiveFees({ const market = await getMarketDetailsFromGammaApi({ marketId }); const { tags } = market; const slugs = tags?.map((t) => t.slug); - return slugs?.some((slug) => waiveList.includes(slug)) ?? false; + return slugs?.some((slug) => waiveList?.includes(slug)) ?? false; } export async function calculateFees({ @@ -1091,21 +1101,19 @@ export async function calculateFees({ totalFee: 0, totalFeePercentage: 0, collector: '0x0', + executors: [], + permit2Enabled: false, }; } const totalFeePercentage = (feeCollection.metamaskFee + feeCollection.providerFee) * 100; - let metamaskFee = userBetAmount * feeCollection.metamaskFee; - let providerFee = userBetAmount * feeCollection.providerFee; + const metamaskFee = userBetAmount * feeCollection.metamaskFee; + const providerFee = userBetAmount * feeCollection.providerFee; - // Round to 3 decimals - metamaskFee = Math.round(metamaskFee * 1000) / 1000; - providerFee = Math.round(providerFee * 1000) / 1000; - - // Rounded to 4 decimals - const totalFee = metamaskFee + providerFee; + // Rounded to 6 decimals + const totalFee = Math.round((metamaskFee + providerFee) * 1000000) / 1000000; return { metamaskFee, @@ -1113,6 +1121,8 @@ export async function calculateFees({ totalFee, totalFeePercentage, collector: feeCollection.collector, + executors: feeCollection.executors ?? [], + permit2Enabled: feeCollection.permit2Enabled ?? false, }; } @@ -1506,7 +1516,7 @@ export const previewOrder = async ( fees: await calculateFees({ feeCollection, marketId, - userBetAmount: size, + userBetAmount: makerAmount, }), }; } diff --git a/app/components/UI/Predict/providers/types.ts b/app/components/UI/Predict/providers/types.ts index ace26d8cd6dc..0d15ba18cd5a 100644 --- a/app/components/UI/Predict/providers/types.ts +++ b/app/components/UI/Predict/providers/types.ts @@ -24,7 +24,7 @@ import { } from '../types'; import { Hex } from '@metamask/utils'; import { TransactionType } from '@metamask/transaction-controller'; -import { PredictFeeCollection } from '../types/flags'; +import { PredictFeatureFlags } from '../types/flags'; // Re-export shared types so existing provider-layer imports continue to work export type { @@ -42,6 +42,7 @@ export type { PreviewOrderParams, PriceUpdateCallback, }; +export type { PredictFeatureFlags }; export interface Signer { address: string; @@ -121,14 +122,8 @@ export interface PredictProvider { readonly chainId: number; getMarkets(params: GetMarketsParams): Promise; - getMarketsByIds?( - marketIds: string[], - liveSportsLeagues?: string[], - ): Promise; - getMarketDetails(params: { - marketId: string; - liveSportsLeagues?: string[]; - }): Promise; + getMarketsByIds?(marketIds: string[]): Promise; + getMarketDetails(params: { marketId: string }): Promise; getPriceHistory( params: GetPriceHistoryParams, ): Promise; @@ -143,7 +138,6 @@ export interface PredictProvider { previewOrder( params: PreviewOrderParams & { signer: Signer; - feeCollection?: PredictFeeCollection; }, ): Promise; placeOrder( diff --git a/app/components/UI/Predict/queries/index.ts b/app/components/UI/Predict/queries/index.ts index fc150fc152b5..26b0d84657ea 100644 --- a/app/components/UI/Predict/queries/index.ts +++ b/app/components/UI/Predict/queries/index.ts @@ -1,6 +1,10 @@ import { predictActivityKeys, predictActivityOptions } from './activity'; import { predictBalanceKeys, predictBalanceOptions } from './balance'; import { predictPositionsKeys, predictPositionsOptions } from './positions'; +import { + predictUnrealizedPnLKeys, + predictUnrealizedPnLOptions, +} from './unrealizedPnL'; export const predictQueries = { activity: { @@ -15,4 +19,8 @@ export const predictQueries = { keys: predictPositionsKeys, options: predictPositionsOptions, }, + unrealizedPnL: { + keys: predictUnrealizedPnLKeys, + options: predictUnrealizedPnLOptions, + }, }; diff --git a/app/components/UI/Predict/queries/unrealizedPnL.ts b/app/components/UI/Predict/queries/unrealizedPnL.ts new file mode 100644 index 000000000000..98f02cb25d30 --- /dev/null +++ b/app/components/UI/Predict/queries/unrealizedPnL.ts @@ -0,0 +1,27 @@ +import { queryOptions } from '@tanstack/react-query'; +import Engine from '../../../../core/Engine'; +import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; +import type { UnrealizedPnL } from '../types'; + +export const predictUnrealizedPnLKeys = { + all: () => ['predict', 'unrealizedPnL'] as const, + byAddress: (address: string) => + [...predictUnrealizedPnLKeys.all(), address] as const, +}; + +export const predictUnrealizedPnLOptions = ({ address }: { address: string }) => + queryOptions({ + queryKey: predictUnrealizedPnLKeys.byAddress(address), + queryFn: async (): Promise => { + const result = await Engine.context.PredictController.getUnrealizedPnL({ + address, + }); + + DevLogger.log('useUnrealizedPnL: Loaded unrealized P&L', { + unrealizedPnL: result, + }); + + return result ?? null; + }, + staleTime: 10_000, + }); diff --git a/app/components/UI/Predict/selectors/featureFlags/index.test.ts b/app/components/UI/Predict/selectors/featureFlags/index.test.ts index 08fe65e96666..436b0493870d 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.test.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.test.ts @@ -1,6 +1,7 @@ import { selectPredictEnabledFlag, - selectPredictGtmOnboardingModalEnabledFlag, + selectPredictFakOrdersEnabledFlag, + selectPredictFeeCollectionFlag, selectPredictHotTabFlag, } from '.'; import mockedEngine from '../../../../../core/__mocks__/MockedEngine'; @@ -36,7 +37,6 @@ describe('Predict Feature Flag Selectors', () => { beforeEach(() => { jest.clearAllMocks(); delete process.env.MM_PREDICT_ENABLED; - delete process.env.MM_PREDICT_GTM_MODAL_ENABLED; mockHasMinimumRequiredVersion = jest.spyOn( remoteFeatureFlagModule, 'hasMinimumRequiredVersion', @@ -46,7 +46,6 @@ describe('Predict Feature Flag Selectors', () => { afterEach(() => { delete process.env.MM_PREDICT_ENABLED; - delete process.env.MM_PREDICT_GTM_MODAL_ENABLED; mockHasMinimumRequiredVersion?.mockRestore(); }); @@ -192,79 +191,6 @@ describe('Predict Feature Flag Selectors', () => { }); }); - describe('selectPredictGtmOnboardingModalEnabledFlag', () => { - it('returns true when remote flag is enabled (VersionGated shape from builds.yml)', () => { - const state = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictGtmOnboardingModalEnabled: { - enabled: true, - minimumVersion: '0.0.0', - }, - }, - cacheTimestamp: 0, - }, - }, - }, - }; - expect(selectPredictGtmOnboardingModalEnabledFlag(state)).toBe(true); - }); - - it('returns false when remote flag is disabled (VersionGated shape from builds.yml)', () => { - const state = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: { - predictGtmOnboardingModalEnabled: { - enabled: false, - minimumVersion: '0.0.0', - }, - }, - cacheTimestamp: 0, - }, - }, - }, - }; - expect(selectPredictGtmOnboardingModalEnabledFlag(state)).toBe(false); - }); - - it('falls back to process.env.MM_PREDICT_GTM_MODAL_ENABLED when remote flag is absent', () => { - const emptyRemoteState = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: {}, - cacheTimestamp: 0, - }, - }, - }, - }; - const emptyRemoteState2 = { - engine: { - backgroundState: { - RemoteFeatureFlagController: { - remoteFeatureFlags: {}, - cacheTimestamp: 1, - }, - }, - }, - }; - - process.env.MM_PREDICT_GTM_MODAL_ENABLED = 'true'; - expect(selectPredictGtmOnboardingModalEnabledFlag(emptyRemoteState)).toBe( - true, - ); - - process.env.MM_PREDICT_GTM_MODAL_ENABLED = 'false'; - expect( - selectPredictGtmOnboardingModalEnabledFlag(emptyRemoteState2), - ).toBe(false); - }); - }); - describe('selectPredictHotTabFlag', () => { it('returns hot tab flag when present in remote feature flags', () => { const stateWithHotTabFlag = { @@ -717,4 +643,285 @@ describe('Predict Feature Flag Selectors', () => { }); }); }); + + describe('selectPredictFeeCollectionFlag', () => { + it('returns fee collection config when present in remote feature flags', () => { + const feeCollectionConfig = { + enabled: true, + collector: '0xe6a2026d58eaff3c7ad7ba9386fb143388002382', + metamaskFee: 0.03, + providerFee: 0.01, + waiveList: ['middle-east'], + executors: ['0x1234'], + permit2Enabled: true, + }; + const stateWithFeeCollection = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictFeeCollection: feeCollectionConfig, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictFeeCollectionFlag(stateWithFeeCollection); + + expect(result).toEqual(feeCollectionConfig); + }); + + it('returns default flag when remote flag is missing', () => { + const result = selectPredictFeeCollectionFlag(mockedEmptyFlagsState); + + expect(result).toEqual({ + enabled: true, + collector: expect.any(String), + metamaskFee: 0.02, + providerFee: 0.02, + waiveList: [], + executors: [], + permit2Enabled: false, + }); + }); + + it('returns default flag when remote flag is null', () => { + const stateWithNullFlag = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictFeeCollection: null, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictFeeCollectionFlag(stateWithNullFlag); + + expect(result).toEqual({ + enabled: true, + collector: expect.any(String), + metamaskFee: 0.02, + providerFee: 0.02, + waiveList: [], + executors: [], + permit2Enabled: false, + }); + }); + + it('returns default flag when controller is undefined', () => { + const stateWithUndefinedController = { + engine: { + backgroundState: { + RemoteFeatureFlagController: undefined, + }, + }, + }; + + const result = selectPredictFeeCollectionFlag( + stateWithUndefinedController, + ); + + expect(result).toEqual({ + enabled: true, + collector: expect.any(String), + metamaskFee: 0.02, + providerFee: 0.02, + waiveList: [], + executors: [], + permit2Enabled: false, + }); + }); + + it('returns remote config with custom waiveList', () => { + const stateWithWaiveList = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictFeeCollection: { + enabled: true, + collector: '0xabc', + metamaskFee: 0.05, + providerFee: 0.03, + waiveList: ['middle-east', 'humanitarian'], + executors: [], + permit2Enabled: false, + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictFeeCollectionFlag(stateWithWaiveList); + + expect(result.waiveList).toEqual(['middle-east', 'humanitarian']); + expect(result.metamaskFee).toBe(0.05); + expect(result.providerFee).toBe(0.03); + }); + + it('returns remote config when fee collection is disabled', () => { + const stateWithDisabledFees = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictFeeCollection: { + enabled: false, + collector: '0x0', + metamaskFee: 0, + providerFee: 0, + waiveList: [], + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictFeeCollectionFlag(stateWithDisabledFees); + + expect(result.enabled).toBe(false); + }); + }); + + describe('selectPredictFakOrdersEnabledFlag', () => { + it('returns true when remote flag is enabled and version check passes', () => { + mockHasMinimumRequiredVersion.mockReturnValue(true); + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictFakOrders: { + enabled: true, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictFakOrdersEnabledFlag(state); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is disabled', () => { + mockHasMinimumRequiredVersion.mockReturnValue(true); + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictFakOrders: { + enabled: false, + minimumVersion: '1.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictFakOrdersEnabledFlag(state); + + expect(result).toBe(false); + }); + + it('returns false when app version is below minimum required version', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictFakOrders: { + enabled: true, + minimumVersion: '99.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictFakOrdersEnabledFlag(state); + + expect(result).toBe(false); + }); + + it('defaults to false when remote flag is null', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictFakOrders: null, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictFakOrdersEnabledFlag(state); + + expect(result).toBe(false); + }); + + it('defaults to false when remote feature flags are empty', () => { + const result = selectPredictFakOrdersEnabledFlag(mockedEmptyFlagsState); + + expect(result).toBe(false); + }); + + it('defaults to false when controller is undefined', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: undefined, + }, + }, + }; + + const result = selectPredictFakOrdersEnabledFlag(state); + + expect(result).toBe(false); + }); + + it('defaults to false when remote flag is invalid', () => { + const state = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + predictFakOrders: { + enabled: 'invalid', + minimumVersion: 123, + }, + }, + cacheTimestamp: 0, + }, + }, + }, + }; + + const result = selectPredictFakOrdersEnabledFlag(state); + + expect(result).toBe(false); + }); + }); }); diff --git a/app/components/UI/Predict/selectors/featureFlags/index.ts b/app/components/UI/Predict/selectors/featureFlags/index.ts index b2865f2548ea..c8cfee9e2831 100644 --- a/app/components/UI/Predict/selectors/featureFlags/index.ts +++ b/app/components/UI/Predict/selectors/featureFlags/index.ts @@ -4,8 +4,11 @@ import { VersionGatedFeatureFlag, validatedVersionGatedFeatureFlag, } from '../../../../../util/remoteFeatureFlag'; -import { PredictHotTabFlag } from '../../types/flags'; -import { DEFAULT_HOT_TAB_FLAG } from '../../constants/flags'; +import { PredictFeatureFlags, PredictHotTabFlag } from '../../types/flags'; +import { + DEFAULT_FEE_COLLECTION_FLAG, + DEFAULT_HOT_TAB_FLAG, +} from '../../constants/flags'; import { unwrapRemoteFeatureFlag } from '../../utils/flags'; /** @@ -38,6 +41,7 @@ export const selectPredictGtmOnboardingModalEnabledFlag = createSelector( remoteFeatureFlags?.predictGtmOnboardingModalEnabled, ); + // Fallback to local flag if remote flag is not available return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; }, ); @@ -108,3 +112,39 @@ export const selectPredictHotTabFlag = createSelector( return flag; }, ); + +/** + * Selector for Predict fee collection config flag + */ +export const selectPredictFeeCollectionFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const flag = unwrapRemoteFeatureFlag( + remoteFeatureFlags?.predictFeeCollection, + ); + + if (!flag) { + return DEFAULT_FEE_COLLECTION_FLAG; + } + + return flag; + }, +); + +/** + * Selector for Predict FAK (Fill-And-Kill) orders enablement + * + * Uses version-gated feature flag `predictFakOrders` from remote config. + * Falls back to `false` if remote flag is unavailable or invalid. + * + * @returns {boolean} True if FAK orders are enabled and version requirement is met + */ +export const selectPredictFakOrdersEnabledFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => + validatedVersionGatedFeatureFlag( + unwrapRemoteFeatureFlag( + remoteFeatureFlags?.predictFakOrders, + ), + ) ?? false, +); diff --git a/app/components/UI/Predict/types/flags.ts b/app/components/UI/Predict/types/flags.ts index 9444940b7c54..54e60d1ac9ca 100644 --- a/app/components/UI/Predict/types/flags.ts +++ b/app/components/UI/Predict/types/flags.ts @@ -7,6 +7,8 @@ export interface PredictFeeCollection { metamaskFee: number; providerFee: number; waiveList: string[]; + executors?: string[]; + permit2Enabled?: boolean; } export interface PredictLiveSportsFlag { @@ -23,6 +25,13 @@ export interface PredictMarketHighlightsFlag extends VersionGatedFeatureFlag { highlights: PredictMarketHighlight[]; } +export interface PredictFeatureFlags { + feeCollection: PredictFeeCollection; + liveSportsLeagues: string[]; + marketHighlightsFlag: PredictMarketHighlightsFlag; + fakOrdersEnabled: boolean; +} + export interface PredictHotTabFlag extends VersionGatedFeatureFlag { queryParams?: string; // Raw query params WITHOUT leading &: "tag_id=149&tag_id=100995&order=volume24hr" } diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts index 6191a2279a48..51464d2ee186 100644 --- a/app/components/UI/Predict/types/index.ts +++ b/app/components/UI/Predict/types/index.ts @@ -7,6 +7,8 @@ export enum Side { SELL = 'SELL', } +export type PredictOrderType = 'FOK' | 'FAK'; + export enum PredictPriceHistoryInterval { ONE_HOUR = '1h', SIX_HOUR = '6h', @@ -420,7 +422,6 @@ export interface GetMarketsParams { sortDirection?: 'asc' | 'desc'; offset?: number; limit?: number; - liveSportsLeagues?: string[]; customQueryParams?: string; } @@ -434,6 +435,8 @@ export interface PredictFees { totalFee: number; totalFeePercentage: number; collector: Hex; + executors?: string[]; + permit2Enabled?: boolean; } /** @@ -469,6 +472,7 @@ export interface OrderPreview { // For sell orders, we can store the position ID // so we can perform optimistic updates positionId?: string; + orderType?: PredictOrderType; } export type OrderResult = Result<{ @@ -540,3 +544,5 @@ export interface GetAccountStateParams {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PrepareWithdrawParams {} + +export type { PredictFeatureFlags } from './flags'; diff --git a/app/components/UI/Predict/utils/format.test.ts b/app/components/UI/Predict/utils/format.test.ts index bcef0ca9d88f..483a3941a7f2 100644 --- a/app/components/UI/Predict/utils/format.test.ts +++ b/app/components/UI/Predict/utils/format.test.ts @@ -1980,21 +1980,101 @@ describe('format utils', () => { }); }); - describe('Negative values', () => { - it('formats negative regular price', () => { + describe('with currencyCode param', () => { + it('defaults to USD when no currencyCode is provided', () => { // Arrange & Act - const result = formatPriceWithSubscriptNotation(-1.99); + const result = formatPriceWithSubscriptNotation(1.99); + + // Assert + expect(result).toBe('$1.99'); + }); + + it('formats regular price with EUR', () => { + // Arrange & Act + const result = formatPriceWithSubscriptNotation(1.99, 'EUR'); + + // Assert + expect(result).toBe('€1.99'); + }); + + it('formats price with up to 4 decimal places with EUR', () => { + // Arrange & Act + const result = formatPriceWithSubscriptNotation(0.144566, 'EUR'); + + // Assert + expect(result).toBe('€0.1446'); + }); + + it('formats large price with GBP (not in symbol map, uses suffix)', () => { + // Arrange & Act + const result = formatPriceWithSubscriptNotation(1234.56, 'GBP'); + + // Assert + expect(result).toBe('1,234.56 GBP'); + }); + + it('accepts lowercase currency code', () => { + // Arrange & Act + const result = formatPriceWithSubscriptNotation(99.99, 'eur'); + + // Assert + expect(result).toBe('€99.99'); + }); + + it('formats subscript value with EUR symbol', () => { + // Arrange & Act + const result = formatPriceWithSubscriptNotation(0.00000614, 'EUR'); + + // Assert + expect(result).toBe('€0.0₅614'); + }); + + it('formats subscript value with GBP (not in symbol map, uses suffix)', () => { + // Arrange & Act + const result = formatPriceWithSubscriptNotation(0.00001, 'GBP'); + + // Assert + expect(result).toBe('0.0₄1 GBP'); + }); + + it('returns dash for zero regardless of currency', () => { + // Arrange & Act + const result = formatPriceWithSubscriptNotation(0, 'EUR'); + + // Assert + expect(result).toBe('—'); + }); + + it('formats whole number with EUR showing 2 decimals', () => { + // Arrange & Act + const result = formatPriceWithSubscriptNotation(100, 'EUR'); + + // Assert + expect(result).toBe('€100.00'); + }); + + it('uses currency code as suffix for currencies not in the symbol map (e.g. PLN)', () => { + // Arrange & Act + const result = formatPriceWithSubscriptNotation(0.00001263, 'PLN'); + + // Assert + expect(result).toBe('0.0₄1263 PLN'); + }); + + it('uses currency code as suffix for subscript values with unknown currency', () => { + // Arrange & Act + const result = formatPriceWithSubscriptNotation(0.00000614, 'CHF'); // Assert - expect(result).toBe('-$1.99'); + expect(result).toBe('0.0₅614 CHF'); }); - it('formats negative large price', () => { + it('accepts lowercase unknown currency code and uppercases it as suffix', () => { // Arrange & Act - const result = formatPriceWithSubscriptNotation(-1234.56); + const result = formatPriceWithSubscriptNotation(0.00001, 'pln'); // Assert - expect(result).toBe('-$1,234.56'); + expect(result).toBe('0.0₄1 PLN'); }); }); @@ -2017,8 +2097,6 @@ describe('format utils', () => { [0.00000614, '$0.0₅614'], [0.0000001234, '$0.0₆1234'], [0.000000001, '$0.0₈1'], - [-1.99, '-$1.99'], - [-1234.56, '-$1,234.56'], ])('formats %f as %s', (input, expected) => { expect(formatPriceWithSubscriptNotation(input)).toBe(expected); }); diff --git a/app/components/UI/Predict/utils/format.ts b/app/components/UI/Predict/utils/format.ts index a941e060e35e..45122edcc98a 100644 --- a/app/components/UI/Predict/utils/format.ts +++ b/app/components/UI/Predict/utils/format.ts @@ -1,6 +1,7 @@ import { Dimensions } from 'react-native'; import { PredictSeries, Recurrence } from '../types'; import { formatSubscriptNotation } from '../../../../util/number/subscriptNotation'; +import currencySymbols from '../../../../util/currency-symbols.json'; /** * Formats a percentage value @@ -109,20 +110,23 @@ export const formatPrice = ( }; /** - * Formats a price value for trending tokens with subscript notation for very small values + * Formats a price value with subscript notation for very small values * - Uses subscript notation for values with 4+ leading zeros (e.g., 0.00000614 → $0.0₅614) * - The subscript indicates the number of leading zeros after the decimal point * - Returns "—" for zero values * - Uses min 2, max 4 decimal places for regular values * @param price - The price value to format (string or number) - * @returns Formatted price string with $ prefix or "—" for zero + * @param currencyCode - ISO 4217 currency code (e.g. 'USD', 'EUR'). Defaults to 'USD'. + * @returns Formatted price string with currency symbol or "—" for zero * @example formatPriceWithSubscriptNotation(1.99) => "$1.99" * @example formatPriceWithSubscriptNotation(0.144566) => "$0.1446" * @example formatPriceWithSubscriptNotation(0.00000614) => "$0.0₅614" * @example formatPriceWithSubscriptNotation(0) => "—" + * @example formatPriceWithSubscriptNotation(1.2345, 'EUR') => "€1.2345" */ export const formatPriceWithSubscriptNotation = ( price: string | number, + currencyCode = 'USD', ): string => { const num = typeof price === 'string' ? parseFloat(price) : price; @@ -134,12 +138,22 @@ export const formatPriceWithSubscriptNotation = ( return '—'; } - const subscript = formatSubscriptNotation(num); - if (subscript) { - return `$${subscript}`; - } + // Known symbol (e.g. $, €) → prefix; unknown code (e.g. PLN) → suffix + // Matches addCurrencySymbol convention used elsewhere in the app + const symbol = + currencySymbols[currencyCode.toLowerCase() as keyof typeof currencySymbols]; + const addSymbol = (n: string) => + symbol ? `${symbol}${n}` : `${n} ${currencyCode.toUpperCase()}`; - return formatPrice(num, { minimumDecimals: 2, maximumDecimals: 4 }); + const subscript = formatSubscriptNotation(num); + if (subscript) return addSymbol(subscript); + + const formattedNumber = new Intl.NumberFormat('en-US', { + style: 'decimal', + minimumFractionDigits: 2, + maximumFractionDigits: 4, + }).format(num); + return addSymbol(formattedNumber); }; /** diff --git a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx index 60621002bf84..8c3d284b4b69 100644 --- a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx +++ b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx @@ -34,6 +34,7 @@ import { ScrollView, TouchableOpacity, } from 'react-native'; +import { useSelector } from 'react-redux'; import Button, { ButtonSize, ButtonVariants, @@ -78,7 +79,7 @@ import { TraceName } from '../../../../../util/trace'; import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; import { PredictBuyPreviewSelectorsIDs } from '../../Predict.testIds'; import { usePredictOrderRetry } from '../../hooks/usePredictOrderRetry'; - +import { selectPredictFakOrdersEnabledFlag } from '../../selectors/featureFlags'; export const MINIMUM_BET = 1; // $1 minimum bet const PredictBuyPreview = () => { @@ -131,6 +132,7 @@ const PredictBuyPreview = () => { usePredictBalance(); const { deposit } = usePredictDeposit(); + const fakOrdersEnabled = useSelector(selectPredictFakOrdersEnabledFlag); const [currentValue, setCurrentValue] = useState(0); const [currentValueUSDString, setCurrentValueUSDString] = useState(''); @@ -653,6 +655,7 @@ const PredictBuyPreview = () => { betAmount={currentValue} total={total} onClose={handleFeeBreakdownClose} + fakOrdersEnabled={fakOrdersEnabled} /> )} { }); describe('Fee Exemption Display', () => { - it('displays fee exemption message when market has Middle East tag', () => { - const marketWithMiddleEastTag = createMockMarket({ + it('displays fee exemption message when market tag matches waiveList', () => { + const marketWithWaivedTag = createMockMarket({ status: 'open', - tags: ['Middle East'], + tags: ['middle-east'], outcomes: [ { id: 'outcome-1', @@ -3214,17 +3228,17 @@ describe('PredictMarketDetails', () => { ], }); - setupPredictMarketDetailsTest(marketWithMiddleEastTag); + setupPredictMarketDetailsTest(marketWithWaivedTag); expect( screen.getByText('predict.market_details.fee_exemption'), ).toBeOnTheScreen(); }); - it('hides fee exemption message when market does not have Middle East tag', () => { - const marketWithoutMiddleEastTag = createMockMarket({ + it('hides fee exemption message when market tags do not match waiveList', () => { + const marketWithNonWaivedTags = createMockMarket({ status: 'open', - tags: ['Sports', 'Politics'], + tags: ['sports', 'politics'], outcomes: [ { id: 'outcome-1', @@ -3238,7 +3252,7 @@ describe('PredictMarketDetails', () => { ], }); - setupPredictMarketDetailsTest(marketWithoutMiddleEastTag); + setupPredictMarketDetailsTest(marketWithNonWaivedTags); expect( screen.queryByText('predict.market_details.fee_exemption'), @@ -3293,10 +3307,10 @@ describe('PredictMarketDetails', () => { ).not.toBeOnTheScreen(); }); - it('displays fee exemption message when Middle East tag exists among multiple tags', () => { + it('displays fee exemption message when waiveList tag exists among multiple tags', () => { const marketWithMultipleTags = createMockMarket({ status: 'open', - tags: ['Politics', 'Middle East', 'International'], + tags: ['politics', 'middle-east', 'international'], outcomes: [ { id: 'outcome-1', @@ -3317,10 +3331,10 @@ describe('PredictMarketDetails', () => { ).toBeOnTheScreen(); }); - it('displays fee exemption message when market is closed with Middle East tag', () => { - const closedMarketWithMiddleEastTag = createMockMarket({ + it('displays fee exemption message when market is closed with waiveList tag', () => { + const closedMarketWithWaivedTag = createMockMarket({ status: 'closed', - tags: ['Middle East'], + tags: ['middle-east'], outcomes: [ { id: 'outcome-1', @@ -3331,23 +3345,23 @@ describe('PredictMarketDetails', () => { ], }); - setupPredictMarketDetailsTest(closedMarketWithMiddleEastTag); + setupPredictMarketDetailsTest(closedMarketWithWaivedTag); - // Note: The component currently shows the fee exemption message for closed markets - // if they have the Middle East tag. This behavior matches the current implementation. + // Note: The component shows the fee exemption message for closed markets + // if they have a tag matching the waiveList. This matches the current implementation. expect( screen.getByText('predict.market_details.fee_exemption'), ).toBeOnTheScreen(); }); - it('removes fee exemption message when market updates without Middle East tag', async () => { + it('removes fee exemption message when market updates without waiveList tag', async () => { const { usePredictMarket } = jest.requireMock( '../../hooks/usePredictMarket', ); - const marketWithMiddleEastTag = createMockMarket({ + const marketWithWaivedTag = createMockMarket({ status: 'open', - tags: ['Middle East', 'Politics'], + tags: ['middle-east', 'politics'], outcomes: [ { id: 'outcome-1', @@ -3361,17 +3375,15 @@ describe('PredictMarketDetails', () => { ], }); - const { rerender } = setupPredictMarketDetailsTest( - marketWithMiddleEastTag, - ); + const { rerender } = setupPredictMarketDetailsTest(marketWithWaivedTag); expect( screen.getByText('predict.market_details.fee_exemption'), ).toBeOnTheScreen(); - const marketWithoutMiddleEastTag = createMockMarket({ + const marketWithoutWaivedTag = createMockMarket({ status: 'open', - tags: ['Politics'], + tags: ['politics'], outcomes: [ { id: 'outcome-1', @@ -3386,7 +3398,7 @@ describe('PredictMarketDetails', () => { }); usePredictMarket.mockReturnValue({ - market: marketWithoutMiddleEastTag, + market: marketWithoutWaivedTag, isFetching: false, refetch: jest.fn(), }); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index 42a4daa54a48..022226b9ea4c 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -47,6 +47,8 @@ import PredictMarketDetailsTabContent from './components/PredictMarketDetailsTab import { useChartData } from './hooks/useChartData'; import { useOutcomeResolution } from './hooks/useOutcomeResolution'; import { useOpenOutcomes } from './hooks/useOpenOutcomes'; +import { useSelector } from 'react-redux'; +import { selectPredictFeeCollectionFlag } from '../../selectors/featureFlags'; // Use theme tokens instead of hex values for multi-series charts @@ -128,8 +130,11 @@ const PredictMarketDetails: React.FC = () => { enabled: !isMarketFetching && Boolean(resolvedMarketId), }); - // check if market has fee exemption (note: worth moveing to a const or util at some point)) - const isFeeExemption = market?.tags?.includes('Middle East') ?? false; + const feeCollectionConfig = useSelector(selectPredictFeeCollectionFlag); + const isFeeExemption = + market?.tags?.some((slug) => + feeCollectionConfig.waiveList?.includes(slug), + ) ?? false; // Tabs become ready when both market and positions queries have resolved const tabsReady = useMemo( diff --git a/app/components/UI/ProtectYourWalletModal/__snapshots__/index.test.tsx.snap b/app/components/UI/ProtectYourWalletModal/__snapshots__/index.test.tsx.snap index d363f5abbe6c..11bfb3da4e81 100644 --- a/app/components/UI/ProtectYourWalletModal/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/ProtectYourWalletModal/__snapshots__/index.test.tsx.snap @@ -53,7 +53,7 @@ exports[`ProtectYourWalletModal render matches snapshot 1`] = ` onStartShouldSetResponder={[Function]} style={ { - "backgroundColor": "#3f434a66", + "backgroundColor": "#0a0d135c", "bottom": 0, "height": 1334, "left": 0, @@ -218,7 +218,7 @@ exports[`ProtectYourWalletModal render matches snapshot 1`] = ` { }); describe('Camera Permission Error', () => { - it('calls onScanError only after requestPermission resolves with denial', async () => { + it('re-requests camera permission when app returns to foreground', async () => { const mockUseCameraPermission = jest.requireMock( 'react-native-vision-camera', ).useCameraPermission; - const mockRequestPermission = jest.fn().mockResolvedValue(false); mockUseCameraPermission.mockReturnValue({ hasPermission: false, requestPermission: mockRequestPermission, }); + let appStateChangeHandler: + | ((nextAppState: AppStateStatus) => void) + | null = null; + const addEventListenerSpy = jest + .spyOn(AppState, 'addEventListener') + .mockImplementation((eventType, listener) => { + if (eventType === 'change') { + appStateChangeHandler = listener; + } + return { remove: jest.fn() }; + }); + render(); + await waitFor(() => { + expect(mockRequestPermission).toHaveBeenCalledTimes(1); + }); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ); + expect(appStateChangeHandler).not.toBeNull(); + + act(() => { + appStateChangeHandler?.('background'); + }); + + await waitFor(() => { + expect(mockRequestPermission).toHaveBeenCalledTimes(1); + }); + + act(() => { + appStateChangeHandler?.('active'); + }); + + await waitFor(() => { + expect(mockRequestPermission).toHaveBeenCalledTimes(2); + }); + + addEventListenerSpy.mockRestore(); + }); + + it('keeps modal open with settings button when permission is denied', async () => { + const mockUseCameraPermission = jest.requireMock( + 'react-native-vision-camera', + ).useCameraPermission; + const openSettingsSpy = jest + .spyOn(Linking, 'openSettings') + .mockResolvedValue(); + + const mockRequestPermission = jest.fn().mockResolvedValue(false); + mockUseCameraPermission.mockReturnValue({ + hasPermission: false, + requestPermission: mockRequestPermission, + }); + + const { getByTestId, getByText } = render( + , + ); + await waitFor(() => { expect(mockRequestPermission).toHaveBeenCalled(); - expect(mockOnScanError).toHaveBeenCalledWith( - 'transaction.no_camera_permission', - ); }); + + expect(mockOnScanError).not.toHaveBeenCalled(); + expect(getByText('transaction.no_camera_permission')).toBeOnTheScreen(); + expect(getByTestId('open-settings-button')).toBeOnTheScreen(); + + await act(async () => { + getByTestId('open-settings-button').props.onPress(); + }); + expect(openSettingsSpy).toHaveBeenCalledTimes(1); + + openSettingsSpy.mockRestore(); }); it('does not call onScanError when requestPermission is granted', async () => { @@ -662,7 +729,7 @@ describe('AnimatedQRScannerModal - Metrics', () => { expect(mockOnScanError).not.toHaveBeenCalled(); }); - it('does not call onScanError when modal is not visible', async () => { + it('does not request permission when modal is not visible', async () => { const mockUseCameraPermission = jest.requireMock( 'react-native-vision-camera', ).useCameraPermission; @@ -681,7 +748,6 @@ describe('AnimatedQRScannerModal - Metrics', () => { expect(mockOnScanError).not.toHaveBeenCalled(); }); - // requestPermission should not have been called since modal is not visible expect(mockRequestPermission).not.toHaveBeenCalled(); }); }); diff --git a/app/components/UI/QRHardware/AnimatedQRScanner.tsx b/app/components/UI/QRHardware/AnimatedQRScanner.tsx index ecbe23096210..a049512eb1ad 100644 --- a/app/components/UI/QRHardware/AnimatedQRScanner.tsx +++ b/app/components/UI/QRHardware/AnimatedQRScanner.tsx @@ -2,8 +2,23 @@ /* eslint @typescript-eslint/no-require-imports: "off" */ 'use strict'; -import React, { useCallback, useState, useEffect, useMemo } from 'react'; -import { Image, Text, TouchableOpacity, View, StyleSheet } from 'react-native'; +import React, { + useCallback, + useState, + useEffect, + useMemo, + useRef, +} from 'react'; +import { + AppState, + AppStateStatus, + Image, + Linking, + Text, + TouchableOpacity, + View, + StyleSheet, +} from 'react-native'; import { Camera, useCameraDevice, @@ -110,6 +125,21 @@ const createStyles = (theme: Theme) => flex: 1, justifyContent: 'center', alignItems: 'center', + paddingHorizontal: 24, + }, + openSettingsButton: { + marginTop: 24, + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 40, + borderWidth: 1, + borderColor: theme.brandColors.white, + }, + openSettingsText: { + color: theme.brandColors.white, + fontSize: 16, + textAlign: 'center', + ...fontStyles.normal, }, }); @@ -142,6 +172,7 @@ const AnimatedQRScannerModal = (props: AnimatedQRScannerProps) => { const cameraDevice = useCameraDevice('back'); const { hasPermission, requestPermission } = useCameraPermission(); + const appState = useRef(AppState.currentState); let expectedURTypes: string[]; if (purpose === QrScanRequestType.PAIR) { @@ -153,19 +184,42 @@ const AnimatedQRScannerModal = (props: AnimatedQRScannerProps) => { expectedURTypes = [SUPPORTED_UR_TYPE.ETH_SIGNATURE]; } + const refreshCameraPermission = useCallback(() => { + if (!visible || hasPermission) { + return; + } + + requestPermission(); + }, [hasPermission, requestPermission, visible]); + useEffect(() => { - let cancelled = false; - if (!hasPermission && visible) { - requestPermission().then((granted) => { - if (!cancelled && !granted) { - onScanError(strings('transaction.no_camera_permission')); - } - }); + refreshCameraPermission(); + }, [refreshCameraPermission]); + + useEffect(() => { + if (!visible) { + return undefined; } + + const subscription = AppState.addEventListener( + 'change', + (nextAppState: AppStateStatus) => { + const hasReturnedToForeground = + /inactive|background/.test(appState.current) && + nextAppState === 'active'; + + appState.current = nextAppState; + + if (hasReturnedToForeground) { + refreshCameraPermission(); + } + }, + ); + return () => { - cancelled = true; + subscription?.remove?.(); }; - }, [hasPermission, requestPermission, visible, onScanError]); + }, [refreshCameraPermission, visible]); const reset = useCallback(() => { setURDecoder(new URRegistryDecoder()); @@ -336,6 +390,15 @@ const AnimatedQRScannerModal = (props: AnimatedQRScannerProps) => { {strings('transaction.no_camera_permission')} + Linking.openSettings()} + testID="open-settings-button" + > + + {strings('qr_scanner.open_settings')} + + )} diff --git a/app/components/UI/QRHardware/QRSigningDetails.tsx b/app/components/UI/QRHardware/QRSigningDetails.tsx index b38a92d91f3d..894fea8e4426 100644 --- a/app/components/UI/QRHardware/QRSigningDetails.tsx +++ b/app/components/UI/QRHardware/QRSigningDetails.tsx @@ -1,16 +1,6 @@ import React, { Fragment, useCallback, useEffect, useState } from 'react'; import Engine from '../../../core/Engine'; -import { - StyleSheet, - Text, - View, - ScrollView, - // eslint-disable-next-line react-native/split-platform-components - PermissionsAndroid, - Linking, - AppState, - AppStateStatus, -} from 'react-native'; +import { StyleSheet, Text, View, ScrollView } from 'react-native'; import { strings } from '../../../../locales/i18n'; import AnimatedQRCode from './AnimatedQRCode'; import AnimatedQRScannerModal from './AnimatedQRScanner'; @@ -25,7 +15,6 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import { useNavigation } from '@react-navigation/native'; import { useTheme } from '../../../util/theme'; -import Device from '../../../util/device'; import { useMetrics } from '../../../components/hooks/useMetrics'; import { QrScanRequest, QrScanRequestType } from '@metamask/eth-qr-keyring'; @@ -39,7 +28,6 @@ interface IQRSigningDetails { tighten?: boolean; showHint?: boolean; shouldStartAnimated?: boolean; - bypassAndroidCameraAccessCheck?: boolean; fromAddress: string; } @@ -120,7 +108,6 @@ const QRSigningDetails = ({ tighten = false, showHint = true, shouldStartAnimated = true, - bypassAndroidCameraAccessCheck = true, fromAddress, }: IQRSigningDetails) => { const { colors } = useTheme(); @@ -130,50 +117,6 @@ const QRSigningDetails = ({ const [scannerVisible, setScannerVisible] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [shouldPause, setShouldPause] = useState(false); - const [cameraError, setCameraError] = useState(''); - - // ios handled camera perfectly in this situation, we just need to check permission with android. - const [hasCameraPermission, setCameraPermission] = useState( - Device.isIos() || bypassAndroidCameraAccessCheck, - ); - - const checkAndroidCamera = useCallback(() => { - if (Device.isAndroid() && !hasCameraPermission) { - PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.CAMERA).then( - (_hasPermission) => { - setCameraPermission(_hasPermission); - if (!_hasPermission) { - setCameraError(strings('transaction.no_camera_permission_android')); - } else { - setCameraError(''); - } - }, - ); - } - }, [hasCameraPermission]); - - const handleAppState = useCallback( - (appState: AppStateStatus) => { - if (appState === 'active') { - checkAndroidCamera(); - } - }, - [checkAndroidCamera], - ); - - useEffect(() => { - checkAndroidCamera(); - }, [checkAndroidCamera]); - - useEffect(() => { - const appStateListener = AppState.addEventListener( - 'change', - handleAppState, - ); - return () => { - appStateListener.remove(); - }; - }, [handleAppState]); const [hasSentOrCanceled, setSentOrCanceled] = useState(false); @@ -269,23 +212,12 @@ const QRSigningDetails = ({ ); - const renderCameraAlert = () => - cameraError !== '' && ( - - {cameraError} - - ); - return ( {pendingScanRequest?.request && ( {renderAlert()} - {renderCameraAlert()} diff --git a/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx b/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx index 22ae09f88204..44a1af937e36 100644 --- a/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx +++ b/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx @@ -232,9 +232,6 @@ describe('QRSigningTransactionModal', () => { expect(qrSigningDetailsElement.props.tighten).toBe(true); expect(qrSigningDetailsElement.props.showHint).toBe(true); expect(qrSigningDetailsElement.props.shouldStartAnimated).toBe(true); - expect(qrSigningDetailsElement.props.bypassAndroidCameraAccessCheck).toBe( - false, - ); expect(qrSigningDetailsElement.props.fromAddress).toBe( mockSelectedAccount.address, ); diff --git a/app/components/UI/QRHardware/QRSigningTransactionModal.tsx b/app/components/UI/QRHardware/QRSigningTransactionModal.tsx index 41260f462868..0d9dc6c28ae5 100644 --- a/app/components/UI/QRHardware/QRSigningTransactionModal.tsx +++ b/app/components/UI/QRHardware/QRSigningTransactionModal.tsx @@ -110,7 +110,6 @@ const QRSigningTransactionModal = () => { }} cancelCallback={onRejection} failureCallback={onRejection} - bypassAndroidCameraAccessCheck={false} fromAddress={selectedAccount?.address ?? ''} /> )} diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap index 5196c2425095..62b44e3ca882 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -117,7 +117,7 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -158,7 +158,7 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 16, "fontWeight": 400, @@ -236,7 +236,7 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -544,7 +544,7 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no style={ { "alignItems": "center", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 100, "flexDirection": "row", "justifyContent": "center", @@ -579,7 +579,7 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no numberOfLines={1} style={ { - "color": "#121314", + "color": "#131416", "flexShrink": 1, "fontFamily": "Geist-Medium", "fontSize": 16, @@ -591,7 +591,7 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no } /> { } catch (fetchError) { Logger.error(fetchError as Error, { message: 'FiatOrders::OrderDetails error while processing order', - order, + orderId: order.id, }); setError((fetchError as Error).message || 'An error as occurred'); } finally { diff --git a/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap index 6afbc6d4f5f5..86d02eae154a 100644 --- a/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap @@ -309,7 +309,7 @@ exports[`OrderDetails renders a cancelled order 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -350,7 +350,7 @@ exports[`OrderDetails renders a cancelled order 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 16, "fontWeight": 400, @@ -386,7 +386,7 @@ exports[`OrderDetails renders a cancelled order 1`] = ` } onRefresh={[Function]} refreshing={false} - tintColor="#121314" + tintColor="#131416" /> } > @@ -443,9 +443,9 @@ exports[`OrderDetails renders a cancelled order 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, "textAlign": "center", @@ -458,7 +458,7 @@ exports[`OrderDetails renders a cancelled order 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -482,7 +482,7 @@ exports[`OrderDetails renders a cancelled order 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", "fontSize": 24, "letterSpacing": 0, @@ -499,7 +499,7 @@ exports[`OrderDetails renders a cancelled order 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -528,7 +528,7 @@ exports[`OrderDetails renders a cancelled order 1`] = ` style={ [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 8, "padding": 16, }, @@ -555,7 +555,7 @@ exports[`OrderDetails renders a cancelled order 1`] = ` [ { "alignItems": "center", - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderRadius": 100, "flexDirection": "row", "flexShrink": 1, @@ -574,7 +574,7 @@ exports[`OrderDetails renders a cancelled order 1`] = ` } > @@ -1879,9 +1879,9 @@ exports[`OrderDetails renders a completed order 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, "textAlign": "center", @@ -1894,7 +1894,7 @@ exports[`OrderDetails renders a completed order 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -1918,7 +1918,7 @@ exports[`OrderDetails renders a completed order 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", "fontSize": 24, "letterSpacing": 0, @@ -1935,7 +1935,7 @@ exports[`OrderDetails renders a completed order 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -1964,7 +1964,7 @@ exports[`OrderDetails renders a completed order 1`] = ` style={ [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 8, "padding": 16, }, @@ -1991,7 +1991,7 @@ exports[`OrderDetails renders a completed order 1`] = ` [ { "alignItems": "center", - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderRadius": 100, "flexDirection": "row", "flexShrink": 1, @@ -2010,7 +2010,7 @@ exports[`OrderDetails renders a completed order 1`] = ` } > @@ -3286,7 +3286,7 @@ exports[`OrderDetails renders a created order 1`] = ` } > @@ -4711,9 +4711,9 @@ exports[`OrderDetails renders a failed order 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, "textAlign": "center", @@ -4726,7 +4726,7 @@ exports[`OrderDetails renders a failed order 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -4750,7 +4750,7 @@ exports[`OrderDetails renders a failed order 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", "fontSize": 24, "letterSpacing": 0, @@ -4767,7 +4767,7 @@ exports[`OrderDetails renders a failed order 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -4796,7 +4796,7 @@ exports[`OrderDetails renders a failed order 1`] = ` style={ [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 8, "padding": 16, }, @@ -4823,7 +4823,7 @@ exports[`OrderDetails renders a failed order 1`] = ` [ { "alignItems": "center", - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderRadius": 100, "flexDirection": "row", "flexShrink": 1, @@ -4842,7 +4842,7 @@ exports[`OrderDetails renders a failed order 1`] = ` } > @@ -6118,7 +6118,7 @@ exports[`OrderDetails renders a pending order 1`] = ` } > @@ -8518,7 +8518,7 @@ exports[`OrderDetails renders non-transacted orders 1`] = ` } > @@ -10026,9 +10026,9 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, "textAlign": "center", @@ -10041,7 +10041,7 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -10065,7 +10065,7 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", "fontSize": 24, "letterSpacing": 0, @@ -10082,7 +10082,7 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, @@ -10138,7 +10138,7 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = ` style={ [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 8, "padding": 16, }, @@ -10165,7 +10165,7 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = ` [ { "alignItems": "center", - "backgroundColor": "#f3f5f9", + "backgroundColor": "#f3f3f4", "borderRadius": 100, "flexDirection": "row", "flexShrink": 1, @@ -10184,7 +10184,7 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = ` } > @@ -11479,7 +11479,7 @@ exports[`OrderDetails renders transacted orders that do not have timeDescription } > @@ -12893,7 +12893,7 @@ exports[`OrderDetails renders transacted orders that have timeDescriptionPending [] = [ }, }, }, + { + id: 'test-ramps-v2-order-1', + account: MOCK_ADDRESS, + network: '1', + cryptoAmount: '0.5', + orderType: 'BUY', + state: FIAT_ORDER_STATES.COMPLETED, + createdAt: 1697242033399, + provider: FIAT_ORDER_PROVIDERS.RAMPS_V2, + cryptocurrency: 'ETH', + amount: '1000', + currency: 'USD', + data: { + cryptoCurrency: { + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + }, + }, { id: 'test-deposit-order-1', account: MOCK_ADDRESS, @@ -219,6 +239,11 @@ jest.mock('../../../hooks/useRampNavigation', () => ({ })); describe('OrdersList', () => { + beforeEach(() => { + mockNavigate.mockClear(); + mockGoToDeposit.mockClear(); + }); + it('renders correctly', () => { render(); expect(screen.toJSON()).toMatchSnapshot(); @@ -279,29 +304,21 @@ describe('OrdersList', () => { `); }); + it('navigates to ramps order details when pressing RAMPS_V2 order item', () => { + render(, [testOrders[4]]); + + fireEvent.press(screen.getByRole('button', { name: /Purchased ETH/ })); + expect(mockNavigate).toHaveBeenCalledWith('RampsOrderDetails', { + orderId: 'test-ramps-v2-order-1', + }); + }); + it('navigates to deposit order details when pressing deposit order item', () => { render(); fireEvent.press(screen.getByRole('button', { name: 'Purchased' })); - fireEvent.press(screen.getByRole('button', { name: /USDC Deposit/ })); - expect(mockNavigate).toHaveBeenCalled(); - expect(mockNavigate.mock.calls).toMatchInlineSnapshot(` - [ - [ - "OrderDetails", - { - "orderId": "test-order-2", - }, - ], - [ - "RampsOrderDetails", - { - "orderId": "test-deposit-order-1", - }, - ], - ] - `); - expect(mockNavigate).toHaveBeenCalledWith('RampsOrderDetails', { + fireEvent.press(screen.getByRole('button', { name: /Purchased USDC/ })); + expect(mockNavigate).toHaveBeenCalledWith('DepositOrderDetails', { orderId: 'test-deposit-order-1', }); }); @@ -310,7 +327,7 @@ describe('OrdersList', () => { render(); fireEvent.press(screen.getByRole('button', { name: 'Purchased' })); - fireEvent.press(screen.getByRole('button', { name: /USDT Deposit/ })); + fireEvent.press(screen.getByRole('button', { name: /Purchased USDT/ })); expect(mockGoToDeposit).toHaveBeenCalledWith(); }); diff --git a/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.testIds.ts b/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.testIds.ts new file mode 100644 index 000000000000..86b14cac06ea --- /dev/null +++ b/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.testIds.ts @@ -0,0 +1,14 @@ +export type RampsOrderTypeSlug = 'buy' | 'sell' | 'deposit'; + +export const getOrderRowTestId = (type: RampsOrderTypeSlug, index: number) => + `orders-list-row-${type}-${index}`; + +export const getOrderRowCryptoAmountTestId = ( + type: RampsOrderTypeSlug, + index: number, +) => `orders-list-crypto-amount-${type}-${index}`; + +export const getOrderRowFiatAmountTestId = ( + type: RampsOrderTypeSlug, + index: number, +) => `orders-list-fiat-amount-${type}-${index}`; diff --git a/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.tsx b/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.tsx index 0352acf668aa..69d549095ba4 100644 --- a/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { FlatList, TouchableHighlight } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { useNavigation } from '@react-navigation/native'; @@ -7,8 +7,8 @@ import { OrderOrderTypeEnum } from '@consensys/on-ramp-sdk/dist/API'; import { createOrderDetailsNavDetails } from '../OrderDetails/OrderDetails'; import { createRampsOrderDetailsNavDetails } from '../../../Views/OrderDetails'; +import { createDepositOrderDetailsNavDetails } from '../../../Deposit/Views/DepositOrderDetails/DepositOrderDetails'; import { useRampNavigation } from '../../../hooks/useRampNavigation'; -import OrderListItem from '../../components/OrderListItem'; import createStyles from './OrdersList.styles'; import { TabEmptyState } from '../../../../../../component-library/components-temp/TabEmptyState'; @@ -16,7 +16,7 @@ import { FIAT_ORDER_PROVIDERS, FIAT_ORDER_STATES, } from '../../../../../../constants/on-ramp'; -import { FiatOrder, getOrders } from '../../../../../../reducers/fiatOrders'; +import { getOrders } from '../../../../../../reducers/fiatOrders'; import { strings } from '../../../../../../../locales/i18n'; import { useTheme } from '../../../../../../util/theme'; import ButtonFilter from '../../../../../../component-library/components-temp/ButtonFilter'; @@ -25,29 +25,187 @@ import { ButtonSize as ButtonBaseSize, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useRampsOrders } from '../../../hooks/useRampsOrders'; +import { + mergeDisplayOrders, + type DisplayOrder, +} from '../../../utils/displayOrder'; +import { toDateFormat } from '../../../../../../util/date'; +import { addCurrencySymbol, renderFiat } from '../../../../../../util/number'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../../component-library/components/Texts/Text'; +import ListItem from '../../../../../../component-library/components/List/ListItem'; +import ListItemColumn, { + WidthType, +} from '../../../../../../component-library/components/List/ListItemColumn'; +import ListItemColumnEnd from '../../components/ListItemColumnEnd'; +import { + getOrderRowTestId, + getOrderRowCryptoAmountTestId, + getOrderRowFiatAmountTestId, + type RampsOrderTypeSlug, +} from './OrdersList.testIds'; type filterType = 'ALL' | 'PURCHASE' | 'SELL'; +function getStatusColorAndText( + status: string, + orderType: string, +): [TextColor, string] { + let statusColor; + switch (status) { + case 'CANCELLED': + case 'FAILED': + statusColor = TextColor.Error; + break; + case 'COMPLETED': + statusColor = TextColor.Success; + break; + case 'PENDING': + statusColor = TextColor.Primary; + break; + case 'CREATED': + default: + statusColor = TextColor.Default; + break; + } + + let statusText; + switch (status) { + case 'CANCELLED': + statusText = strings('fiat_on_ramp_aggregator.order_status_cancelled'); + break; + case 'FAILED': + statusText = strings('fiat_on_ramp_aggregator.order_status_failed'); + break; + case 'COMPLETED': + statusText = strings('fiat_on_ramp_aggregator.order_status_completed'); + break; + case 'PENDING': + statusText = + orderType === 'BUY' + ? strings('fiat_on_ramp_aggregator.order_status_pending') + : strings('fiat_on_ramp_aggregator.order_status_processing'); + break; + case 'CREATED': + default: + statusText = strings('fiat_on_ramp_aggregator.order_status_pending'); + break; + } + + return [statusColor, statusText]; +} + +function getOrderTypeSlug(orderType: string): RampsOrderTypeSlug { + if (orderType === 'DEPOSIT') return 'deposit'; + if (orderType === 'SELL') return 'sell'; + return 'buy'; +} + +function DisplayOrderListItem({ + item, + index, +}: { + item: DisplayOrder; + index: number; +}) { + const isBuy = item.orderType === 'BUY' || item.orderType === 'DEPOSIT'; + const [statusColor, statusText] = getStatusColorAndText( + item.status, + item.orderType, + ); + const typeSlug = getOrderTypeSlug(item.orderType); + + const title = item.providerName + ? `${item.providerName}: ${strings( + isBuy + ? 'fiat_on_ramp_aggregator.purchased_currency' + : 'fiat_on_ramp_aggregator.sold_currency', + { currency: item.cryptoCurrencySymbol }, + )}` + : strings( + isBuy + ? 'fiat_on_ramp_aggregator.purchased_currency' + : 'fiat_on_ramp_aggregator.sold_currency', + { currency: item.cryptoCurrencySymbol }, + ); + + return ( + {toDateFormat(item.createdAt)} + } + topAccessoryGap={10} + > + + {title} + + {statusText} + + + + + + {item.cryptoAmount} {item.cryptoCurrencySymbol} + + + {item.fiatAmount == null + ? '...' + : addCurrencySymbol( + renderFiat(Number(item.fiatAmount), ''), + item.fiatCurrencyCode, + )} + + + + ); +} + +/** + * Merges legacy FiatOrder[] from Redux with V2 RampsOrder[] from controller into a single list + * Routes to the appropriate detail screen based on order source. + */ function OrdersList() { const { colors } = useTheme(); const styles = createStyles(colors); const navigation = useNavigation(); - const allOrders = useSelector(getOrders); + const allLegacyOrders = useSelector(getOrders); + const { orders: v2Orders } = useRampsOrders(); const [currentFilter, setCurrentFilter] = useState('ALL'); const { goToDeposit } = useRampNavigation(); const tw = useTailwind(); - const orders = allOrders.filter((order) => { - if (currentFilter === 'PURCHASE') { - return ( - order.orderType === OrderOrderTypeEnum.Buy || - order.orderType === 'DEPOSIT' - ); - } - if (currentFilter === 'SELL') { - return order.orderType === OrderOrderTypeEnum.Sell; - } - return true; - }); + + const displayOrders = useMemo( + () => mergeDisplayOrders(allLegacyOrders, v2Orders), + [allLegacyOrders, v2Orders], + ); + + const filteredOrders = useMemo( + () => + displayOrders.filter((order) => { + if (currentFilter === 'PURCHASE') { + return ( + order.orderType === OrderOrderTypeEnum.Buy || + order.orderType === 'DEPOSIT' || + order.orderType === 'BUY' + ); + } + if (currentFilter === 'SELL') { + return order.orderType === OrderOrderTypeEnum.Sell; + } + return true; + }), + [displayOrders, currentFilter], + ); const handleNavigateToAggregatorTxDetails = useCallback( (orderId: string) => { @@ -73,44 +231,74 @@ function OrdersList() { const handleNavigateToDepositTxDetails = useCallback( (orderId: string) => { - const order = orders.find((o) => o.id === orderId); + const order = allLegacyOrders.find((o) => o.id === orderId); - // CREATED state means the order is at the bank details step, - // which requires the DepositSDKProvider context for cancel/confirm actions if ( order?.state === FIAT_ORDER_STATES.CREATED && order?.provider === FIAT_ORDER_PROVIDERS.DEPOSIT ) { goToDeposit(); - } else { + } else if (order?.provider === FIAT_ORDER_PROVIDERS.DEPOSIT) { navigation.navigate( - ...createRampsOrderDetailsNavDetails({ + ...createDepositOrderDetailsNavDetails({ orderId, }), ); + } else { + handleNavigateToAggregatorTxDetails(orderId); } }, - [navigation, orders, goToDeposit], + [ + allLegacyOrders, + goToDeposit, + handleNavigateToAggregatorTxDetails, + navigation, + ], ); - const renderItem = ({ item }: { item: FiatOrder }) => ( + const handleItemPress = useCallback( + (item: DisplayOrder) => { + if (item.source === 'v2') { + handleNavigateToRampsTxDetails(item.id); + return; + } + + const legacyOrder = allLegacyOrders.find((o) => o.id === item.id); + if (!legacyOrder) return; + + if (legacyOrder.provider === FIAT_ORDER_PROVIDERS.DEPOSIT) { + handleNavigateToDepositTxDetails(item.id); + } else if (legacyOrder.provider === FIAT_ORDER_PROVIDERS.RAMPS_V2) { + handleNavigateToRampsTxDetails(item.id); + } else { + handleNavigateToAggregatorTxDetails(item.id); + } + }, + [ + allLegacyOrders, + handleNavigateToAggregatorTxDetails, + handleNavigateToRampsTxDetails, + handleNavigateToDepositTxDetails, + ], + ); + + const renderItem = ({ + item, + index, + }: { + item: DisplayOrder; + index: number; + }) => ( handleNavigateToAggregatorTxDetails(item.id) - : item.provider === FIAT_ORDER_PROVIDERS.RAMPS_V2 - ? () => handleNavigateToRampsTxDetails(item.id) - : item.provider === FIAT_ORDER_PROVIDERS.DEPOSIT - ? () => handleNavigateToDepositTxDetails(item.id) - : undefined - } + onPress={() => handleItemPress(item)} underlayColor={colors.background.alternative} activeOpacity={1} > - + ); @@ -150,9 +338,9 @@ function OrdersList() { } - data={orders} + data={filteredOrders} renderItem={renderItem} - keyExtractor={(item) => item.id} + keyExtractor={(item) => `${item.source}-${item.id}`} ListEmptyComponent={ - - - - - - - - - - - - - - - - 0.01231 + 0.01231324 ETH @@ -741,13 +594,14 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-1" > $34.23 @@ -769,10 +623,11 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-buy-2" + underlayColor="#f3f3f4" > - - - - - - - - - - - - - - - - 0.01231 + 0.01231324 ETH @@ -1007,13 +744,14 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-2" > $34.23 @@ -1035,10 +773,11 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-buy-3" + underlayColor="#f3f3f4" > - [missing "en.date.months.NaN" translation] NaN at 12:NaN am + Oct 13 at 8:07 pm - - - - - - - - - - - - - - - - Test Provider: Purchased ETH + ...: Purchased ETH - Pending + Completed - ... + 0.5 ETH @@ -1273,15 +894,16 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-3" > - ... + $1000 @@ -1301,10 +923,11 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-deposit-4" + underlayColor="#f3f3f4" > - - - - - - - - - - - - - - - - USDC Deposit + Transak: Purchased USDC 100 @@ -1539,13 +1044,14 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, "lineHeight": 22, } } + testID="orders-list-fiat-amount-deposit-4" > $100 @@ -1567,10 +1073,11 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-deposit-5" + underlayColor="#f3f3f4" > - - - - - - - - - - - - - - - - USDT Deposit + Transak: Purchased USDT 20 @@ -1805,13 +1194,14 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, "lineHeight": 22, } } + testID="orders-list-fiat-amount-deposit-5" > $20 @@ -1820,200 +1210,311 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` - - -`; - -exports[`OrdersList renders correctly 1`] = ` - - - - } - ListHeaderComponent={ - - - - All - - - Purchased - - - Sold - - - - } - data={ - [ - { - "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", - "amount": "34.23", - "createdAt": 1697242033399, - "cryptoAmount": "0.01231324", - "cryptocurrency": "ETH", - "currency": "USD", - "data": { - "cryptoCurrency": { - "decimals": 18, - "name": "Ethereum", - "symbol": "ETH", - }, - "provider": { - "name": "Test Provider", - }, - }, - "id": "test-order-1", - "network": "1", - "orderType": "BUY", - "provider": "AGGREGATOR", - "state": "COMPLETED", - }, - { - "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", - "amount": "34.23", - "createdAt": 1697242033399, - "cryptoAmount": "0.01231324", - "cryptocurrency": "ETH", - "currency": "USD", - "data": { - "cryptoCurrency": { - "decimals": 18, - "name": "Ethereum", - "symbol": "ETH", - }, - "provider": { - "name": "Test Provider", - }, - }, - "id": "test-order-2", - "network": "1", - "orderType": "SELL", - "provider": "AGGREGATOR", - "state": "PENDING", - }, - { + + + Dec 31 at 7:00 pm + + + + + + Test Provider: Purchased ETH + + + Pending + + + + + + 0 + + ETH + + + ... + + + + + + + + +`; + +exports[`OrdersList renders correctly 1`] = ` + + + + } + ListHeaderComponent={ + + + + All + + + Purchased + + + Sold + + + + } + data={ + [ + { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", - "amount": "34.23", "createdAt": 1697242033399, "cryptoAmount": "0.01231324", - "cryptocurrency": "ETH", - "currency": "USD", - "data": { - "cryptoCurrency": { - "decimals": 18, - "name": "Ethereum", - "symbol": "ETH", - }, - "provider": { - "name": "Test Provider", - }, - }, + "cryptoCurrencySymbol": "ETH", + "fiatAmount": "34.23", + "fiatCurrencyCode": "USD", + "id": "test-order-1", + "network": "1", + "orderType": "BUY", + "providerName": "Test Provider", + "source": "legacy", + "status": "COMPLETED", + }, + { + "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", + "createdAt": 1697242033399, + "cryptoAmount": "0.01231324", + "cryptoCurrencySymbol": "ETH", + "fiatAmount": "34.23", + "fiatCurrencyCode": "USD", + "id": "test-order-2", + "network": "1", + "orderType": "SELL", + "providerName": "Test Provider", + "source": "legacy", + "status": "PENDING", + }, + { + "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", + "createdAt": 1697242033399, + "cryptoAmount": "0.01231324", + "cryptoCurrencySymbol": "ETH", + "fiatAmount": "34.23", + "fiatCurrencyCode": "USD", "id": "test-order-3", "network": "1", "orderType": "BUY", - "provider": "AGGREGATOR", - "state": "PENDING", + "providerName": "Test Provider", + "source": "legacy", + "status": "PENDING", }, { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", - "cryptocurrency": "ETH", - "currency": "USD", - "data": { - "cryptoCurrency": { - "decimals": 18, - "name": "Ethereum", - "symbol": "ETH", - }, - "provider": { - "name": "Test Provider", - }, - }, - "id": "test-order-4", + "createdAt": 1697242033399, + "cryptoAmount": "0.5", + "cryptoCurrencySymbol": "ETH", + "fiatAmount": "1000", + "fiatCurrencyCode": "USD", + "id": "test-ramps-v2-order-1", "network": "1", "orderType": "BUY", - "provider": "AGGREGATOR", - "state": "PENDING", + "providerName": "...", + "source": "legacy", + "status": "COMPLETED", }, { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", - "amount": "100", "createdAt": 1697242033399, "cryptoAmount": "100", - "cryptocurrency": "USDC", - "currency": "USD", - "data": { - "cryptoCurrency": { - "assetId": "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "chainId": "eip155:1", - "decimals": 6, - "iconUrl": "https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.png", - "name": "USD Coin", - "symbol": "USDC", - }, - "providerOrderLink": "https://transak.com/order/123", - }, + "cryptoCurrencySymbol": "USDC", + "fiatAmount": "100", + "fiatCurrencyCode": "USD", "id": "test-deposit-order-1", "network": "1", "orderType": "DEPOSIT", - "provider": "DEPOSIT", - "state": "COMPLETED", + "providerName": "Transak", + "source": "legacy", + "status": "COMPLETED", }, { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", - "amount": "20", "createdAt": 1697242033399, "cryptoAmount": "20", - "cryptocurrency": "USDT", - "currency": "USD", - "data": { - "cryptoCurrency": { - "assetId": "eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7", - "chainId": "eip155:1", - "decimals": 6, - "iconUrl": "https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xdAC17F958D2ee523a2206206994597C13D831ec7.png", - "name": "Tether USD", - "symbol": "USDT", - "unsupported": true, - }, - }, + "cryptoCurrencySymbol": "USDT", + "fiatAmount": "20", + "fiatCurrencyCode": "USD", "id": "test-deposit-order-2", "network": "1", "orderType": "DEPOSIT", - "provider": "DEPOSIT", - "state": "CREATED", + "providerName": "Transak", + "source": "legacy", + "status": "CREATED", + }, + { + "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", + "createdAt": 0, + "cryptoAmount": 0, + "cryptoCurrencySymbol": "ETH", + "fiatAmount": undefined, + "fiatCurrencyCode": "USD", + "id": "test-order-4", + "network": "1", + "orderType": "BUY", + "providerName": "Test Provider", + "source": "legacy", + "status": "PENDING", }, ] } @@ -2106,7 +1607,7 @@ exports[`OrdersList renders correctly 1`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -2120,7 +1621,7 @@ exports[`OrdersList renders correctly 1`] = ` }, [ { - "backgroundColor": "#121314", + "backgroundColor": "#131416", }, undefined, ], @@ -2194,7 +1695,7 @@ exports[`OrdersList renders correctly 1`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -2208,7 +1709,7 @@ exports[`OrdersList renders correctly 1`] = ` }, [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", }, undefined, ], @@ -2227,7 +1728,7 @@ exports[`OrdersList renders correctly 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "flexGrow": 0, "flexShrink": 1, "flexWrap": "wrap", @@ -2282,7 +1783,7 @@ exports[`OrdersList renders correctly 1`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -2296,7 +1797,7 @@ exports[`OrdersList renders correctly 1`] = ` }, [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", }, undefined, ], @@ -2315,7 +1816,7 @@ exports[`OrdersList renders correctly 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "flexGrow": 0, "flexShrink": 1, "flexWrap": "wrap", @@ -2350,10 +1851,11 @@ exports[`OrdersList renders correctly 1`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-buy-1" + underlayColor="#f3f3f4" > - - - - - - - - - - - - - - - - 0.01231 + 0.01231324 ETH @@ -2588,13 +1972,14 @@ exports[`OrdersList renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-1" > $34.23 @@ -2616,10 +2001,11 @@ exports[`OrdersList renders correctly 1`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-sell-2" + underlayColor="#f3f3f4" > - - - - - - - - - - - - - - - - 0.01231 + 0.01231324 ETH @@ -2854,13 +2122,14 @@ exports[`OrdersList renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, "lineHeight": 22, } } + testID="orders-list-fiat-amount-sell-2" > $34.23 @@ -2882,10 +2151,11 @@ exports[`OrdersList renders correctly 1`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-buy-3" + underlayColor="#f3f3f4" > - - - - - - - - - - - - - - - - 0.01231 + 0.01231324 ETH @@ -3120,13 +2272,14 @@ exports[`OrdersList renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-3" > $34.23 @@ -3148,10 +2301,11 @@ exports[`OrdersList renders correctly 1`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-buy-4" + underlayColor="#f3f3f4" > - [missing "en.date.months.NaN" translation] NaN at 12:NaN am + Oct 13 at 8:07 pm - - - - - - - - - - - - - - - - Test Provider: Purchased ETH + ...: Purchased ETH - Pending + Completed - ... + 0.5 ETH @@ -3386,15 +2422,16 @@ exports[`OrdersList renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-4" > - ... + $1000 @@ -3414,10 +2451,11 @@ exports[`OrdersList renders correctly 1`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-deposit-5" + underlayColor="#f3f3f4" > - - - - - + - - - - - - - + } + > + Completed + + + + 100 + + USDC + + + $100 + + + + + + + + + + + + Oct 13 at 8:07 pm + + + - USDC Deposit + Transak: Purchased USDT - Completed + Pending - 100 + 20 - USDC + USDT - $100 + $20 @@ -3680,10 +2751,11 @@ exports[`OrdersList renders correctly 1`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-buy-7" + underlayColor="#f3f3f4" > - Oct 13 at 8:07 pm + Dec 31 at 7:00 pm - - - - - - - - - - - - - - - - USDT Deposit + Test Provider: Purchased ETH - 20 + 0 - USDT + ETH - $20 + ... @@ -4079,7 +3034,7 @@ exports[`OrdersList renders empty buy message 1`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -4093,7 +3048,7 @@ exports[`OrdersList renders empty buy message 1`] = ` }, [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", }, undefined, ], @@ -4112,7 +3067,7 @@ exports[`OrdersList renders empty buy message 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "flexGrow": 0, "flexShrink": 1, "flexWrap": "wrap", @@ -4167,7 +3122,7 @@ exports[`OrdersList renders empty buy message 1`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -4181,7 +3136,7 @@ exports[`OrdersList renders empty buy message 1`] = ` }, [ { - "backgroundColor": "#121314", + "backgroundColor": "#131416", }, undefined, ], @@ -4255,7 +3210,7 @@ exports[`OrdersList renders empty buy message 1`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -4269,7 +3224,7 @@ exports[`OrdersList renders empty buy message 1`] = ` }, [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", }, undefined, ], @@ -4288,7 +3243,7 @@ exports[`OrdersList renders empty buy message 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "flexGrow": 0, "flexShrink": 1, "flexWrap": "wrap", @@ -4346,7 +3301,7 @@ exports[`OrdersList renders empty buy message 1`] = ` style={ [ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "fontWeight": 400, @@ -4508,7 +3463,7 @@ exports[`OrdersList renders empty sell message 1`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -4522,7 +3477,7 @@ exports[`OrdersList renders empty sell message 1`] = ` }, [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", }, undefined, ], @@ -4541,7 +3496,7 @@ exports[`OrdersList renders empty sell message 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "flexGrow": 0, "flexShrink": 1, "flexWrap": "wrap", @@ -4596,7 +3551,7 @@ exports[`OrdersList renders empty sell message 1`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -4610,7 +3565,7 @@ exports[`OrdersList renders empty sell message 1`] = ` }, [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", }, undefined, ], @@ -4629,7 +3584,7 @@ exports[`OrdersList renders empty sell message 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "flexGrow": 0, "flexShrink": 1, "flexWrap": "wrap", @@ -4684,7 +3639,7 @@ exports[`OrdersList renders empty sell message 1`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -4698,7 +3653,7 @@ exports[`OrdersList renders empty sell message 1`] = ` }, [ { - "backgroundColor": "#121314", + "backgroundColor": "#131416", }, undefined, ], @@ -4775,7 +3730,7 @@ exports[`OrdersList renders empty sell message 1`] = ` style={ [ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "fontWeight": 400, @@ -4851,26 +3806,17 @@ exports[`OrdersList renders sell only correctly when pressing sell filter 1`] = [ { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", - "amount": "34.23", "createdAt": 1697242033399, "cryptoAmount": "0.01231324", - "cryptocurrency": "ETH", - "currency": "USD", - "data": { - "cryptoCurrency": { - "decimals": 18, - "name": "Ethereum", - "symbol": "ETH", - }, - "provider": { - "name": "Test Provider", - }, - }, + "cryptoCurrencySymbol": "ETH", + "fiatAmount": "34.23", + "fiatCurrencyCode": "USD", "id": "test-order-2", "network": "1", "orderType": "SELL", - "provider": "AGGREGATOR", - "state": "PENDING", + "providerName": "Test Provider", + "source": "legacy", + "status": "PENDING", }, ] } @@ -4963,7 +3909,7 @@ exports[`OrdersList renders sell only correctly when pressing sell filter 1`] = { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -4977,7 +3923,7 @@ exports[`OrdersList renders sell only correctly when pressing sell filter 1`] = }, [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", }, undefined, ], @@ -4996,7 +3942,7 @@ exports[`OrdersList renders sell only correctly when pressing sell filter 1`] = style={ [ { - "color": "#121314", + "color": "#131416", "flexGrow": 0, "flexShrink": 1, "flexWrap": "wrap", @@ -5051,7 +3997,7 @@ exports[`OrdersList renders sell only correctly when pressing sell filter 1`] = { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -5065,7 +4011,7 @@ exports[`OrdersList renders sell only correctly when pressing sell filter 1`] = }, [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", }, undefined, ], @@ -5084,7 +4030,7 @@ exports[`OrdersList renders sell only correctly when pressing sell filter 1`] = style={ [ { - "color": "#121314", + "color": "#131416", "flexGrow": 0, "flexShrink": 1, "flexWrap": "wrap", @@ -5139,7 +4085,7 @@ exports[`OrdersList renders sell only correctly when pressing sell filter 1`] = { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -5153,7 +4099,7 @@ exports[`OrdersList renders sell only correctly when pressing sell filter 1`] = }, [ { - "backgroundColor": "#121314", + "backgroundColor": "#131416", }, undefined, ], @@ -5207,10 +4153,11 @@ exports[`OrdersList renders sell only correctly when pressing sell filter 1`] = style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-sell-1" + underlayColor="#f3f3f4" > - - - - - - - - - - - - - - - - 0.01231 + 0.01231324 ETH @@ -5445,13 +4274,14 @@ exports[`OrdersList renders sell only correctly when pressing sell filter 1`] = accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, "lineHeight": 22, } } + testID="orders-list-fiat-amount-sell-1" > $34.23 @@ -5520,26 +4350,17 @@ exports[`OrdersList resets filter to all after other filter was set 1`] = ` [ { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", - "amount": "34.23", "createdAt": 1697242033399, "cryptoAmount": "0.01231324", - "cryptocurrency": "ETH", - "currency": "USD", - "data": { - "cryptoCurrency": { - "decimals": 18, - "name": "Ethereum", - "symbol": "ETH", - }, - "provider": { - "name": "Test Provider", - }, - }, + "cryptoCurrencySymbol": "ETH", + "fiatAmount": "34.23", + "fiatCurrencyCode": "USD", "id": "test-order-2", "network": "1", "orderType": "SELL", - "provider": "AGGREGATOR", - "state": "PENDING", + "providerName": "Test Provider", + "source": "legacy", + "status": "PENDING", }, ] } @@ -5632,7 +4453,7 @@ exports[`OrdersList resets filter to all after other filter was set 1`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -5646,7 +4467,7 @@ exports[`OrdersList resets filter to all after other filter was set 1`] = ` }, [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", }, undefined, ], @@ -5665,7 +4486,7 @@ exports[`OrdersList resets filter to all after other filter was set 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "flexGrow": 0, "flexShrink": 1, "flexWrap": "wrap", @@ -5720,7 +4541,7 @@ exports[`OrdersList resets filter to all after other filter was set 1`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -5734,7 +4555,7 @@ exports[`OrdersList resets filter to all after other filter was set 1`] = ` }, [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", }, undefined, ], @@ -5753,7 +4574,7 @@ exports[`OrdersList resets filter to all after other filter was set 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "flexGrow": 0, "flexShrink": 1, "flexWrap": "wrap", @@ -5808,7 +4629,7 @@ exports[`OrdersList resets filter to all after other filter was set 1`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -5822,7 +4643,7 @@ exports[`OrdersList resets filter to all after other filter was set 1`] = ` }, [ { - "backgroundColor": "#121314", + "backgroundColor": "#131416", }, undefined, ], @@ -5876,10 +4697,11 @@ exports[`OrdersList resets filter to all after other filter was set 1`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-sell-1" + underlayColor="#f3f3f4" > - - - - - - - - - - - - - - - - 0.01231 + 0.01231324 ETH @@ -6114,13 +4818,14 @@ exports[`OrdersList resets filter to all after other filter was set 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, "lineHeight": 22, } } + testID="orders-list-fiat-amount-sell-1" > $34.23 @@ -6189,140 +4894,101 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` [ { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", - "amount": "34.23", "createdAt": 1697242033399, "cryptoAmount": "0.01231324", - "cryptocurrency": "ETH", - "currency": "USD", - "data": { - "cryptoCurrency": { - "decimals": 18, - "name": "Ethereum", - "symbol": "ETH", - }, - "provider": { - "name": "Test Provider", - }, - }, + "cryptoCurrencySymbol": "ETH", + "fiatAmount": "34.23", + "fiatCurrencyCode": "USD", "id": "test-order-1", "network": "1", "orderType": "BUY", - "provider": "AGGREGATOR", - "state": "COMPLETED", + "providerName": "Test Provider", + "source": "legacy", + "status": "COMPLETED", }, { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", - "amount": "34.23", "createdAt": 1697242033399, "cryptoAmount": "0.01231324", - "cryptocurrency": "ETH", - "currency": "USD", - "data": { - "cryptoCurrency": { - "decimals": 18, - "name": "Ethereum", - "symbol": "ETH", - }, - "provider": { - "name": "Test Provider", - }, - }, + "cryptoCurrencySymbol": "ETH", + "fiatAmount": "34.23", + "fiatCurrencyCode": "USD", "id": "test-order-2", "network": "1", "orderType": "SELL", - "provider": "AGGREGATOR", - "state": "PENDING", + "providerName": "Test Provider", + "source": "legacy", + "status": "PENDING", }, { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", - "amount": "34.23", "createdAt": 1697242033399, "cryptoAmount": "0.01231324", - "cryptocurrency": "ETH", - "currency": "USD", - "data": { - "cryptoCurrency": { - "decimals": 18, - "name": "Ethereum", - "symbol": "ETH", - }, - "provider": { - "name": "Test Provider", - }, - }, + "cryptoCurrencySymbol": "ETH", + "fiatAmount": "34.23", + "fiatCurrencyCode": "USD", "id": "test-order-3", "network": "1", "orderType": "BUY", - "provider": "AGGREGATOR", - "state": "PENDING", + "providerName": "Test Provider", + "source": "legacy", + "status": "PENDING", }, { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", - "cryptocurrency": "ETH", - "currency": "USD", - "data": { - "cryptoCurrency": { - "decimals": 18, - "name": "Ethereum", - "symbol": "ETH", - }, - "provider": { - "name": "Test Provider", - }, - }, - "id": "test-order-4", + "createdAt": 1697242033399, + "cryptoAmount": "0.5", + "cryptoCurrencySymbol": "ETH", + "fiatAmount": "1000", + "fiatCurrencyCode": "USD", + "id": "test-ramps-v2-order-1", "network": "1", "orderType": "BUY", - "provider": "AGGREGATOR", - "state": "PENDING", + "providerName": "...", + "source": "legacy", + "status": "COMPLETED", }, { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", - "amount": "100", "createdAt": 1697242033399, "cryptoAmount": "100", - "cryptocurrency": "USDC", - "currency": "USD", - "data": { - "cryptoCurrency": { - "assetId": "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - "chainId": "eip155:1", - "decimals": 6, - "iconUrl": "https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.png", - "name": "USD Coin", - "symbol": "USDC", - }, - "providerOrderLink": "https://transak.com/order/123", - }, + "cryptoCurrencySymbol": "USDC", + "fiatAmount": "100", + "fiatCurrencyCode": "USD", "id": "test-deposit-order-1", "network": "1", "orderType": "DEPOSIT", - "provider": "DEPOSIT", - "state": "COMPLETED", + "providerName": "Transak", + "source": "legacy", + "status": "COMPLETED", }, { "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", - "amount": "20", "createdAt": 1697242033399, "cryptoAmount": "20", - "cryptocurrency": "USDT", - "currency": "USD", - "data": { - "cryptoCurrency": { - "assetId": "eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7", - "chainId": "eip155:1", - "decimals": 6, - "iconUrl": "https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xdAC17F958D2ee523a2206206994597C13D831ec7.png", - "name": "Tether USD", - "symbol": "USDT", - "unsupported": true, - }, - }, + "cryptoCurrencySymbol": "USDT", + "fiatAmount": "20", + "fiatCurrencyCode": "USD", "id": "test-deposit-order-2", "network": "1", "orderType": "DEPOSIT", - "provider": "DEPOSIT", - "state": "CREATED", + "providerName": "Transak", + "source": "legacy", + "status": "CREATED", + }, + { + "account": "0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A", + "createdAt": 0, + "cryptoAmount": 0, + "cryptoCurrencySymbol": "ETH", + "fiatAmount": undefined, + "fiatCurrencyCode": "USD", + "id": "test-order-4", + "network": "1", + "orderType": "BUY", + "providerName": "Test Provider", + "source": "legacy", + "status": "PENDING", }, ] } @@ -6415,7 +5081,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -6429,7 +5095,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` }, [ { - "backgroundColor": "#121314", + "backgroundColor": "#131416", }, undefined, ], @@ -6503,7 +5169,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -6517,7 +5183,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` }, [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", }, undefined, ], @@ -6536,7 +5202,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "flexGrow": 0, "flexShrink": 1, "flexWrap": "wrap", @@ -6591,7 +5257,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` { "alignItems": "center", "alignSelf": "flex-start", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 12, "columnGap": 8, "flexDirection": "row", @@ -6605,7 +5271,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` }, [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", }, undefined, ], @@ -6622,29 +5288,179 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` + Sold + + + + + + + + + + + + Oct 13 at 8:07 pm + + + + + + Test Provider: Purchased ETH + + + Completed + + + + + - Sold + 0.01231324 + + ETH + + + $34.23 - - + + - - - - - - - - - - - - - - - - Test Provider: Purchased ETH + Test Provider: Sold ETH - Completed + Processing - 0.01231 + 0.01231324 ETH @@ -6897,13 +5596,14 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, "lineHeight": 22, } } + testID="orders-list-fiat-amount-sell-2" > $34.23 @@ -6925,10 +5625,11 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-buy-3" + underlayColor="#f3f3f4" > - - - - - - - - - - - - - - - - Test Provider: Sold ETH + Test Provider: Purchased ETH - Processing + Pending - 0.01231 + 0.01231324 ETH @@ -7163,13 +5746,14 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-3" > $34.23 @@ -7191,10 +5775,11 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-buy-4" + underlayColor="#f3f3f4" > - - - - - - - - - - - - - - - - Test Provider: Purchased ETH + ...: Purchased ETH - Pending + Completed - 0.01231 + 0.5 ETH @@ -7429,15 +5896,16 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 14, "letterSpacing": 0, "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-4" > - $34.23 + $1000 @@ -7457,10 +5925,11 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-deposit-5" + underlayColor="#f3f3f4" > - [missing "en.date.months.NaN" translation] NaN at 12:NaN am + Oct 13 at 8:07 pm - - - - - - - - - - - - - - - - Test Provider: Purchased ETH + Transak: Purchased USDC - Pending + Completed - ... + 100 - ETH + USDC - ... + $100 @@ -7723,10 +6075,11 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-deposit-6" + underlayColor="#f3f3f4" > - - - - - - - - - - - - - - - - USDC Deposit + Transak: Purchased USDT - Completed + Pending - 100 + 20 - USDC + USDT - $100 + $20 @@ -7989,10 +6225,11 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` style={ { "borderBottomWidth": 0.5, - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", } } - underlayColor="#f3f5f9" + testID="orders-list-row-buy-7" + underlayColor="#f3f3f4" > - Oct 13 at 8:07 pm + Dec 31 at 7:00 pm - - - - - - - - - - - - - - - - USDT Deposit + Test Provider: Purchased ETH - 20 + 0 - USDT + ETH - $20 + ... diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap index 6aa5b9de2bdc..72c64b7c7fc4 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap @@ -24,7 +24,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` style={ [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 8, "padding": 16, }, @@ -66,7 +66,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` style={ [ { - "backgroundColor": "#858b9a14", + "backgroundColor": "#b4b4b528", "borderRadius": 30, "padding": 14, }, @@ -112,7 +112,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` style={ [ { - "backgroundColor": "#858b9a14", + "backgroundColor": "#b4b4b528", "borderRadius": 30, "padding": 14, }, @@ -167,7 +167,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` style={ [ { - "backgroundColor": "#858b9a14", + "backgroundColor": "#b4b4b528", "borderRadius": 30, "padding": 14, }, @@ -213,7 +213,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` style={ [ { - "backgroundColor": "#858b9a14", + "backgroundColor": "#b4b4b528", "borderRadius": 30, "padding": 14, }, @@ -264,7 +264,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` style={ [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 8, "padding": 16, }, @@ -306,7 +306,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` style={ [ { - "backgroundColor": "#858b9a14", + "backgroundColor": "#b4b4b528", "borderRadius": 30, "padding": 14, }, @@ -352,7 +352,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` style={ [ { - "backgroundColor": "#858b9a14", + "backgroundColor": "#b4b4b528", "borderRadius": 30, "padding": 14, }, @@ -401,7 +401,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` style={ [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 8, "padding": 16, }, @@ -443,7 +443,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` style={ [ { - "backgroundColor": "#858b9a14", + "backgroundColor": "#b4b4b528", "borderRadius": 30, "padding": 14, }, @@ -489,7 +489,7 @@ exports[`LoadingQuotes component renders correctly 1`] = ` style={ [ { - "backgroundColor": "#858b9a14", + "backgroundColor": "#b4b4b528", "borderRadius": 30, "padding": 14, }, @@ -638,7 +638,7 @@ exports[`Quotes custom action renders correctly after animation with the recomme style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -679,7 +679,7 @@ exports[`Quotes custom action renders correctly after animation with the recomme style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 16, "fontWeight": 400, @@ -905,7 +905,7 @@ exports[`Quotes custom action renders correctly after animation with the recomme style={ [ { - "backgroundColor": "#3f434a66", + "backgroundColor": "#0a0d135c", "bottom": 0, "left": 0, "position": "absolute", @@ -955,7 +955,7 @@ exports[`Quotes custom action renders correctly after animation with the recomme [ { "backgroundColor": "#ffffff", - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", "borderTopLeftRadius": 24, "borderTopRightRadius": 24, "borderWidth": 1, @@ -992,7 +992,7 @@ exports[`Quotes custom action renders correctly after animation with the recomme @@ -824,7 +824,7 @@ exports[`Settings Activation Keys renders correctly when is loading 1`] = ` selectable={true} style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -839,7 +839,7 @@ exports[`Settings Activation Keys renders correctly when is loading 1`] = ` selectable={true} style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1011,13 +1011,13 @@ exports[`Settings Activation Keys renders correctly when is loading 1`] = ` "width": 51, }, { - "backgroundColor": "#b7bbc866", + "backgroundColor": "#b4b4b566", "borderRadius": 16, }, ] } thumbTintColor="#ffffff" - tintColor="#b7bbc866" + tintColor="#b4b4b566" value={false} /> @@ -1043,7 +1043,7 @@ exports[`Settings Activation Keys renders correctly when is loading 1`] = ` selectable={true} style={ { - "color": "#b7bbc8", + "color": "#babbbe", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1058,7 +1058,7 @@ exports[`Settings Activation Keys renders correctly when is loading 1`] = ` selectable={true} style={ { - "color": "#b7bbc8", + "color": "#babbbe", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1215,7 +1215,7 @@ exports[`Settings Activation Keys renders correctly when is loading 1`] = ` { "alignItems": "center", "alignSelf": "stretch", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderColor": "transparent", "borderRadius": 12, "borderWidth": 1, @@ -1232,7 +1232,7 @@ exports[`Settings Activation Keys renders correctly when is loading 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", "fontSize": 16, "letterSpacing": 0, @@ -1671,7 +1671,7 @@ exports[`Settings Activation Keys renders correctly when there are no keys 1`] = style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -1712,7 +1712,7 @@ exports[`Settings Activation Keys renders correctly when there are no keys 1`] = style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 16, "fontWeight": 400, @@ -1813,9 +1813,9 @@ exports[`Settings Activation Keys renders correctly when there are no keys 1`] = accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, } @@ -1852,7 +1852,7 @@ exports[`Settings Activation Keys renders correctly when there are no keys 1`] = accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1884,7 +1884,7 @@ exports[`Settings Activation Keys renders correctly when there are no keys 1`] = accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1908,7 +1908,7 @@ exports[`Settings Activation Keys renders correctly when there are no keys 1`] = { "alignItems": "center", "alignSelf": "stretch", - "backgroundColor": "#121314", + "backgroundColor": "#131416", "borderRadius": 12, "flexDirection": "row", "height": 48, @@ -1950,7 +1950,7 @@ exports[`Settings Activation Keys renders correctly when there are no keys 1`] = accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1962,9 +1962,9 @@ exports[`Settings Activation Keys renders correctly when there are no keys 1`] = accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, } @@ -1976,7 +1976,7 @@ exports[`Settings Activation Keys renders correctly when there are no keys 1`] = accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -2003,7 +2003,7 @@ exports[`Settings Activation Keys renders correctly when there are no keys 1`] = accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -2038,7 +2038,7 @@ exports[`Settings Activation Keys renders correctly when there are no keys 1`] = { "alignItems": "center", "alignSelf": "stretch", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderColor": "transparent", "borderRadius": 12, "borderWidth": 1, @@ -2054,7 +2054,7 @@ exports[`Settings Activation Keys renders correctly when there are no keys 1`] = accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", "fontSize": 16, "letterSpacing": 0, @@ -2493,7 +2493,7 @@ exports[`Settings Region V2 disabled (Original) renders correctly when region is style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -2534,7 +2534,7 @@ exports[`Settings Region V2 disabled (Original) renders correctly when region is style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 16, "fontWeight": 400, @@ -2635,9 +2635,9 @@ exports[`Settings Region V2 disabled (Original) renders correctly when region is accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, } @@ -2674,7 +2674,7 @@ exports[`Settings Region V2 disabled (Original) renders correctly when region is accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -2706,7 +2706,7 @@ exports[`Settings Region V2 disabled (Original) renders correctly when region is accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -3146,7 +3146,7 @@ exports[`Settings Region V2 disabled (Original) renders correctly when region is style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -3187,7 +3187,7 @@ exports[`Settings Region V2 disabled (Original) renders correctly when region is style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 16, "fontWeight": 400, @@ -3288,9 +3288,9 @@ exports[`Settings Region V2 disabled (Original) renders correctly when region is accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, } @@ -3327,7 +3327,7 @@ exports[`Settings Region V2 disabled (Original) renders correctly when region is accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -3359,7 +3359,7 @@ exports[`Settings Region V2 disabled (Original) renders correctly when region is accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -3383,7 +3383,7 @@ exports[`Settings Region V2 disabled (Original) renders correctly when region is { "alignItems": "center", "alignSelf": "stretch", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderColor": "transparent", "borderRadius": 12, "borderWidth": 1, @@ -3399,7 +3399,7 @@ exports[`Settings Region V2 disabled (Original) renders correctly when region is accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", "fontSize": 16, "letterSpacing": 0, @@ -3837,7 +3837,7 @@ exports[`Settings Region V2 enabled renders correctly when region has state 1`] style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -3878,7 +3878,7 @@ exports[`Settings Region V2 enabled renders correctly when region has state 1`] style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 16, "fontWeight": 400, @@ -3979,9 +3979,9 @@ exports[`Settings Region V2 enabled renders correctly when region has state 1`] accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, } @@ -4018,7 +4018,7 @@ exports[`Settings Region V2 enabled renders correctly when region has state 1`] accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -4050,7 +4050,7 @@ exports[`Settings Region V2 enabled renders correctly when region has state 1`] accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -4074,7 +4074,7 @@ exports[`Settings Region V2 enabled renders correctly when region has state 1`] { "alignItems": "center", "alignSelf": "stretch", - "backgroundColor": "#121314", + "backgroundColor": "#131416", "borderRadius": 12, "flexDirection": "row", "height": 48, @@ -4526,7 +4526,7 @@ exports[`Settings Region V2 enabled renders correctly when region is country onl style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -4567,7 +4567,7 @@ exports[`Settings Region V2 enabled renders correctly when region is country onl style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 16, "fontWeight": 400, @@ -4668,9 +4668,9 @@ exports[`Settings Region V2 enabled renders correctly when region is country onl accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, } @@ -4707,7 +4707,7 @@ exports[`Settings Region V2 enabled renders correctly when region is country onl accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -4739,7 +4739,7 @@ exports[`Settings Region V2 enabled renders correctly when region is country onl accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -4763,7 +4763,7 @@ exports[`Settings Region V2 enabled renders correctly when region is country onl { "alignItems": "center", "alignSelf": "stretch", - "backgroundColor": "#121314", + "backgroundColor": "#131416", "borderRadius": 12, "flexDirection": "row", "height": 48, @@ -5215,7 +5215,7 @@ exports[`Settings Region V2 enabled renders correctly when region is not set 1`] style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -5256,7 +5256,7 @@ exports[`Settings Region V2 enabled renders correctly when region is not set 1`] style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 16, "fontWeight": 400, @@ -5357,9 +5357,9 @@ exports[`Settings Region V2 enabled renders correctly when region is not set 1`] accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, } @@ -5396,7 +5396,7 @@ exports[`Settings Region V2 enabled renders correctly when region is not set 1`] accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -5428,7 +5428,7 @@ exports[`Settings Region V2 enabled renders correctly when region is not set 1`] accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -5452,7 +5452,7 @@ exports[`Settings Region V2 enabled renders correctly when region is not set 1`] { "alignItems": "center", "alignSelf": "stretch", - "backgroundColor": "#121314", + "backgroundColor": "#131416", "borderRadius": 12, "flexDirection": "row", "height": 48, @@ -5904,7 +5904,7 @@ exports[`Settings Region V2 enabled renders correctly when region is set 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -5945,7 +5945,7 @@ exports[`Settings Region V2 enabled renders correctly when region is set 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 16, "fontWeight": 400, @@ -6046,9 +6046,9 @@ exports[`Settings Region V2 enabled renders correctly when region is set 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, } @@ -6085,7 +6085,7 @@ exports[`Settings Region V2 enabled renders correctly when region is set 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -6117,7 +6117,7 @@ exports[`Settings Region V2 enabled renders correctly when region is set 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -6141,7 +6141,7 @@ exports[`Settings Region V2 enabled renders correctly when region is set 1`] = ` { "alignItems": "center", "alignSelf": "stretch", - "backgroundColor": "#121314", + "backgroundColor": "#131416", "borderRadius": 12, "flexDirection": "row", "height": 48, @@ -6593,7 +6593,7 @@ exports[`Settings renders correctly 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -6634,7 +6634,7 @@ exports[`Settings renders correctly 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 16, "fontWeight": 400, @@ -6735,9 +6735,9 @@ exports[`Settings renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, } @@ -6774,7 +6774,7 @@ exports[`Settings renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -6806,7 +6806,7 @@ exports[`Settings renders correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -6830,7 +6830,7 @@ exports[`Settings renders correctly 1`] = ` { "alignItems": "center", "alignSelf": "stretch", - "backgroundColor": "#121314", + "backgroundColor": "#131416", "borderRadius": 12, "flexDirection": "row", "height": 48, @@ -7282,7 +7282,7 @@ exports[`Settings renders correctly for internal builds 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -7323,7 +7323,7 @@ exports[`Settings renders correctly for internal builds 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 16, "fontWeight": 400, @@ -7424,9 +7424,9 @@ exports[`Settings renders correctly for internal builds 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, } @@ -7463,7 +7463,7 @@ exports[`Settings renders correctly for internal builds 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -7495,7 +7495,7 @@ exports[`Settings renders correctly for internal builds 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -7519,7 +7519,7 @@ exports[`Settings renders correctly for internal builds 1`] = ` { "alignItems": "center", "alignSelf": "stretch", - "backgroundColor": "#121314", + "backgroundColor": "#131416", "borderRadius": 12, "flexDirection": "row", "height": 48, @@ -7561,7 +7561,7 @@ exports[`Settings renders correctly for internal builds 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -7573,9 +7573,9 @@ exports[`Settings renders correctly for internal builds 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, } @@ -7587,7 +7587,7 @@ exports[`Settings renders correctly for internal builds 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -7614,7 +7614,7 @@ exports[`Settings renders correctly for internal builds 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -7664,13 +7664,13 @@ exports[`Settings renders correctly for internal builds 1`] = ` "width": 51, }, { - "backgroundColor": "#b7bbc866", + "backgroundColor": "#b4b4b566", "borderRadius": 16, }, ] } thumbTintColor="#ffffff" - tintColor="#b7bbc866" + tintColor="#b4b4b566" value={true} /> @@ -7696,7 +7696,7 @@ exports[`Settings renders correctly for internal builds 1`] = ` selectable={true} style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -7711,7 +7711,7 @@ exports[`Settings renders correctly for internal builds 1`] = ` selectable={true} style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -7883,13 +7883,13 @@ exports[`Settings renders correctly for internal builds 1`] = ` "width": 51, }, { - "backgroundColor": "#b7bbc866", + "backgroundColor": "#b4b4b566", "borderRadius": 16, }, ] } thumbTintColor="#ffffff" - tintColor="#b7bbc866" + tintColor="#b4b4b566" value={false} /> @@ -7915,7 +7915,7 @@ exports[`Settings renders correctly for internal builds 1`] = ` selectable={true} style={ { - "color": "#b7bbc8", + "color": "#babbbe", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -7930,7 +7930,7 @@ exports[`Settings renders correctly for internal builds 1`] = ` selectable={true} style={ { - "color": "#b7bbc8", + "color": "#babbbe", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -8087,7 +8087,7 @@ exports[`Settings renders correctly for internal builds 1`] = ` { "alignItems": "center", "alignSelf": "stretch", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderColor": "transparent", "borderRadius": 12, "borderWidth": 1, @@ -8103,7 +8103,7 @@ exports[`Settings renders correctly for internal builds 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Medium", "fontSize": 16, "letterSpacing": 0, diff --git a/app/components/UI/Ramp/Aggregator/components/CustomAction/__snapshots__/CustomAction.test.tsx.snap b/app/components/UI/Ramp/Aggregator/components/CustomAction/__snapshots__/CustomAction.test.tsx.snap index fe0f732c171f..856c95346c2e 100644 --- a/app/components/UI/Ramp/Aggregator/components/CustomAction/__snapshots__/CustomAction.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/components/CustomAction/__snapshots__/CustomAction.test.tsx.snap @@ -19,7 +19,7 @@ exports[`CustomAction Component shows loading indicator when isLoading is true 1 style={ [ { - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 8, "padding": 16, }, @@ -84,7 +84,7 @@ exports[`CustomAction Component shows loading indicator when isLoading is true 1 "fontSize": 12, }, { - "color": "#686e7d", + "color": "#66676a", "marginLeft": 8, }, { @@ -149,7 +149,7 @@ exports[`CustomAction Component shows loading indicator when isLoading is true 1 { "alignItems": "center", "alignSelf": "stretch", - "backgroundColor": "#121314", + "backgroundColor": "#131416", "borderRadius": 12, "flexDirection": "row", "height": 48, diff --git a/app/components/UI/Ramp/Aggregator/components/FiatSelectorModal/__snapshots__/FiatSelectorModal.test.tsx.snap b/app/components/UI/Ramp/Aggregator/components/FiatSelectorModal/__snapshots__/FiatSelectorModal.test.tsx.snap index c9cafe5212d7..3babdfca1f8a 100644 --- a/app/components/UI/Ramp/Aggregator/components/FiatSelectorModal/__snapshots__/FiatSelectorModal.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/components/FiatSelectorModal/__snapshots__/FiatSelectorModal.test.tsx.snap @@ -334,7 +334,7 @@ exports[`FiatSelectorModal renders the modal with currency list 1`] = ` style={ [ { - "backgroundColor": "#3f434a66", + "backgroundColor": "#0a0d135c", "bottom": 0, "left": 0, "position": "absolute", @@ -384,7 +384,7 @@ exports[`FiatSelectorModal renders the modal with currency list 1`] = ` [ { "backgroundColor": "#ffffff", - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", "borderTopLeftRadius": 24, "borderTopRightRadius": 24, "borderWidth": 1, @@ -421,7 +421,7 @@ exports[`FiatSelectorModal renders the modal with currency list 1`] = ` { expect(toJSON()).toMatchSnapshot(); }); - it('tracks RAMPS_PAYMENT_METHOD_SELECTED event when payment method is selected', () => { + it('tracks OFFRAMP_PAYMENT_METHOD_SELECTED event when payment method is selected in sell flow', () => { + mockUseParams.mockReturnValue({ + ...defaultParams, + location: 'Amount to Sell Screen' as const, + }); + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, + rampType: RampType.SELL, + isBuy: false, + }; + + const { getByText } = render(PaymentMethodSelectorModal); + + const paymentMethodElement = getByText('Bank Transfer'); + fireEvent.press(paymentMethodElement); + + expect(mockTrackEvent).toHaveBeenCalledWith( + 'OFFRAMP_PAYMENT_METHOD_SELECTED', + { + payment_method_id: 'payment-method-2', + available_payment_method_ids: ['payment-method-1', 'payment-method-2'], + region: 'US', + location: 'Amount to Sell Screen', + }, + ); + }); + + it('tracks ONRAMP_PAYMENT_METHOD_SELECTED event when payment method is selected in buy flow', () => { const { getByText } = render(PaymentMethodSelectorModal); const paymentMethodElement = getByText('Bank Transfer'); @@ -126,7 +153,7 @@ describe('PaymentMethodSelectorModal', () => { }, ); }); - it('does not track RAMPS_PAYMENT_METHOD_SELECTED event when the same payment method is selected', () => { + it('does not track ONRAMP_PAYMENT_METHOD_SELECTED event when the same payment method is selected', () => { const { getByText } = render(PaymentMethodSelectorModal); const paymentMethodElement = getByText('Credit Card'); diff --git a/app/components/UI/Ramp/Aggregator/components/PaymentMethodSelectorModal/__snapshots__/PaymentMethodSelectorModal.test.tsx.snap b/app/components/UI/Ramp/Aggregator/components/PaymentMethodSelectorModal/__snapshots__/PaymentMethodSelectorModal.test.tsx.snap index 2b96ed2bab6a..28d9429380b0 100644 --- a/app/components/UI/Ramp/Aggregator/components/PaymentMethodSelectorModal/__snapshots__/PaymentMethodSelectorModal.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/components/PaymentMethodSelectorModal/__snapshots__/PaymentMethodSelectorModal.test.tsx.snap @@ -334,7 +334,7 @@ exports[`PaymentMethodSelectorModal renders correctly 1`] = ` style={ [ { - "backgroundColor": "#3f434a66", + "backgroundColor": "#0a0d135c", "bottom": 0, "left": 0, "position": "absolute", @@ -384,7 +384,7 @@ exports[`PaymentMethodSelectorModal renders correctly 1`] = ` [ { "backgroundColor": "#ffffff", - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", "borderTopLeftRadius": 24, "borderTopRightRadius": 24, "borderWidth": 1, @@ -421,7 +421,7 @@ exports[`PaymentMethodSelectorModal renders correctly 1`] = ` { const originalProcessEnv = process.env; - const originalGithubActions = process.env.GITHUB_ACTIONS; + const originalBuildsEnabled = + process.env.BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY; const originalRampsEnvironment = process.env.RAMPS_ENVIRONMENT; - const originalE2e = process.env.E2E; beforeEach(() => { - process.env.GITHUB_ACTIONS = 'false'; + process.env.BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY = 'false'; }); afterAll(() => { @@ -16,27 +16,22 @@ describe('getSdkEnvironment', () => { }); afterEach(() => { - if (originalGithubActions !== undefined) { - process.env.GITHUB_ACTIONS = originalGithubActions; + if (originalBuildsEnabled !== undefined) { + process.env.BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY = + originalBuildsEnabled; } else { - delete process.env.GITHUB_ACTIONS; + delete process.env.BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY; } if (originalRampsEnvironment !== undefined) { process.env.RAMPS_ENVIRONMENT = originalRampsEnvironment; } else { delete process.env.RAMPS_ENVIRONMENT; } - if (originalE2e !== undefined) { - process.env.E2E = originalE2e; - } else { - delete process.env.E2E; - } }); - describe('when GITHUB_ACTIONS (builds.yml path)', () => { + describe('when BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY (builds.yml path)', () => { beforeEach(() => { - process.env.GITHUB_ACTIONS = 'true'; - delete process.env.E2E; + process.env.BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY = 'true'; }); it('returns Production when RAMPS_ENVIRONMENT is production', () => { @@ -59,14 +54,6 @@ describe('getSdkEnvironment', () => { process.env.RAMPS_ENVIRONMENT = 'staging'; expect(getSdkEnvironment()).toBe(Environment.Staging); }); - - it('uses METAMASK_ENVIRONMENT when E2E is true (E2E path)', () => { - process.env.GITHUB_ACTIONS = 'true'; - process.env.E2E = 'true'; - process.env.RAMPS_ENVIRONMENT = 'staging'; - process.env.METAMASK_ENVIRONMENT = 'production'; - expect(getSdkEnvironment()).toBe(Environment.Production); - }); }); describe('Production environments', () => { diff --git a/app/components/UI/Ramp/Aggregator/sdk/getSdkEnvironment.ts b/app/components/UI/Ramp/Aggregator/sdk/getSdkEnvironment.ts index fbdf32d93716..380e38451708 100644 --- a/app/components/UI/Ramp/Aggregator/sdk/getSdkEnvironment.ts +++ b/app/components/UI/Ramp/Aggregator/sdk/getSdkEnvironment.ts @@ -1,11 +1,11 @@ import { Environment } from '@consensys/on-ramp-sdk'; /** - * When GITHUB_ACTIONS (and not E2E), uses RAMPS_ENVIRONMENT (set by builds.yml). + * When BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY (and not E2E), uses RAMPS_ENVIRONMENT (set by builds.yml). * When not (Bitrise / .js.env / E2E), uses METAMASK_ENVIRONMENT switch. */ export function getSdkEnvironment() { - if (process.env.GITHUB_ACTIONS === 'true' && process.env.E2E !== 'true') { + if (process.env.BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY === 'true') { const rampsEnv = process.env.RAMPS_ENVIRONMENT; return rampsEnv === 'production' ? Environment.Production diff --git a/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap index 20bee4832795..868df4fbd152 100644 --- a/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap @@ -117,7 +117,7 @@ exports[`AdditionalVerification Component render matches snapshot 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -158,7 +158,7 @@ exports[`AdditionalVerification Component render matches snapshot 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 16, "fontWeight": 400, @@ -427,7 +427,7 @@ exports[`AdditionalVerification Component render matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 24, "fontWeight": "bold", @@ -443,7 +443,7 @@ exports[`AdditionalVerification Component render matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -458,7 +458,7 @@ exports[`AdditionalVerification Component render matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -497,7 +497,7 @@ exports[`AdditionalVerification Component render matches snapshot 1`] = ` { "alignItems": "center", "alignSelf": "stretch", - "backgroundColor": "#121314", + "backgroundColor": "#131416", "borderRadius": 12, "flexDirection": "row", "height": 48, @@ -527,7 +527,7 @@ exports[`AdditionalVerification Component render matches snapshot 1`] = ` name="powered-by-transak-logo" style={ { - "color": "#686e7d", + "color": "#66676a", "height": 24, } } diff --git a/app/components/UI/Ramp/Deposit/Views/BankDetails/__snapshots__/BankDetails.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/BankDetails/__snapshots__/BankDetails.test.tsx.snap index ecc16abbd877..e4edebb99d16 100644 --- a/app/components/UI/Ramp/Deposit/Views/BankDetails/__snapshots__/BankDetails.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/BankDetails/__snapshots__/BankDetails.test.tsx.snap @@ -117,7 +117,7 @@ exports[`BankDetails Component render matches snapshot 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "height": 24, "width": 24, }, @@ -158,7 +158,7 @@ exports[`BankDetails Component render matches snapshot 1`] = ` style={ [ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 16, "fontWeight": 400, @@ -403,7 +403,7 @@ exports[`BankDetails Component render matches snapshot 1`] = ` } onRefresh={[Function]} refreshing={true} - tintColor="#121314" + tintColor="#131416" /> } testID="bank-details-refresh-control-scrollview" @@ -447,9 +447,9 @@ exports[`BankDetails Component render matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, } @@ -461,7 +461,7 @@ exports[`BankDetails Component render matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -475,7 +475,7 @@ exports[`BankDetails Component render matches snapshot 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -489,7 +489,7 @@ exports[`BankDetails Component render matches snapshot 1`] = ` } testID="bank-details-refresh-control-scrollview" @@ -1404,9 +1404,9 @@ exports[`BankDetails Component render matches snapshot with bank info shown 1`] accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, } @@ -1418,7 +1418,7 @@ exports[`BankDetails Component render matches snapshot with bank info shown 1`] accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1432,7 +1432,7 @@ exports[`BankDetails Component render matches snapshot with bank info shown 1`] accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1446,7 +1446,7 @@ exports[`BankDetails Component render matches snapshot with bank info shown 1`] { Logger.error(fetchError as Error, { message: 'FiatOrders::DepositOrderDetails error while processing order', - order, + orderId: order.id, }); setError( fetchError instanceof Error && fetchError.message diff --git a/app/components/UI/Ramp/Deposit/Views/DepositOrderDetails/__snapshots__/DepositOrderDetails.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/DepositOrderDetails/__snapshots__/DepositOrderDetails.test.tsx.snap index b1f5f1bef334..88c5d4b958c4 100644 --- a/app/components/UI/Ramp/Deposit/Views/DepositOrderDetails/__snapshots__/DepositOrderDetails.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/DepositOrderDetails/__snapshots__/DepositOrderDetails.test.tsx.snap @@ -112,7 +112,7 @@ exports[`DepositOrderDetails Component renders an error screen if a CREATED orde { "color": "#ca3542", "fontFamily": "Geist-Bold", - "fontSize": 18, + "fontSize": 20, "letterSpacing": 0, "lineHeight": 24, "textAlign": "center", @@ -125,7 +125,7 @@ exports[`DepositOrderDetails Component renders an error screen if a CREATED orde accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -147,7 +147,7 @@ exports[`DepositOrderDetails Component renders an error screen if a CREATED orde { "alignItems": "center", "alignSelf": "center", - "backgroundColor": "#121314", + "backgroundColor": "#131416", "borderRadius": 12, "flexDirection": "row", "height": 48, @@ -222,7 +222,7 @@ exports[`DepositOrderDetails Component renders error state correctly 1`] = ` } onRefresh={[Function]} refreshing={false} - tintColor="#121314" + tintColor="#131416" /> } > @@ -420,7 +420,7 @@ exports[`DepositOrderDetails Component renders error state correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 40, "letterSpacing": 0, @@ -439,7 +439,7 @@ exports[`DepositOrderDetails Component renders error state correctly 1`] = ` style={ { "backgroundColor": "#ffffff", - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", "borderRadius": 8, "borderWidth": 1, "marginBottom": 16, @@ -462,7 +462,7 @@ exports[`DepositOrderDetails Component renders error state correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -476,7 +476,7 @@ exports[`DepositOrderDetails Component renders error state correctly 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 36, "flexDirection": "row", "gap": 4, @@ -489,7 +489,7 @@ exports[`DepositOrderDetails Component renders error state correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -513,7 +513,7 @@ exports[`DepositOrderDetails Component renders error state correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -548,7 +548,7 @@ exports[`DepositOrderDetails Component renders error state correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -574,7 +574,7 @@ exports[`DepositOrderDetails Component renders error state correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -598,7 +598,7 @@ exports[`DepositOrderDetails Component renders error state correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -610,7 +610,7 @@ exports[`DepositOrderDetails Component renders error state correctly 1`] = ` ak_123 } > @@ -1051,7 +1051,7 @@ exports[`DepositOrderDetails Component renders processing state correctly 1`] = accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 40, "letterSpacing": 0, @@ -1070,7 +1070,7 @@ exports[`DepositOrderDetails Component renders processing state correctly 1`] = style={ { "backgroundColor": "#ffffff", - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", "borderRadius": 8, "borderWidth": 1, "marginBottom": 16, @@ -1093,7 +1093,7 @@ exports[`DepositOrderDetails Component renders processing state correctly 1`] = accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1107,7 +1107,7 @@ exports[`DepositOrderDetails Component renders processing state correctly 1`] = style={ { "alignItems": "center", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 36, "flexDirection": "row", "gap": 4, @@ -1120,7 +1120,7 @@ exports[`DepositOrderDetails Component renders processing state correctly 1`] = accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1144,7 +1144,7 @@ exports[`DepositOrderDetails Component renders processing state correctly 1`] = accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1179,7 +1179,7 @@ exports[`DepositOrderDetails Component renders processing state correctly 1`] = accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1205,7 +1205,7 @@ exports[`DepositOrderDetails Component renders processing state correctly 1`] = accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1229,7 +1229,7 @@ exports[`DepositOrderDetails Component renders processing state correctly 1`] = accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1241,7 +1241,7 @@ exports[`DepositOrderDetails Component renders processing state correctly 1`] = ak_123 } > @@ -1592,7 +1592,7 @@ exports[`DepositOrderDetails Component renders success state correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Bold", "fontSize": 40, "letterSpacing": 0, @@ -1611,7 +1611,7 @@ exports[`DepositOrderDetails Component renders success state correctly 1`] = ` style={ { "backgroundColor": "#ffffff", - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", "borderRadius": 8, "borderWidth": 1, "marginBottom": 16, @@ -1634,7 +1634,7 @@ exports[`DepositOrderDetails Component renders success state correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1648,7 +1648,7 @@ exports[`DepositOrderDetails Component renders success state correctly 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#3c4d9d0f", + "backgroundColor": "#b4b4b528", "borderRadius": 36, "flexDirection": "row", "gap": 4, @@ -1661,7 +1661,7 @@ exports[`DepositOrderDetails Component renders success state correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1685,7 +1685,7 @@ exports[`DepositOrderDetails Component renders success state correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1720,7 +1720,7 @@ exports[`DepositOrderDetails Component renders success state correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1746,7 +1746,7 @@ exports[`DepositOrderDetails Component renders success state correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#686e7d", + "color": "#66676a", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1770,7 +1770,7 @@ exports[`DepositOrderDetails Component renders success state correctly 1`] = ` accessibilityRole="text" style={ { - "color": "#121314", + "color": "#131416", "fontFamily": "Geist-Regular", "fontSize": 16, "letterSpacing": 0, @@ -1782,7 +1782,7 @@ exports[`DepositOrderDetails Component renders success state correctly 1`] = ` ak_123 { @@ -16,13 +21,24 @@ function useHandleNewOrder() { return; } _dispatch(addFiatOrder(order)); - const notificationDetails = getNotificationDetails(order); - if (notificationDetails) { - NotificationManager.showSimpleNotification(notificationDetails); - } + InteractionManager.runAfterInteractions(() => { + if (isV2Enabled) { + showV2OrderToast({ + orderId: order.id, + cryptocurrency: order.cryptocurrency, + cryptoAmount: order.cryptoAmount, + status: order.state as unknown as RampsOrderStatus, + }); + } else { + const notificationDetails = getNotificationDetails(order); + if (notificationDetails) { + NotificationManager.showSimpleNotification(notificationDetails); + } + } + }); }); }, - [dispatchThunk], + [dispatchThunk, isV2Enabled], ); } diff --git a/app/components/UI/Ramp/Deposit/orderProcessor/index.ts b/app/components/UI/Ramp/Deposit/orderProcessor/index.ts index 1b026842317e..a3c17a7e423a 100644 --- a/app/components/UI/Ramp/Deposit/orderProcessor/index.ts +++ b/app/components/UI/Ramp/Deposit/orderProcessor/index.ts @@ -96,7 +96,11 @@ export async function processDepositOrder( } catch (error) { Logger.error(error as Error, { message: 'DepositOrder::Processor error while processing order', - order, + orderId: order.id, + provider: order.provider, + orderType: order.orderType, + state: order.state, + network: order.network, }); return order; } diff --git a/app/components/UI/Ramp/Deposit/sdk/getSdkEnvironment.test.ts b/app/components/UI/Ramp/Deposit/sdk/getSdkEnvironment.test.ts index b208fcde4412..fd691818f6b6 100644 --- a/app/components/UI/Ramp/Deposit/sdk/getSdkEnvironment.test.ts +++ b/app/components/UI/Ramp/Deposit/sdk/getSdkEnvironment.test.ts @@ -3,37 +3,32 @@ import { getSdkEnvironment } from './getSdkEnvironment'; describe('getSdkEnvironment', () => { const originalEnv = process.env.METAMASK_ENVIRONMENT; - const originalGithubActions = process.env.GITHUB_ACTIONS; + const originalBuildsEnabled = + process.env.BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY; const originalRampsEnvironment = process.env.RAMPS_ENVIRONMENT; - const originalE2e = process.env.E2E; beforeEach(() => { - process.env.GITHUB_ACTIONS = 'false'; + process.env.BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY = 'false'; }); afterEach(() => { process.env.METAMASK_ENVIRONMENT = originalEnv; - if (originalGithubActions !== undefined) { - process.env.GITHUB_ACTIONS = originalGithubActions; + if (originalBuildsEnabled !== undefined) { + process.env.BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY = + originalBuildsEnabled; } else { - delete process.env.GITHUB_ACTIONS; + delete process.env.BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY; } if (originalRampsEnvironment !== undefined) { process.env.RAMPS_ENVIRONMENT = originalRampsEnvironment; } else { delete process.env.RAMPS_ENVIRONMENT; } - if (originalE2e !== undefined) { - process.env.E2E = originalE2e; - } else { - delete process.env.E2E; - } }); - describe('when GITHUB_ACTIONS (builds.yml path)', () => { + describe('when BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY (builds.yml path)', () => { beforeEach(() => { - process.env.GITHUB_ACTIONS = 'true'; - delete process.env.E2E; + process.env.BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY = 'true'; }); it('returns Production when RAMPS_ENVIRONMENT is production', () => { @@ -56,14 +51,6 @@ describe('getSdkEnvironment', () => { process.env.RAMPS_ENVIRONMENT = 'staging'; expect(getSdkEnvironment()).toBe(SdkEnvironment.Staging); }); - - it('uses METAMASK_ENVIRONMENT when E2E is true (E2E path)', () => { - process.env.GITHUB_ACTIONS = 'true'; - process.env.E2E = 'true'; - process.env.RAMPS_ENVIRONMENT = 'staging'; - process.env.METAMASK_ENVIRONMENT = 'production'; - expect(getSdkEnvironment()).toBe(SdkEnvironment.Production); - }); }); describe('Production Environment', () => { diff --git a/app/components/UI/Ramp/Deposit/sdk/getSdkEnvironment.ts b/app/components/UI/Ramp/Deposit/sdk/getSdkEnvironment.ts index 378bd9836bbb..84a18d5ab819 100644 --- a/app/components/UI/Ramp/Deposit/sdk/getSdkEnvironment.ts +++ b/app/components/UI/Ramp/Deposit/sdk/getSdkEnvironment.ts @@ -1,11 +1,11 @@ import { SdkEnvironment } from '@consensys/native-ramps-sdk'; /** - * When GITHUB_ACTIONS (and not E2E), uses RAMPS_ENVIRONMENT (set by builds.yml). + * When BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY (and not E2E), uses RAMPS_ENVIRONMENT (set by builds.yml). * When not (Bitrise / .js.env / E2E), uses METAMASK_ENVIRONMENT switch. */ export function getSdkEnvironment() { - if (process.env.GITHUB_ACTIONS === 'true' && process.env.E2E !== 'true') { + if (process.env.BUILDS_ENABLED_WITH_GH_ACTIONS_TEMPORARY === 'true') { const rampsEnv = process.env.RAMPS_ENVIRONMENT; return rampsEnv === 'production' ? SdkEnvironment.Production diff --git a/app/components/UI/Ramp/Deposit/types/analytics.ts b/app/components/UI/Ramp/Deposit/types/analytics.ts index a695e8e4fac9..82f79eb3af07 100644 --- a/app/components/UI/Ramp/Deposit/types/analytics.ts +++ b/app/components/UI/Ramp/Deposit/types/analytics.ts @@ -2,7 +2,7 @@ import { UnifiedRampRoutingType } from '../../../../../reducers/fiatOrders'; interface RampsButtonClicked { quote_session_id?: string; - ramp_type: 'DEPOSIT' | 'SELL' | 'BUY' | 'UNIFIED BUY'; + ramp_type: 'DEPOSIT' | 'SELL' | 'BUY' | 'UNIFIED_BUY' | 'UNIFIED_BUY_2'; user_id?: string; region: string; location: string; @@ -23,7 +23,7 @@ interface RampsDepositCashButtonClicked { interface RampsPaymentMethodSelected { quote_session_id?: string; - ramp_type: 'DEPOSIT'; + ramp_type: 'DEPOSIT' | 'UNIFIED_BUY_2'; user_id?: string; region: string; payment_method_id: string; @@ -32,7 +32,7 @@ interface RampsPaymentMethodSelected { interface RampsTokenSelected { quote_session_id?: string; - ramp_type: 'DEPOSIT' | 'SELL' | 'BUY' | 'UNIFIED BUY'; + ramp_type: 'DEPOSIT' | 'SELL' | 'BUY' | 'UNIFIED_BUY' | 'UNIFIED_BUY_2'; user_id?: string; region: string; chain_id: string; @@ -259,6 +259,147 @@ interface RampsUserDetailsFetched { location: string; } +// Unified Buy v2 event interfaces + +interface RampsScreenViewed { + location: string; + ramp_type: 'UNIFIED_BUY_2'; + ramp_routing?: UnifiedRampRoutingType; +} + +interface RampsBackButtonClicked { + location: string; + ramp_type: 'UNIFIED_BUY_2'; + ramp_routing?: UnifiedRampRoutingType; +} + +interface RampsNetworkFilterClicked { + network_chain_id?: string; + location: string; + ramp_type: 'UNIFIED_BUY_2'; +} + +interface RampsTokenSearched { + search_query: string; + results_count?: number; + location: string; + ramp_type: 'UNIFIED_BUY_2'; +} + +interface RampsSettingsClicked { + location: string; + ramp_type: 'UNIFIED_BUY_2'; +} + +interface RampsSettingOptionClicked { + option: string; + location: string; + ramp_type: 'UNIFIED_BUY_2'; +} + +interface RampsPaymentMethodSelectorClicked { + current_payment_method?: string; + location: string; + ramp_type: 'UNIFIED_BUY_2'; +} + +interface RampsQuickAmountClicked { + amount: number; + currency_source: string; + location: string; + ramp_type: 'UNIFIED_BUY_2'; +} + +interface RampsChangeProviderButtonClicked { + current_provider?: string; + location: string; + ramp_type: 'UNIFIED_BUY_2'; + ramp_routing?: UnifiedRampRoutingType; +} + +interface RampsProviderSelected { + provider: string; + previous_provider?: string; + location: string; + ramp_type: 'UNIFIED_BUY_2'; + ramp_routing?: UnifiedRampRoutingType; +} + +interface RampsContinueButtonClicked { + ramp_routing: UnifiedRampRoutingType; + ramp_type: 'UNIFIED_BUY_2'; + amount_source: number; + amount_destination?: number; + payment_method_id: string; + provider_onramp?: string; + region: string; + chain_id: string; + currency_destination: string; + currency_destination_symbol?: string; + currency_destination_network?: string; + currency_source: string; + is_authenticated?: boolean; + first_time_order?: boolean; + exchange_rate?: number; + total_fee?: number; + gas_fee?: number; + processing_fee?: number; +} + +interface RampsTermsConsentClicked { + location: string; + ramp_type: 'UNIFIED_BUY_2'; + ramp_routing?: UnifiedRampRoutingType; +} + +interface RampsExternalLinkClicked { + location: string; + external_link_description: string; + url_domain?: string; + ramp_type: 'UNIFIED_BUY_2'; +} + +interface RampsCloseButtonClicked { + location: string; + ramp_type: 'UNIFIED_BUY_2'; +} + +interface RampsQuoteError { + error_message?: string; + amount?: number; + currency_source?: string; + currency_destination?: string; + payment_method_id?: string; + chain_id?: string; + ramp_type: 'UNIFIED_BUY_2'; + ramp_routing?: UnifiedRampRoutingType; +} + +interface RampsQuoteErrorTooltipClicked { + error_message?: string; + location: string; + ramp_type: 'UNIFIED_BUY_2'; +} + +interface RampsUnsupportedTokenTooltipClicked { + reason?: string; + token_symbol?: string; + chain_id?: string; + location: string; + ramp_type: 'UNIFIED_BUY_2'; +} + +interface RampsInfoTooltipClicked { + location: string; + ramp_type: 'UNIFIED_BUY_2'; +} + +interface RampsToastButtonClicked { + action: string; + location?: string; + ramp_type: 'UNIFIED_BUY_2'; +} + export interface AnalyticsEvents { RAMPS_BUTTON_CLICKED: RampsButtonClicked; RAMPS_DEPOSIT_CASH_BUTTON_CLICKED: RampsDepositCashButtonClicked; @@ -283,4 +424,25 @@ export interface AnalyticsEvents { RAMPS_KYC_APPLICATION_FAILED: RampsKycApplicationFailed; RAMPS_KYC_APPLICATION_APPROVED: RampsKycApplicationApproved; RAMPS_USER_DETAILS_FETCHED: RampsUserDetailsFetched; + + // Unified Buy v2 + RAMPS_SCREEN_VIEWED: RampsScreenViewed; + RAMPS_BACK_BUTTON_CLICKED: RampsBackButtonClicked; + RAMPS_NETWORK_FILTER_CLICKED: RampsNetworkFilterClicked; + RAMPS_TOKEN_SEARCHED: RampsTokenSearched; + RAMPS_SETTINGS_CLICKED: RampsSettingsClicked; + RAMPS_SETTING_OPTION_CLICKED: RampsSettingOptionClicked; + RAMPS_PAYMENT_METHOD_SELECTOR_CLICKED: RampsPaymentMethodSelectorClicked; + RAMPS_QUICK_AMOUNT_CLICKED: RampsQuickAmountClicked; + RAMPS_CHANGE_PROVIDER_BUTTON_CLICKED: RampsChangeProviderButtonClicked; + RAMPS_PROVIDER_SELECTED: RampsProviderSelected; + RAMPS_CONTINUE_BUTTON_CLICKED: RampsContinueButtonClicked; + RAMPS_TERMS_CONSENT_CLICKED: RampsTermsConsentClicked; + RAMPS_EXTERNAL_LINK_CLICKED: RampsExternalLinkClicked; + RAMPS_CLOSE_BUTTON_CLICKED: RampsCloseButtonClicked; + RAMPS_QUOTE_ERROR: RampsQuoteError; + RAMPS_QUOTE_ERROR_TOOLTIP_CLICKED: RampsQuoteErrorTooltipClicked; + RAMPS_UNSUPPORTED_TOKEN_TOOLTIP_CLICKED: RampsUnsupportedTokenTooltipClicked; + RAMPS_INFO_TOOLTIP_CLICKED: RampsInfoTooltipClicked; + RAMPS_TOAST_BUTTON_CLICKED: RampsToastButtonClicked; } diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx index 7f0357b2a2af..d775e3c84719 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx @@ -44,13 +44,9 @@ const createMockToken = (overrides?: Partial): RampsToken => ({ const mockTokenNetworkInfo = { networkName: 'Ethereum Mainnet', - networkImageSource: { uri: 'https://example.com/eth.png' }, }; const mockGetTokenNetworkInfo = jest.fn(() => mockTokenNetworkInfo); -const mockGetRampsBuildQuoteNavbarOptions = jest.fn( - (_navigation: unknown, _options: unknown) => ({}), -); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -86,10 +82,50 @@ jest.mock('../../../../../../locales/i18n', () => ({ }, })); -jest.mock('../../../Navbar', () => ({ - getRampsBuildQuoteNavbarOptions: (navigation: unknown, options: unknown) => - mockGetRampsBuildQuoteNavbarOptions(navigation, options), -})); +jest.mock( + '../../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + subtitle, + onBack, + backButtonProps, + endButtonIconProps, + }: { + title?: string; + subtitle?: string; + onBack?: () => void; + backButtonProps?: { testID?: string }; + endButtonIconProps?: { + iconName: string; + onPress?: () => void; + testID?: string; + }[]; + }) => ( + + {title ? {title} : null} + {subtitle ? {subtitle} : null} + {onBack ? ( + + ) : null} + {endButtonIconProps?.map((btn, i) => ( + + ))} + + ), + }; + }, +); jest.mock('../../../../hooks/useFormatters', () => ({ useFormatters: () => ({ @@ -216,8 +252,21 @@ jest.mock('../../hooks/useTransakRouting', () => ({ }), })); -jest.mock('../NativeFlow/EnterEmail', () => ({ - createV2EnterEmailNavDetails: (params: unknown) => ['RampEnterEmail', params], +jest.mock('../NativeFlow/VerifyIdentity', () => ({ + createV2VerifyIdentityNavDetails: (params: unknown) => [ + 'RampVerifyIdentity', + params, + ], +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(() => 'aggregator'), +})); + +jest.mock('../../../../../reducers/fiatOrders', () => ({ + getRampRoutingDecision: jest.fn(), + UnifiedRampRoutingType: { AGGREGATOR: 'aggregator', DEPOSIT: 'deposit' }, })); const renderWithTheme = (component: React.ReactElement) => @@ -262,7 +311,7 @@ describe('BuildQuote', () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('displays initial amount as $100', () => { @@ -320,7 +369,15 @@ describe('BuildQuote', () => { expect(getByText('$100123')).toBeOnTheScreen(); }); - it('deletes last digit when delete button is pressed', () => { + it('clears the default amount when delete is pressed', () => { + const { getByText, getByTestId } = renderWithTheme(); + + fireEvent.press(getByTestId('keypad-delete-button')); + + expect(getByText('$0')).toBeOnTheScreen(); + }); + + it('deletes one character when delete button is pressed after typing', () => { const { getByText, getByTestId } = renderWithTheme(); fireEvent.press(getByText('1')); @@ -330,24 +387,29 @@ describe('BuildQuote', () => { expect(getByText('$1001')).toBeOnTheScreen(); }); - it('sets navigation options with token and network data', () => { - renderWithTheme(); + it('deletes one character when delete is pressed after user has modified amount even if value equals default', () => { + const { getByText, getByTestId } = renderWithTheme(); - expect(mockGetRampsBuildQuoteNavbarOptions).toHaveBeenCalledWith( - expect.objectContaining({ - navigate: mockNavigate, - setOptions: mockSetOptions, - goBack: mockGoBack, - }), - expect.objectContaining({ - tokenName: 'USD Coin', - tokenSymbol: 'USDC', - tokenIconUrl: 'https://example.com/usdc.png', - networkName: 'Ethereum Mainnet', - networkImageSource: { uri: 'https://example.com/eth.png' }, - onSettingsPress: expect.any(Function), - }), - ); + fireEvent.press(getByTestId('keypad-delete-button')); + expect(getByText('$0')).toBeOnTheScreen(); + + fireEvent.press(getByText('1')); + fireEvent.press(getByText('0')); + fireEvent.press(getByText('0')); + expect(getByText('$100')).toBeOnTheScreen(); + + fireEvent.press(getByTestId('keypad-delete-button')); + expect(getByText('$10')).toBeOnTheScreen(); + }); + + it('renders inline header with token and network data', () => { + const { getByTestId, getByText } = renderWithTheme(); + + expect(getByTestId('header-compact-standard')).toBeOnTheScreen(); + expect(getByText('fiat_on_ramp.buy')).toBeOnTheScreen(); + expect(getByText('fiat_on_ramp.on_network')).toBeOnTheScreen(); + expect(getByTestId('build-quote-back-button')).toBeOnTheScreen(); + expect(getByTestId('build-quote-settings-button')).toBeOnTheScreen(); }); it('renders the payment method pill', () => { @@ -370,29 +432,17 @@ describe('BuildQuote', () => { ); }); - it('sets navigation options with undefined values when token is not found (shows skeleton)', () => { + it('renders inline header without title or subtitle when token is not found', () => { mockTokens = { allTokens: [], topTokens: [], }; - renderWithTheme(); + const { getByTestId, queryByText } = renderWithTheme(); - expect(mockGetRampsBuildQuoteNavbarOptions).toHaveBeenCalledWith( - expect.objectContaining({ - navigate: mockNavigate, - setOptions: mockSetOptions, - goBack: mockGoBack, - }), - expect.objectContaining({ - tokenName: undefined, - tokenSymbol: undefined, - tokenIconUrl: undefined, - networkName: undefined, - networkImageSource: undefined, - onSettingsPress: expect.any(Function), - }), - ); + expect(getByTestId('header-compact-standard')).toBeOnTheScreen(); + expect(queryByText('fiat_on_ramp.buy')).toBeNull(); + expect(queryByText('fiat_on_ramp.on_network')).toBeNull(); }); it('renders quick amount buttons when amount is zero', () => { @@ -456,6 +506,30 @@ describe('BuildQuote', () => { expect(getByTestId('build-quote-continue-button')).toBeOnTheScreen(); }); + it('displays powered by provider text when selected provider is set', () => { + mockSelectedProvider = { + id: '/providers/transak', + name: 'Transak', + environmentType: 'PRODUCTION', + description: 'Test Provider', + hqAddress: '123 Test St', + links: [], + logos: { light: '', dark: '', height: 24, width: 79 }, + }; + + const { getByText } = renderWithTheme(); + + expect(getByText('fiat_on_ramp.powered_by_provider')).toBeOnTheScreen(); + }); + + it('does not display powered by text when no selected provider is set', () => { + mockSelectedProvider = null; + + const { queryByText } = renderWithTheme(); + + expect(queryByText('fiat_on_ramp.powered_by_provider')).toBeNull(); + }); + it('matches snapshot', () => { const { toJSON } = renderWithTheme(); @@ -723,7 +797,7 @@ describe('BuildQuote', () => { ); }); - it('navigates to enter email for native provider when no existing token', async () => { + it('navigates to verify identity for native provider when no existing token', async () => { mockTransakCheckExistingToken.mockResolvedValue(false); const mockNativeQuote = { @@ -774,7 +848,7 @@ describe('BuildQuote', () => { expect(mockTransakCheckExistingToken).toHaveBeenCalled(); expect(mockNavigate).toHaveBeenCalledWith( - 'RampEnterEmail', + 'RampVerifyIdentity', expect.objectContaining({ amount: '100', currency: 'USD', @@ -1444,7 +1518,7 @@ describe('BuildQuote', () => { expect(getByTestId('build-quote-continue-button')).toBeDisabled(); }); - it('does not navigate to payment selection when amount is zero', () => { + it('navigates to payment selection when amount is zero', () => { const { getByTestId } = renderWithTheme(); fireEvent.press(getByTestId('keypad-delete-button')); @@ -1455,7 +1529,13 @@ describe('BuildQuote', () => { fireEvent.press(getByTestId('payment-method-pill')); - expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith( + 'RampModals', + expect.objectContaining({ + screen: 'RampPaymentSelectionModal', + params: { amount: 0 }, + }), + ); }); }); diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index 6c115f1f66b6..b1cf83689631 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -11,20 +11,22 @@ import type { CaipChainId } from '@metamask/utils'; import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import { getRampCallbackBaseUrl } from '../../utils/getRampCallbackBaseUrl'; -import Keypad, { type KeypadChangeData } from '../../../../Base/Keypad'; +import Keypad, { type KeypadChangeData, Keys } from '../../../../Base/Keypad'; import PaymentMethodPill from '../../components/PaymentMethodPill'; import QuickAmounts from '../../components/QuickAmounts'; import Text, { TextVariant, + TextColor, } from '../../../../../component-library/components/Texts/Text'; import { Button, ButtonVariant, ButtonSize, + IconName, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; -import { getRampsBuildQuoteNavbarOptions } from '../../../Navbar'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import Routes from '../../../../../constants/navigation/Routes'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './BuildQuote.styles'; @@ -51,8 +53,17 @@ import BannerAlert from '../../../../../component-library/components/Banners/Ban import { BannerAlertSeverity } from '../../../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.types'; import { useTransakController } from '../../hooks/useTransakController'; import { useTransakRouting } from '../../hooks/useTransakRouting'; -import { createV2EnterEmailNavDetails } from '../NativeFlow/EnterEmail'; +import { createV2VerifyIdentityNavDetails } from '../NativeFlow/VerifyIdentity'; import { parseUserFacingError } from '../../utils/parseUserFacingError'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { useSelector } from 'react-redux'; +import { + getRampRoutingDecision, + UnifiedRampRoutingType, +} from '../../../../../reducers/fiatOrders'; +import TruncatedError from '../../components/TruncatedError'; +import { PROVIDER_LINKS } from '../../Aggregator/types'; export interface BuildQuoteParams { assetId?: string; @@ -97,6 +108,7 @@ function BuildQuote() { const [amount, setAmount] = useState(() => String(DEFAULT_AMOUNT)); const [amountAsNumber, setAmountAsNumber] = useState(DEFAULT_AMOUNT); const [userHasEnteredAmount, setUserHasEnteredAmount] = useState(false); + const [keyboardIsDirty, setKeyboardIsDirty] = useState(false); const [isOnBuildQuoteScreen, setIsOnBuildQuoteScreen] = useState(true); const [isContinueLoading, setIsContinueLoading] = useState(false); @@ -128,6 +140,16 @@ function BuildQuote() { selectedPaymentMethod, } = useRampsController(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const rampRoutingDecision = useSelector(getRampRoutingDecision); + const prevSelectedProviderRef = useRef(selectedProvider); + useEffect(() => { + if (prevSelectedProviderRef.current !== selectedProvider) { + prevSelectedProviderRef.current = selectedProvider; + setNativeFlowError(null); + } + }, [selectedProvider]); + const isTokenUnavailable = useMemo( () => !!( @@ -165,6 +187,23 @@ function BuildQuote() { const currency = userRegion?.country?.currency || 'USD'; const quickAmounts = userRegion?.country?.quickAmounts ?? [50, 100, 200, 400]; + const hasTrackedScreenViewRef = useRef(false); + useEffect(() => { + if (hasTrackedScreenViewRef.current) return; + if (rampRoutingDecision != null) { + hasTrackedScreenViewRef.current = true; + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_SCREEN_VIEWED) + .addProperties({ + location: 'Amount Input', + ramp_type: 'UNIFIED_BUY_2', + ramp_routing: rampRoutingDecision, + }) + .build(), + ); + } + }, [rampRoutingDecision, trackEvent, createEventBuilder]); + useEffect(() => { if (!userHasEnteredAmount && userRegion?.country?.defaultAmount != null) { const regionDefault = userRegion.country.defaultAmount; @@ -222,6 +261,46 @@ function BuildQuote() { error: quoteFetchError, } = useRampsQuotes(quoteFetchEnabled ? quoteFetchParams : null); + const lastTrackedQuoteErrorRef = useRef(null); + useEffect(() => { + if ( + quoteFetchError && + quoteFetchError !== lastTrackedQuoteErrorRef.current + ) { + lastTrackedQuoteErrorRef.current = quoteFetchError; + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_QUOTE_ERROR) + .addProperties({ + error_message: parseUserFacingError( + quoteFetchError, + strings('deposit.buildQuote.quoteFetchError'), + ), + amount: amountAsNumber, + currency_source: currency, + currency_destination: selectedToken?.assetId, + payment_method_id: selectedPaymentMethod?.id, + chain_id: selectedToken?.chainId, + ramp_type: 'UNIFIED_BUY_2', + ramp_routing: rampRoutingDecision ?? undefined, + }) + .build(), + ); + } + if (!quoteFetchError) { + lastTrackedQuoteErrorRef.current = null; + } + }, [ + quoteFetchError, + amountAsNumber, + currency, + selectedToken?.assetId, + selectedToken?.chainId, + selectedPaymentMethod?.id, + rampRoutingDecision, + trackEvent, + createEventBuilder, + ]); + const selectedQuote = useMemo(() => { if (!quotesResponse?.success || !selectedProvider || !selectedPaymentMethod) return null; @@ -247,52 +326,104 @@ function BuildQuote() { return getTokenNetworkInfo(selectedToken.chainId as CaipChainId); }, [selectedToken, getTokenNetworkInfo]); - useEffect(() => { - navigation.setOptions( - getRampsBuildQuoteNavbarOptions(navigation, { - tokenName: selectedToken?.name, - tokenSymbol: selectedToken?.symbol, - tokenIconUrl: selectedToken?.iconUrl, - networkName: networkInfo?.networkName ?? undefined, - networkImageSource: networkInfo?.networkImageSource, - onSettingsPress: () => { - navigation.navigate(...createSettingsModalNavDetails()); - }, - }), + const handleSettingsPress = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_SETTINGS_CLICKED) + .addProperties({ + location: 'Amount Input', + ramp_type: 'UNIFIED_BUY_2', + }) + .build(), ); - }, [navigation, selectedToken, networkInfo]); + navigation.navigate(...createSettingsModalNavDetails()); + }, [trackEvent, createEventBuilder, navigation]); + + const handleBackPress = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_BACK_BUTTON_CLICKED) + .addProperties({ + location: 'Amount Input', + ramp_type: 'UNIFIED_BUY_2', + }) + .build(), + ); + navigation.goBack(); + }, [trackEvent, createEventBuilder, navigation]); const handleKeypadChange = useCallback( - ({ value, valueAsNumber }: KeypadChangeData) => { + ({ value, valueAsNumber, pressedKey }: KeypadChangeData) => { + if (pressedKey === Keys.Back) { + if (!keyboardIsDirty) { + setAmount('0'); + setAmountAsNumber(0); + } else { + setAmount(value || '0'); + setAmountAsNumber(valueAsNumber || 0); + } + setKeyboardIsDirty(true); + setUserHasEnteredAmount(true); + setNativeFlowError(null); + return; + } + setAmount(value || '0'); setAmountAsNumber(valueAsNumber || 0); + setKeyboardIsDirty(true); setUserHasEnteredAmount(true); setNativeFlowError(null); }, - [], + [keyboardIsDirty], ); - const handleQuickAmountPress = useCallback((quickAmount: number) => { - setAmount(String(quickAmount)); - setAmountAsNumber(quickAmount); - setUserHasEnteredAmount(true); - setNativeFlowError(null); - }, []); + const handleQuickAmountPress = useCallback( + (quickAmount: number) => { + setAmount(String(quickAmount)); + setAmountAsNumber(quickAmount); + setKeyboardIsDirty(true); + setUserHasEnteredAmount(true); + setNativeFlowError(null); + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_QUICK_AMOUNT_CLICKED) + .addProperties({ + amount: quickAmount, + currency_source: currency, + location: 'Amount Input', + ramp_type: 'UNIFIED_BUY_2', + }) + .build(), + ); + }, + [currency, trackEvent, createEventBuilder], + ); const handlePaymentPillPress = useCallback(() => { - if (debouncedPollingAmount <= 0) { - return; - } - + trackEvent( + createEventBuilder( + MetaMetricsEvents.RAMPS_PAYMENT_METHOD_SELECTOR_CLICKED, + ) + .addProperties({ + current_payment_method: selectedPaymentMethod?.id, + location: 'Amount Input', + ramp_type: 'UNIFIED_BUY_2', + }) + .build(), + ); navigation.navigate( ...createPaymentSelectionModalNavigationDetails({ amount: debouncedPollingAmount, }), ); - }, [debouncedPollingAmount, navigation]); + }, [ + debouncedPollingAmount, + navigation, + selectedPaymentMethod?.id, + trackEvent, + createEventBuilder, + ]); const handleContinuePress = useCallback(async () => { if (!selectedQuote || !selectedProvider) return; + setNativeFlowError(null); const quoteAmount = selectedQuote.quote?.amountIn ?? @@ -319,6 +450,24 @@ function BuildQuote() { } } + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_CONTINUE_BUTTON_CLICKED) + .addProperties({ + ramp_routing: + rampRoutingDecision ?? UnifiedRampRoutingType.AGGREGATOR, + ramp_type: 'UNIFIED_BUY_2', + amount_source: amountAsNumber, + payment_method_id: selectedPaymentMethod?.id ?? '', + provider_onramp: selectedProvider?.name, + region: userRegion?.regionCode ?? '', + chain_id: selectedToken?.chainId ?? '', + currency_destination: selectedToken?.assetId ?? '', + currency_destination_symbol: selectedToken?.symbol, + currency_source: currency, + }) + .build(), + ); + if (isNativeProvider(selectedQuote)) { setIsContinueLoading(true); try { @@ -338,7 +487,7 @@ function BuildQuote() { await transakRouteAfterAuth(quote); } else { navigation.navigate( - ...createV2EnterEmailNavDetails({ + ...createV2VerifyIdentityNavDetails({ amount: String(amountAsNumber), currency, assetId: selectedToken?.assetId, @@ -361,7 +510,6 @@ function BuildQuote() { return; } - // V2 aggregator: get widget URL via controller and navigate to checkout setIsContinueLoading(true); try { const fetchedWidgetUrl = await getWidgetUrl(selectedQuote); @@ -394,12 +542,19 @@ function BuildQuote() { new Error('No widget URL available for aggregator provider'), { provider: selectedQuote.provider }, ); + setNativeFlowError(strings('deposit.buildQuote.unexpectedError')); } } catch (error) { Logger.error(error as Error, { provider: selectedQuote.provider, message: 'Failed to fetch widget URL', }); + setNativeFlowError( + parseUserFacingError( + error, + strings('deposit.buildQuote.unexpectedError'), + ), + ); } finally { setIsContinueLoading(false); } @@ -416,6 +571,10 @@ function BuildQuote() { transakCheckExistingToken, transakGetBuyQuote, transakRouteAfterAuth, + rampRoutingDecision, + userRegion?.regionCode, + trackEvent, + createEventBuilder, ]); const hasAmount = amountAsNumber > 0; @@ -460,94 +619,150 @@ function BuildQuote() { quoteMatchesAmount && quoteMatchesCurrentContext; + const hasNoQuotes = + hasAmount && + !selectedQuoteLoading && + !quoteFetchError && + quotesResponse !== null && + selectedQuote === null; + + const noQuotesErrorMessage = selectedProvider + ? strings('fiat_on_ramp.no_quotes_error', { + provider: selectedProvider.name, + }) + : strings('fiat_on_ramp.no_quotes_available'); + return ( - - - - - - - {formatCurrency(amountAsNumber, currency, { - currencyDisplay: 'narrowSymbol', - })} - - + <> + + + + + + + + {formatCurrency(amountAsNumber, currency, { + currencyDisplay: 'narrowSymbol', + })} + + + - - {nativeFlowError && ( - - )} - - {quoteFetchError && ( - - )} - - - {hasAmount ? ( - <> - {selectedProvider && ( - - {strings('fiat_on_ramp.powered_by_provider', { - provider: selectedProvider.name, - })} - + {quoteFetchError && ( + - {strings('fiat_on_ramp.continue')} - - - ) : ( - quickAmounts.length > 0 && ( - - ) + /> )} - - - - - + + + {hasAmount ? ( + <> + {nativeFlowError ? ( + link.name === PROVIDER_LINKS.SUPPORT, + )?.url + } + /> + ) : hasNoQuotes ? ( + + ) : ( + selectedProvider && ( + + {strings('fiat_on_ramp.powered_by_provider', { + provider: selectedProvider.name, + })} + + ) + )} + + + ) : ( + quickAmounts.length > 0 && ( + + ) + )} + + + + + + ); } diff --git a/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap index 8343c9b50f7d..27a64293c0a3 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap +++ b/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -1,36 +1,45 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`BuildQuote Continue button displays error banner when quote fetch fails 1`] = ` - +[ + + fiat_on_ramp.buy + + + fiat_on_ramp.on_network + + + + , + - - $100 - - - - - - Card + $100 - - - - + > + + + + Card + + + + + + - - - + > + + + + + Network error + + @@ -210,146 +253,109 @@ exports[`BuildQuote Continue button displays error banner when quote fetch fails accessibilityRole="text" style={ { - "color": "#121314", + "color": "#66676a", "fontFamily": "Geist-Regular", - "fontSize": 16, + "fontSize": 14, "letterSpacing": 0, - "lineHeight": 24, + "lineHeight": 22, + "textAlign": "center", } } > - Network error + fiat_on_ramp.powered_by_provider - - - - - fiat_on_ramp.powered_by_provider - - - - fiat_on_ramp.continue - + + fiat_on_ramp.continue + + - - - - - 1 - - - - - + 1 + + + + - - 2 - - - - - + 2 + + + + - - 3 - - + + 3 + + + - - - - - 4 - - - - - + 4 + + + + - - 5 - - - - - + 5 + + + + - - 6 - - + + 6 + + + - - - - - 7 - - - - - + 7 + + + + - - 8 - - - - - + 8 + + + + - - 9 - - + + 9 + + + - - - - - . - - - - - + . + + + + - - 0 - - - - - + 0 + + + + - - + testID="keypad-delete-button" + > + + + - - + , +] `; exports[`BuildQuote matches snapshot 1`] = ` - +[ + + fiat_on_ramp.buy + + + fiat_on_ramp.on_network + + + + , + - - $100 - - - - - - fiat_on_ramp.select_payment_method + $100 - - - - + > + + + + fiat_on_ramp.select_payment_method + + + + + + - - + - - fiat_on_ramp.continue - + + fiat_on_ramp.continue + + - - - - - 1 - - - - - + 1 + + + + - - 2 - - - - - + 2 + + + + - - 3 - - + + 3 + + + - - - - - 4 - - - - - + 4 + + + + - - 5 - - - - - + 5 + + + + - - 6 - - + + 6 + + + - - - - - 7 - - - - - + 7 + + + + - - 8 - - - - - + 8 + + + + - - 9 - - + + 9 + + + - - - - - . - - - - - + . + + + + - - 0 - - - - - + 0 + + + + - - + testID="keypad-delete-button" + > + + + - - + , +] `; diff --git a/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx b/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx index e6c27bd66abf..3f4db1d34971 100644 --- a/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx +++ b/app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx @@ -1,7 +1,6 @@ import { act, fireEvent } from '@testing-library/react-native'; import { renderScreen } from '../../../../../util/test/renderWithProvider'; import Checkout from '.'; -import { createInitialFiatOrder } from './Checkout'; import Routes from '../../../../../constants/navigation/Routes'; import { FIAT_ORDER_PROVIDERS } from '../../../../../constants/on-ramp'; import Logger from '../../../../../util/Logger'; @@ -36,11 +35,13 @@ jest.mock('../../../../hooks/useThunkDispatch', () => ({ })); const mockGetOrderFromCallback = jest.fn(); +const mockAddOrder = jest.fn(); jest.mock('../../../../../core/Engine', () => ({ context: { RampsController: { getOrderFromCallback: (...args: unknown[]) => mockGetOrderFromCallback(...args), + addOrder: (...args: unknown[]) => mockAddOrder(...args), }, }, })); @@ -278,14 +279,14 @@ describe('Checkout', () => { ).toBeOnTheScreen(); }); - it('handles callback error when order has no ID', async () => { + it('navigates to order details even when order IDs are null', async () => { mockGetOrderFromCallback.mockResolvedValue({ status: 'PENDING', id: null, providerOrderId: null, }); - const { getByTestId, getByText } = render(); + const { getByTestId } = render(); const webview = getByTestId('checkout-webview'); await act(async () => { @@ -295,9 +296,20 @@ describe('Checkout', () => { }); }); - expect( - getByText('Order response did not contain an order ID'), - ).toBeOnTheScreen(); + expect(mockAddOrder).toHaveBeenCalled(); + expect(mockReset).toHaveBeenCalledWith( + expect.objectContaining({ + routes: expect.arrayContaining([ + expect.objectContaining({ + name: Routes.RAMP.RAMPS_ORDER_DETAILS, + params: expect.objectContaining({ + orderId: null, + showCloseButton: true, + }), + }), + ]), + }), + ); }); it('successfully creates order from callback with customOrderId', async () => { @@ -345,7 +357,16 @@ describe('Checkout', () => { expect(mockDispatch).toHaveBeenCalledWith( expect.objectContaining({ type: 'FIAT_REMOVE_CUSTOM_ID_DATA' }), ); - expect(mockDangerouslyGetParent).toHaveBeenCalled(); + expect(mockReset).toHaveBeenCalledWith( + expect.objectContaining({ + routes: expect.arrayContaining([ + expect.objectContaining({ + name: Routes.RAMP.RAMPS_ORDER_DETAILS, + params: expect.objectContaining({ showCloseButton: true }), + }), + ]), + }), + ); }); it('navigates to Ramps order details with showCloseButton when providerType is RAMPS_V2', async () => { @@ -436,7 +457,7 @@ describe('Checkout', () => { ); }); - it('uses customOrderId as fallback when order IDs are missing', async () => { + it('navigates to order details with null orderId when order IDs are missing', async () => { const mockOrder = { id: null, providerOrderId: null, @@ -468,7 +489,20 @@ describe('Checkout', () => { }); }); - expect(mockDangerouslyGetParent).toHaveBeenCalled(); + expect(mockAddOrder).toHaveBeenCalledWith(mockOrder); + expect(mockReset).toHaveBeenCalledWith( + expect.objectContaining({ + routes: expect.arrayContaining([ + expect.objectContaining({ + name: Routes.RAMP.RAMPS_ORDER_DETAILS, + params: expect.objectContaining({ + orderId: null, + showCloseButton: true, + }), + }), + ]), + }), + ); }); it('does not process callback twice when already handled', async () => { @@ -528,78 +562,4 @@ describe('Checkout', () => { }); }); }); - - describe('createInitialFiatOrder', () => { - const baseParams = { - providerCode: 'transak', - providerName: 'Transak', - orderId: 'order-123', - walletAddress: '0xabc', - network: '1', - currency: 'USD', - cryptocurrency: 'ETH', - }; - - it('builds order with nav params only (no rampsOrder)', () => { - const order = createInitialFiatOrder(baseParams); - expect(order.id).toBe('/providers/transak/orders/order-123'); - expect(order.provider).toBe(FIAT_ORDER_PROVIDERS.RAMPS_V2); - expect(order.currency).toBe('USD'); - expect(order.cryptocurrency).toBe('ETH'); - expect(order.network).toBe('1'); - expect(order.account).toBe('0xabc'); - }); - - it('uses rampsOrder data when provided', () => { - const rampsOrder = { - id: 'ramp-order-456', - providerOrderId: 'provider-456', - status: 'COMPLETED', - fiatAmount: 100, - cryptoAmount: 0.05, - totalFeesFiat: 5, - provider: { id: 'transak', name: 'Transak', links: [] }, - fiatCurrency: { symbol: 'EUR', decimals: 2, denomSymbol: '\u20ac' }, - cryptoCurrency: { symbol: 'ETH', decimals: 18 }, - createdAt: 1000, - walletAddress: '0xabc', - network: '1', - excludeFromPurchases: false, - orderType: 'BUY', - txHash: '0xtxhash', - }; - - const order = createInitialFiatOrder({ - ...baseParams, - rampsOrder: rampsOrder as never, - }); - expect(order.currency).toBe('EUR'); - expect(order.currencySymbol).toBe('\u20ac'); - expect(order.amount).toBe(100); - expect(order.txHash).toBe('0xtxhash'); - }); - - it('sets forceUpdate to false for terminal states', () => { - const order = createInitialFiatOrder({ - ...baseParams, - rampsOrder: { - status: 'COMPLETED', - fiatAmount: 100, - cryptoAmount: 0.05, - totalFeesFiat: 5, - createdAt: 1000, - walletAddress: '0xabc', - network: '1', - excludeFromPurchases: false, - orderType: 'BUY', - } as never, - }); - expect(order.forceUpdate).toBe(false); - }); - - it('defaults providerType to RAMPS_V2', () => { - const order = createInitialFiatOrder(baseParams); - expect(order.provider).toBe(FIAT_ORDER_PROVIDERS.RAMPS_V2); - }); - }); }); diff --git a/app/components/UI/Ramp/Views/Checkout/Checkout.tsx b/app/components/UI/Ramp/Views/Checkout/Checkout.tsx index 5b3065dfbf3b..fa918b8e227c 100644 --- a/app/components/UI/Ramp/Views/Checkout/Checkout.tsx +++ b/app/components/UI/Ramp/Views/Checkout/Checkout.tsx @@ -1,23 +1,19 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { parseUrl } from 'query-string'; import { WebView, WebViewNavigation } from '@metamask/react-native-webview'; import { useNavigation } from '@react-navigation/native'; -import type { RampsOrder } from '@metamask/ramps-controller'; -import { orderStatusToFiatOrderState } from '../../orderProcessor/unifiedOrderProcessor'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { useTheme } from '../../../../../util/theme'; import { getDepositNavbarOptions } from '../../../Navbar'; import { callbackBaseUrl } from '../../Aggregator/sdk'; import { - addFiatOrder, addFiatCustomIdData, removeFiatCustomIdData, - FiatOrder, + getRampRoutingDecision, } from '../../../../../reducers/fiatOrders'; -import { - FIAT_ORDER_PROVIDERS, - FIAT_ORDER_STATES, -} from '../../../../../constants/on-ramp'; +import { FIAT_ORDER_PROVIDERS } from '../../../../../constants/on-ramp'; import { CustomIdData } from '../../../../../reducers/fiatOrders/types'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; @@ -28,16 +24,14 @@ import { import ScreenLayout from '../../Aggregator/components/ScreenLayout'; import ErrorView from '../../Aggregator/components/ErrorView'; import Logger from '../../../../../util/Logger'; -import Engine from '../../../../../core/Engine'; -import NotificationManager from '../../../../../core/NotificationManager'; -import getNotificationDetails from '../../utils/getNotificationDetails'; -import useThunkDispatch from '../../../../hooks/useThunkDispatch'; -import stateHasOrder from '../../utils/stateHasOrder'; import { protectWalletModalVisible } from '../../../../../actions/user'; +import { useRampsOrders } from '../../hooks/useRampsOrders'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import useRampsUnifiedV2Enabled from '../../hooks/useRampsUnifiedV2Enabled'; +import { showV2OrderToast } from '../../utils/v2OrderToast'; import ButtonIcon, { ButtonIconSizes, } from '../../../../../component-library/components/Buttons/ButtonIcon'; @@ -86,128 +80,10 @@ export const createCheckoutNavDetails = createNavigationDetails( Routes.RAMP.CHECKOUT, ); -/** - * Creates the initial FiatOrder for a V2 order immediately after the provider - * callback is received. - * - * Uses deposit-style ID format (/providers/{code}/orders/{id}) so that - * extractProviderAndOrderCode in the unified processor can parse it on - * subsequent polls without relying on data.provider. - * - * The V2 API now returns a full provider object (with name and links) and a - * full fiatCurrency object (with decimals and denomSymbol) in the order - * response, so we derive everything we can from rampsOrder and use the nav - * params only as fallbacks for when the order is still UNKNOWN. - */ -export function createInitialFiatOrder(params: { - providerCode: string; - providerName: string; - orderId: string; - walletAddress: string; - network: string; - currency: string; - cryptocurrency: string; - rampsOrder?: RampsOrder; - providerType?: FIAT_ORDER_PROVIDERS; -}): FiatOrder { - const { - providerCode, - providerName, - orderId, - walletAddress, - network, - currency, - cryptocurrency, - rampsOrder, - providerType = FIAT_ORDER_PROVIDERS.RAMPS_V2, - } = params; - - const id = `/providers/${providerCode}/orders/${orderId}`; - - // If we have a full RampsOrder, use it directly - if (rampsOrder) { - const orderState = orderStatusToFiatOrderState(rampsOrder.status); - const isTerminalState = - orderState === FIAT_ORDER_STATES.FAILED || - orderState === FIAT_ORDER_STATES.COMPLETED || - orderState === FIAT_ORDER_STATES.CANCELLED; - - return { - id, - provider: providerType, - createdAt: rampsOrder.createdAt, - amount: rampsOrder.fiatAmount, - fee: rampsOrder.totalFeesFiat, - cryptoAmount: rampsOrder.cryptoAmount || 0, - cryptoFee: rampsOrder.totalFeesFiat || 0, - currency: rampsOrder.fiatCurrency?.symbol || currency, - currencySymbol: rampsOrder.fiatCurrency?.denomSymbol || '', - cryptocurrency: rampsOrder.cryptoCurrency?.symbol || cryptocurrency, - network: rampsOrder.network?.chainId || network, - state: orderState, - forceUpdate: !isTerminalState, - account: walletAddress, - txHash: rampsOrder.txHash, - excludeFromPurchases: rampsOrder.excludeFromPurchases, - orderType: rampsOrder.orderType as FiatOrder['orderType'], - errorCount: 0, - lastTimeFetched: isTerminalState ? Date.now() : 0, - data: rampsOrder, - }; - } - - // Fallback for when rampsOrder is not yet available (UNKNOWN status) - return { - id, - provider: providerType, - createdAt: Date.now(), - amount: 0, - fee: 0, - cryptoAmount: 0, - cryptoFee: 0, - currency, - currencySymbol: '', - cryptocurrency, - network, - state: FIAT_ORDER_STATES.PENDING, - forceUpdate: true, - account: walletAddress, - excludeFromPurchases: false, - orderType: 'BUY' as FiatOrder['orderType'], - errorCount: 0, - lastTimeFetched: 0, - data: { - id, - isOnlyLink: false, - provider: { - id: `/providers/${providerCode}`, - name: providerName, - }, - success: false, - cryptoAmount: 0, - fiatAmount: 0, - providerOrderId: orderId, - providerOrderLink: '', - createdAt: Date.now(), - totalFeesFiat: 0, - txHash: '', - walletAddress, - status: 'PENDING', - network: { chainId: network, name: '' }, - canBeUpdated: false, - idHasExpired: false, - excludeFromPurchases: false, - timeDescriptionPending: '', - orderType: 'BUY', - } as RampsOrder, - }; -} - const Checkout = () => { const sheetRef = useRef(null); const previousUrlRef = useRef(null); const dispatch = useDispatch(); - const dispatchThunk = useThunkDispatch(); const [error, setError] = useState(''); const [customIdData, setCustomIdData] = useState(); const isRedirectionHandledRef = useRef(false); @@ -216,6 +92,10 @@ const Checkout = () => { const params = useParams(); const theme = useTheme(); const { styles } = useStyles(styleSheet, {}); + const { addOrder, getOrderFromCallback } = useRampsOrders(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const rampRoutingDecision = useSelector(getRampRoutingDecision); + const isV2Enabled = useRampsUnifiedV2Enabled(); const { url: uri, @@ -224,10 +104,7 @@ const Checkout = () => { customOrderId, walletAddress, network, - currency, - cryptocurrency, userAgent, - providerType, onNavigationStateChange, callbackKey, } = params ?? {}; @@ -253,11 +130,43 @@ const Checkout = () => { { title: providerName ?? headerTitle }, theme, () => { - // Cancel analytics could go here + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_BACK_BUTTON_CLICKED) + .addProperties({ + location: 'Checkout', + ramp_type: 'UNIFIED_BUY_2', + ramp_routing: rampRoutingDecision ?? undefined, + }) + .build(), + ); }, ), ); - }, [navigation, theme, providerName, headerTitle]); + }, [ + navigation, + theme, + providerName, + headerTitle, + createEventBuilder, + trackEvent, + rampRoutingDecision, + ]); + + const hasTrackedScreenViewRef = useRef(false); + useEffect(() => { + if (uri && !hasTrackedScreenViewRef.current) { + hasTrackedScreenViewRef.current = true; + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_SCREEN_VIEWED) + .addProperties({ + location: 'Checkout', + ramp_type: 'UNIFIED_BUY_2', + ramp_routing: rampRoutingDecision ?? undefined, + }) + .build(), + ); + } + }, [uri, createEventBuilder, trackEvent, rampRoutingDecision]); useEffect(() => { if (!hasCallbackFlow || !customOrderId || !walletAddress || !network) { @@ -276,44 +185,6 @@ const Checkout = () => { dispatch(addFiatCustomIdData(data)); }, [customOrderId, walletAddress, network, dispatch, hasCallbackFlow]); - const handleOrderCreated = useCallback( - (order: FiatOrder) => { - dispatch(protectWalletModalVisible()); - - dispatchThunk((_dispatch, getState) => { - const state = getState(); - if (stateHasOrder(state, order)) { - return; - } - _dispatch(addFiatOrder(order)); - const notificationDetails = getNotificationDetails(order); - if (notificationDetails) { - NotificationManager.showSimpleNotification(notificationDetails); - } - }); - - if (providerType === FIAT_ORDER_PROVIDERS.RAMPS_V2) { - // Use reset() instead of pop() + navigate() to avoid a race condition: - // dangerouslyGetParent()?.pop() removes the ramp modal from the stack - // before navigate() can push the order details screen, sending the user - // to the home screen instead. - navigation.reset({ - index: 0, - routes: [ - { - name: Routes.RAMP.RAMPS_ORDER_DETAILS, - params: { orderId: order.id, showCloseButton: true }, - }, - ], - }); - } else { - // @ts-expect-error navigation prop mismatch - navigation.dangerouslyGetParent()?.pop(); - } - }, - [dispatch, dispatchThunk, navigation, providerType], - ); - const handleNavigationStateChange = useCallback( async (navState: WebViewNavigation) => { if ( @@ -338,12 +209,11 @@ const Checkout = () => { throw new Error('No wallet address or provider code available'); } - const rampsOrder = - await Engine.context.RampsController.getOrderFromCallback( - providerCode, - navState.url, - walletAddress, - ); + const rampsOrder = await getOrderFromCallback( + providerCode, + navState.url, + walletAddress, + ); if (!rampsOrder) { throw new Error('Order could not be retrieved from callback'); @@ -353,25 +223,31 @@ const Checkout = () => { dispatch(removeFiatCustomIdData(customIdData)); } - const orderId = - rampsOrder.providerOrderId || rampsOrder.id || customOrderId; - if (!orderId) { - throw new Error('Order response did not contain an order ID'); + addOrder(rampsOrder); + dispatch(protectWalletModalVisible()); + + if (isV2Enabled) { + showV2OrderToast({ + orderId: rampsOrder.providerOrderId, + cryptocurrency: + rampsOrder.cryptoCurrency?.symbol ?? params?.cryptocurrency ?? '', + cryptoAmount: rampsOrder.cryptoAmount, + status: rampsOrder.status, + }); } - const fiatOrder = createInitialFiatOrder({ - providerCode, - providerName: providerName ?? providerCode, - orderId, - walletAddress, - network: network ?? '', - currency: currency ?? '', - cryptocurrency: cryptocurrency ?? '', - rampsOrder, - providerType, + navigation.reset({ + index: 0, + routes: [ + { + name: Routes.RAMP.RAMPS_ORDER_DETAILS, + params: { + orderId: rampsOrder.providerOrderId, + showCloseButton: true, + }, + }, + ], }); - - handleOrderCreated(fiatOrder); } catch (navError) { Logger.error(navError as Error, { message: 'UnifiedCheckout: error handling callback', @@ -381,24 +257,29 @@ const Checkout = () => { }, [ hasCallbackFlow, - customOrderId, customIdData, providerCode, - providerName, - providerType, walletAddress, - network, - currency, - cryptocurrency, navigation, dispatch, - handleOrderCreated, + addOrder, + getOrderFromCallback, + isV2Enabled, + params?.cryptocurrency, ], ); const handleCancelPress = useCallback(() => { - // TODO: Add analytics tracking when analytics events are defined for unified flow - }, []); + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_CLOSE_BUTTON_CLICKED) + .addProperties({ + location: 'Checkout', + ramp_type: 'UNIFIED_BUY_2', + ramp_routing: rampRoutingDecision ?? undefined, + }) + .build(), + ); + }, [createEventBuilder, trackEvent, rampRoutingDecision]); const handleClosePress = useCallback(() => { handleCancelPress(); sheetRef.current?.onCloseBottomSheet(); diff --git a/app/components/UI/Ramp/Views/Checkout/__snapshots__/Checkout.test.tsx.snap b/app/components/UI/Ramp/Views/Checkout/__snapshots__/Checkout.test.tsx.snap index a6be1636969e..bcb33d8ac79a 100644 --- a/app/components/UI/Ramp/Views/Checkout/__snapshots__/Checkout.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Checkout/__snapshots__/Checkout.test.tsx.snap @@ -334,7 +334,7 @@ exports[`Checkout renders error view when no URL is provided 1`] = ` style={ [ { - "backgroundColor": "#3f434a66", + "backgroundColor": "#0a0d135c", "bottom": 0, "left": 0, "position": "absolute", @@ -384,7 +384,7 @@ exports[`Checkout renders error view when no URL is provided 1`] = ` [ { "backgroundColor": "#ffffff", - "borderColor": "#b7bbc866", + "borderColor": "#b4b4b566", "borderTopLeftRadius": 24, "borderTopRightRadius": 24, "borderWidth": 1, @@ -422,7 +422,7 @@ exports[`Checkout renders error view when no URL is provided 1`] = ` { + const { vars } = params; + const { screenHeight } = vars; + + return StyleSheet.create({ + headerContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + scrollView: { + maxHeight: screenHeight * 0.6, + }, + contentContainer: { + paddingHorizontal: 16, + paddingBottom: 16, + }, + errorText: { + lineHeight: 24, + }, + buttonContainer: { + paddingHorizontal: 16, + paddingBottom: 16, + gap: 8, + }, + button: { + width: '100%', + }, + }); +}; + +export default styleSheet; diff --git a/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/ErrorDetailsModal.test.tsx b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/ErrorDetailsModal.test.tsx new file mode 100644 index 000000000000..ed17a480df02 --- /dev/null +++ b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/ErrorDetailsModal.test.tsx @@ -0,0 +1,209 @@ +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import InAppBrowser from 'react-native-inappbrowser-reborn'; +import { renderScreen } from '../../../../../../util/test/renderWithProvider'; +import Routes from '../../../../../../constants/navigation/Routes'; +import ErrorDetailsModal from './ErrorDetailsModal'; + +const mockOnCloseBottomSheet = jest.fn((callback?: () => void) => { + callback?.(); +}); +const mockReplace = jest.fn(); +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + replace: mockReplace, + navigate: mockNavigate, + }), +})); + +jest.mock('react-native-inappbrowser-reborn', () => ({ + isAvailable: jest.fn(), + open: jest.fn(), +})); + +jest.mock( + '../../../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactActual = jest.requireActual('react'); + return ReactActual.forwardRef( + ( + { children }: { children: React.ReactNode }, + ref: React.Ref<{ onCloseBottomSheet: () => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: mockOnCloseBottomSheet, + })); + return <>{children}; + }, + ); + }, +); + +const mockUseParams = jest.fn(); +jest.mock('../../../../../../util/navigation/navUtils', () => ({ + createNavigationDetails: jest.fn(), + useParams: () => mockUseParams(), +})); + +function renderWithProvider(component: React.ComponentType) { + return renderScreen(component, { + name: 'ErrorDetailsModal', + }); +} + +describe('ErrorDetailsModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseParams.mockReturnValue({ + errorMessage: 'This is a test error message.', + }); + }); + + it('renders correctly and matches snapshot', () => { + const { toJSON } = renderWithProvider(ErrorDetailsModal); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with a multiline error message', () => { + mockUseParams.mockReturnValue({ + errorMessage: + 'Error on line 1.\nError on line 2.\nAdditional context for debugging.', + }); + + const { toJSON } = renderWithProvider(ErrorDetailsModal); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with an empty error message', () => { + mockUseParams.mockReturnValue({ + errorMessage: '', + }); + + const { toJSON } = renderWithProvider(ErrorDetailsModal); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with provider support info and matches snapshot', () => { + mockUseParams.mockReturnValue({ + errorMessage: 'Provider error occurred.', + providerName: 'Transak', + providerSupportUrl: 'https://support.transak.com', + }); + + const { toJSON } = renderWithProvider(ErrorDetailsModal); + expect(toJSON()).toMatchSnapshot(); + }); + + it('closes the modal when the close button is pressed', () => { + const { getByTestId } = renderWithProvider(ErrorDetailsModal); + const closeButton = getByTestId('error-details-close-button'); + + fireEvent.press(closeButton); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); + + it('closes the modal when Got it button is pressed', () => { + const { getByText } = renderWithProvider(ErrorDetailsModal); + + fireEvent.press(getByText('Got it')); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); + + it('opens support URL in InAppBrowser when available', async () => { + (InAppBrowser.isAvailable as jest.Mock).mockResolvedValue(true); + + mockUseParams.mockReturnValue({ + errorMessage: 'Provider error occurred.', + providerName: 'Transak', + providerSupportUrl: 'https://support.transak.com', + }); + + const { getByText } = renderWithProvider(ErrorDetailsModal); + + fireEvent.press(getByText('Contact Transak support')); + + await waitFor(() => { + expect(InAppBrowser.open).toHaveBeenCalledWith( + 'https://support.transak.com', + ); + }); + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); + + it('falls back to SimpleWebview when InAppBrowser is not available', async () => { + (InAppBrowser.isAvailable as jest.Mock).mockResolvedValue(false); + + mockUseParams.mockReturnValue({ + errorMessage: 'Provider error occurred.', + providerName: 'Transak', + providerSupportUrl: 'https://support.transak.com', + }); + + const { getByText } = renderWithProvider(ErrorDetailsModal); + + fireEvent.press(getByText('Contact Transak support')); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: 'https://support.transak.com', + title: 'Transak', + }, + }); + }); + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); + + it('does not render contact support button when provider info is missing', () => { + const { queryByText } = renderWithProvider(ErrorDetailsModal); + + expect(queryByText(/Contact.*support/u)).toBeNull(); + }); + + it('renders with change provider button and matches snapshot', () => { + mockUseParams.mockReturnValue({ + errorMessage: 'No quotes available.', + showChangeProvider: true, + }); + + const { toJSON } = renderWithProvider(ErrorDetailsModal); + expect(toJSON()).toMatchSnapshot(); + }); + + it('navigates to provider selection when Change provider is pressed', () => { + mockUseParams.mockReturnValue({ + errorMessage: 'No quotes available.', + showChangeProvider: true, + amount: 250, + }); + + const { getByText } = renderWithProvider(ErrorDetailsModal); + + fireEvent.press(getByText('Change provider')); + + expect(mockReplace).toHaveBeenCalledWith( + Routes.RAMP.MODALS.PROVIDER_SELECTION, + { amount: 250 }, + ); + }); + + it('shows change provider instead of contact support when both flags are set', () => { + mockUseParams.mockReturnValue({ + errorMessage: 'Error.', + providerName: 'Transak', + providerSupportUrl: 'https://support.transak.com', + showChangeProvider: true, + }); + + const { getByText, queryByText } = renderWithProvider(ErrorDetailsModal); + + expect(getByText('Change provider')).toBeOnTheScreen(); + expect(queryByText(/Contact.*support/u)).toBeNull(); + }); +}); diff --git a/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/ErrorDetailsModal.tsx b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/ErrorDetailsModal.tsx new file mode 100644 index 000000000000..f6e520a6eb82 --- /dev/null +++ b/app/components/UI/Ramp/Views/Modals/ErrorDetailsModal/ErrorDetailsModal.tsx @@ -0,0 +1,158 @@ +import React, { useCallback, useRef } from 'react'; +import { View, ScrollView, useWindowDimensions } from 'react-native'; +import InAppBrowser from 'react-native-inappbrowser-reborn'; +import { useNavigation, type ParamListBase } from '@react-navigation/native'; +import type { StackNavigationProp } from '@react-navigation/stack'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../../component-library/components/Texts/Text'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import Icon, { + IconName, + IconSize, + IconColor, +} from '../../../../../../component-library/components/Icons/Icon'; +import Button, { + ButtonSize, + ButtonVariants, +} from '../../../../../../component-library/components/Buttons/Button'; +import { useStyles } from '../../../../../../component-library/hooks'; +import { + createNavigationDetails, + useParams, +} from '../../../../../../util/navigation/navUtils'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { strings } from '../../../../../../../locales/i18n'; +import Logger from '../../../../../../util/Logger'; +import styleSheet from './ErrorDetailsModal.styles'; + +export interface ErrorDetailsModalParams { + errorMessage: string; + providerName?: string; + providerSupportUrl?: string; + showChangeProvider?: boolean; + amount?: number; +} + +export const createErrorDetailsModalNavDetails = + createNavigationDetails( + Routes.RAMP.MODALS.ID, + Routes.RAMP.MODALS.ERROR_DETAILS, + ); + +function ErrorDetailsModal() { + const sheetRef = useRef(null); + const navigation = useNavigation>(); + const { height: screenHeight } = useWindowDimensions(); + const { styles } = useStyles(styleSheet, { + screenHeight, + }); + + const { + errorMessage, + providerName, + providerSupportUrl, + showChangeProvider, + amount, + } = useParams(); + + const handleClose = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + const handleContactSupport = useCallback(async () => { + if (!providerSupportUrl) return; + try { + if (await InAppBrowser.isAvailable()) { + handleClose(); + await InAppBrowser.open(providerSupportUrl); + } else { + handleClose(); + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { + url: providerSupportUrl, + title: providerName, + }, + }); + } + } catch (error) { + Logger.error( + error as Error, + 'ErrorDetailsModal: Failed to open support URL', + ); + } + }, [providerSupportUrl, providerName, handleClose, navigation]); + + const handleChangeProvider = useCallback(() => { + navigation.replace(Routes.RAMP.MODALS.PROVIDER_SELECTION, { amount }); + }, [navigation, amount]); + + return ( + + + + + + {strings('deposit.errors.error_details_title')} + + + + + + + + {errorMessage} + + + + + + {showChangeProvider ? ( +