diff --git a/.eslintrc.js b/.eslintrc.js index 68f853d2ed72..02f19036862b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -77,7 +77,7 @@ module.exports = { }, }, { - files: ['scripts/**/*.js', 'app.config.js'], + files: ['scripts/**/*.js', 'e2e/tools/**/*.{js,ts}', 'app.config.js'], rules: { 'no-console': 0, 'import/no-commonjs': 0, diff --git a/.github/actions/ai-e2e-analysis/action.yml b/.github/actions/ai-e2e-analysis/action.yml deleted file mode 100644 index 48611af9f286..000000000000 --- a/.github/actions/ai-e2e-analysis/action.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: 'AI E2E Analysis' -description: 'Run AI-powered E2E test selection analysis based on code changes' -inputs: - event-name: - description: 'GitHub event name (pull_request, workflow_dispatch, schedule, etc.)' - required: true - claude-api-key: - description: 'Claude API key for AI analysis' - required: true - github-token: - description: 'GitHub token for PR comments' - required: true - pr-number: - description: 'Pull request number for commenting' - required: true - repository: - description: 'Repository name (owner/repo) for commenting' - required: true - post-comment: - description: 'Whether to post a comment to the PR' - required: false - default: 'false' - -outputs: - test-matrix: - description: 'JSON matrix for GitHub Actions test jobs - array of {tag, fileCount, split, totalSplits}' - value: ${{ steps.ai-analysis.outputs.test_matrix }} - -runs: - using: 'composite' - steps: - - name: Full checkout for AI analysis - uses: actions/checkout@v4 - with: - fetch-depth: 50 - - - name: Disable sparse checkout and restore all files - shell: bash - run: | - git sparse-checkout disable - git checkout HEAD -- . - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - - - name: Install minimal dependencies for AI analysis - shell: bash - run: | - echo "šŸ“¦ Installing only required packages for AI analysis..." - # Install to a separate location that won't be overwritten - mkdir -p /tmp/ai-deps - cd /tmp/ai-deps - npm init -y - npm install @anthropic-ai/sdk@latest esbuild-register@latest --no-audit --no-fund - echo "āœ… AI analysis dependencies installed in /tmp/ai-deps" - - - name: Copy AI dependencies to workspace - shell: bash - run: | - echo "šŸ“‹ Copying AI dependencies to workspace..." - # Create node_modules if it doesn't exist - mkdir -p node_modules - # Copy our pre-installed dependencies - cp -r /tmp/ai-deps/node_modules/* node_modules/ - echo "āœ… AI dependencies available in workspace" - - - name: Test Selection AI Analysis - id: ai-analysis - shell: bash - env: - E2E_CLAUDE_API_KEY: ${{ inputs.claude-api-key }} - EVENT_NAME: ${{ inputs.event-name }} - PR_NUMBER: ${{ inputs.pr-number }} - GH_TOKEN: ${{ inputs.github-token }} - run: | - # Only run AI analysis for pull_request events - if [[ "$EVENT_NAME" == "pull_request" ]]; then - echo "āœ… Running AI analysis for PR #$PR_NUMBER" - node .github/scripts/ai-e2e-analysis.mjs - else - echo "ā­ļø Skipping AI analysis - only runs on PRs)" - echo "test_matrix=[]" >> "$GITHUB_OUTPUT" - echo "tags=" >> "$GITHUB_OUTPUT" - echo "tags_display=None (AI analysis skipped)" >> "$GITHUB_OUTPUT" - echo "risk_level=N/A" >> "$GITHUB_OUTPUT" - echo "reasoning=AI analysis only runs for pull_request events with changed files" >> "$GITHUB_OUTPUT" - echo "confidence=0" >> "$GITHUB_OUTPUT" - fi - - - name: Delete existing AI E2E comments - if: inputs.post-comment == 'true' && inputs.pr-number != '' && inputs.github-token != '' - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - run: | - echo "šŸ—‘ļø Deleting all existing AI E2E comments..." - - # Get all AI E2E comment IDs (both analysis-only and test mode comments) - ALL_COMMENT_IDS=$(gh api "repos/${{ inputs.repository }}/issues/${{ inputs.pr-number }}/comments" \ - --jq '.[] | select(.body | test("šŸ¤– AI E2E Test Analysis|šŸ” AI E2E Analysis Report")) | .id') - - COMMENT_COUNT=$(echo "$ALL_COMMENT_IDS" | wc -l | tr -d ' ') - echo "šŸ“Š Found $COMMENT_COUNT existing AI E2E comments" - - if [ -n "$ALL_COMMENT_IDS" ] && [ "$COMMENT_COUNT" -gt 0 ]; then - echo "šŸ—‘ļø Deleting all $COMMENT_COUNT AI E2E comments..." - - echo "$ALL_COMMENT_IDS" | while read -r COMMENT_ID; do - if [ -n "$COMMENT_ID" ]; then - echo " Deleting comment: $COMMENT_ID" - gh api "repos/${{ inputs.repository }}/issues/comments/$COMMENT_ID" \ - --method DELETE > /dev/null 2>&1 || echo " āš ļø Failed to delete comment $COMMENT_ID" - fi - done - echo "✨ Cleanup completed - deleted all $COMMENT_COUNT comments" - else - echo "šŸ“ No existing AI E2E comments found" - fi - - - name: Create PR comment with analysis results - if: inputs.post-comment == 'true' && inputs.pr-number != '' && inputs.github-token != '' - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - run: | - # Create analysis report comment - cat > pr_comment.md << EOF - ## šŸ” AI E2E Analysis Report - - **Risk Level:** ${{ steps.ai-analysis.outputs.risk_level }} | **Selected Tags:** ${{ steps.ai-analysis.outputs.tags_display }} - - **šŸ¤– AI Analysis:** - > ${{ steps.ai-analysis.outputs.reasoning }} - - **šŸ“Š Analysis Results:** - - **Confidence:** ${{ steps.ai-analysis.outputs.confidence }}% - - **šŸ·ļø Test Recommendation:** - Based on the code changes, the AI recommends testing the following areas: **${{ steps.ai-analysis.outputs.tags_display }}** - - _šŸ” [View complete analysis](https://github.com/${{ inputs.repository }}/actions/runs/${{ github.run_id }}) • AI E2E Analysis_ - - - EOF - - # Create new comment - echo "šŸ“ Creating AI E2E analysis comment..." - gh pr comment ${{ inputs.pr-number }} --repo ${{ inputs.repository }} --body-file pr_comment.md - echo "āœ… Successfully created comment" \ No newline at end of file diff --git a/.github/actions/smart-e2e-selection/action.yml b/.github/actions/smart-e2e-selection/action.yml new file mode 100644 index 000000000000..e6f5fa81d11e --- /dev/null +++ b/.github/actions/smart-e2e-selection/action.yml @@ -0,0 +1,197 @@ +name: 'Smart E2E Selection' +description: 'Run AI-powered E2E test selection based on code changes' +inputs: + event-name: + description: 'GitHub event name (pull_request, workflow_dispatch, schedule, etc.)' + required: true + claude-api-key: + description: 'Claude API key for AI analysis' + required: true + github-token: + description: 'GitHub token for PR comments' + required: true + pr-number: + description: 'Pull request number for commenting' + required: true + repository: + description: 'Repository name (owner/repo) for commenting' + required: true + post-comment: + description: 'Whether to post a comment to the PR' + required: false + default: 'false' + +outputs: + ai_e2e_test_tags: + description: 'E2E test tags to run (JSON array format)' + value: ${{ steps.ai-analysis.outputs.ai_e2e_test_tags }} + ai_confidence: + description: 'AI confidence score (0-100)' + value: ${{ steps.ai-analysis.outputs.ai_confidence }} + +runs: + using: 'composite' + steps: + - name: Checkout for PR analysis + uses: actions/checkout@v4 + with: + fetch-depth: 1 # Shallow clone - only need PR commit + + - name: Disable sparse checkout and restore all files + shell: bash + run: | + git sparse-checkout disable + git checkout HEAD -- . + + - name: Fetch base branch for comparison + shell: bash + run: | + # Fetch base branch with enough depth to compute merge base for git diff + git fetch origin main --depth=100 2>/dev/null || git fetch origin master --depth=100 2>/dev/null || true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Install minimal dependencies for AI analysis + shell: bash + run: | + echo "šŸ“¦ Installing only required packages for AI analysis..." + # Install to a separate location that won't be overwritten + mkdir -p /tmp/ai-deps + cd /tmp/ai-deps + npm init -y + npm install @anthropic-ai/sdk@0.68.0 esbuild-register@3.6.0 --no-audit --no-fund + echo "āœ… AI analysis dependencies installed in /tmp/ai-deps" + + - name: Copy AI dependencies to workspace + shell: bash + run: | + echo "šŸ“‹ Copying AI dependencies to workspace..." + # Create node_modules if it doesn't exist + mkdir -p node_modules + # Copy our pre-installed dependencies + cp -r /tmp/ai-deps/node_modules/* node_modules/ + echo "āœ… AI dependencies available in workspace" + + - name: Check skip-smart-e2e-selection label + id: check-skip-label + if: inputs.event-name == 'pull_request' && inputs.pr-number != '' + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + run: | + echo "SKIP=false" >> "$GITHUB_OUTPUT" + if gh pr view ${{ inputs.pr-number }} --repo ${{ inputs.repository }} --json labels --jq '.labels[].name' | grep -qx "skip-smart-e2e-selection"; then + echo "SKIP=true" >> "$GITHUB_OUTPUT" + echo "ā­ļø SKIP=true due to 'skip-smart-e2e-selection' label on PR" + fi + + - name: Run E2E AI analysis + id: ai-analysis + shell: bash + env: + E2E_CLAUDE_API_KEY: ${{ inputs.claude-api-key }} + EVENT_NAME: ${{ inputs.event-name }} + PR_NUMBER: ${{ inputs.pr-number }} + GH_TOKEN: ${{ inputs.github-token }} + GITHUB_REPOSITORY: ${{ inputs.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + run: | + echo "ai_e2e_test_tags=[\"ALL\"]" >> "$GITHUB_OUTPUT" + echo "ai_confidence=0" >> "$GITHUB_OUTPUT" + SHOULD_SKIP=false + SKIP_REASON="" + + if [[ "$EVENT_NAME" != "pull_request" ]]; then + SHOULD_SKIP=true + SKIP_REASON="only runs on PRs" + elif [[ -n "${{ steps.check-skip-label.outputs.SKIP }}" ]] && [[ "${{ steps.check-skip-label.outputs.SKIP }}" == "true" ]]; then + SHOULD_SKIP=true + SKIP_REASON="skip-smart-e2e-selection label found" + fi + + if [[ "$SHOULD_SKIP" == "true" ]]; then + echo "ā­ļø Skipping AI analysis - $SKIP_REASON" + else + echo "āœ… Running AI analysis for PR #$PR_NUMBER" + # The script will generate the GH output variables + node .github/scripts/e2e-smart-selection.mjs + fi + + - name: Display AI Analysis Outputs + shell: bash + run: | + echo "šŸ“Š Final GitHub Action Outputs:" + echo "================================" + echo "ai_e2e_test_tags: ${{ steps.ai-analysis.outputs.ai_e2e_test_tags }}" + echo "ai_confidence: ${{ steps.ai-analysis.outputs.ai_confidence }}" + echo "================================" + + - name: Delete previous comments + if: inputs.post-comment == 'true' && inputs.pr-number != '' && inputs.github-token != '' + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + run: | + echo "šŸ—‘ļø Deleting all existing Smart E2E selection comments..." + + # Get comment IDs using the HTML marker for precise identification + ALL_COMMENT_IDS=$(gh api "repos/${{ inputs.repository }}/issues/${{ inputs.pr-number }}/comments" \ + --jq '.[] | select(.body | contains("")) | .id') + + COMMENT_COUNT=$(echo "$ALL_COMMENT_IDS" | wc -l | tr -d ' ') + echo "šŸ“Š Found $COMMENT_COUNT comments" + + if [ -n "$ALL_COMMENT_IDS" ] && [ "$COMMENT_COUNT" -gt 0 ]; then + echo "šŸ—‘ļø Deleting all $COMMENT_COUNT comments..." + + echo "$ALL_COMMENT_IDS" | while read -r COMMENT_ID; do + if [ -n "$COMMENT_ID" ]; then + echo " Deleting comment: $COMMENT_ID" + gh api "repos/${{ inputs.repository }}/issues/comments/$COMMENT_ID" \ + --method DELETE > /dev/null 2>&1 || echo " āš ļø Failed to delete comment $COMMENT_ID" + fi + done + echo "✨ Cleanup completed - deleted all $COMMENT_COUNT comments" + else + echo "šŸ“ No Smart E2E selection comments found" + fi + + - name: Create PR comment + if: inputs.post-comment == 'true' && inputs.pr-number != '' && inputs.github-token != '' + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + run: | + # Comment configuration (single source of truth) + COMMENT_FILE="pr_comment.md" + TITLE="## šŸ” Smart E2E Test Selection" + FOOTER="[View GitHub Actions results](https://github.com/${{ inputs.repository }}/actions/runs/${{ github.run_id }})" + MARKER="" + COMMENT_BODY="" + + if [[ "${{ steps.check-skip-label.outputs.SKIP }}" == "true" ]]; then + COMMENT_BODY="ā­ļø **Smart E2E selection disabled due to \`skip-smart-e2e-selection\` label** + All E2E tests pre-selected." + + else + # Read analysis results from file + if [ -f "$COMMENT_FILE" ]; then + COMMENT_BODY=$(cat "$COMMENT_FILE") + else + echo "āš ļø PR comment file not found: $COMMENT_FILE - using default message" + COMMENT_BODY="AI analysis completed but results file was not generated." + fi + fi + + # Build and post comment + FULL_COMMENT="${TITLE} + ${COMMENT_BODY} + + ${FOOTER} + ${MARKER}" + + gh pr comment ${{ inputs.pr-number }} --repo ${{ inputs.repository }} --body "$FULL_COMMENT" + echo "āœ… Successfully created comment" diff --git a/.github/guidelines/LABELING_GUIDELINES.md b/.github/guidelines/LABELING_GUIDELINES.md index a84d123c65bf..56b90c0bca75 100644 --- a/.github/guidelines/LABELING_GUIDELINES.md +++ b/.github/guidelines/LABELING_GUIDELINES.md @@ -29,6 +29,11 @@ Using any of these labels should be exceptional in case of CI friction and urgen - **skip-sonar-cloud**: The PR will be merged without running SonarCloud checks. - **skip-e2e**: The PR will be merged without running E2E tests. +- **skip-e2e-quality-gate**: This label will disable the default test retries for E2E test files modified in a PR. Useful when making large refactors or when changes don't pose flakiness risk. + +### Skip Smart E2E Selection + +- **skip-smart-e2e-selection**: This label is used to bypass the Smart E2E Selection (select E2E tests to run depending on the PR changes). Useful when we do want all E2E tests to run for a given PR. ### Block merge if any is present diff --git a/.github/scripts/ai-e2e-analysis.mjs b/.github/scripts/ai-e2e-analysis.mjs deleted file mode 100644 index d134ac3ebe82..000000000000 --- a/.github/scripts/ai-e2e-analysis.mjs +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env node -import { execSync } from 'child_process'; -import { appendFileSync } from 'fs'; - -/** - * AI E2E Analysis Script - * This script handles the complex logic for running AI analysis and processing results - * Usage: node ai-e2e-analysis.mjs - */ - -const PR_NUMBER = process.env.PR_NUMBER || ''; - -const GITHUB_OUTPUT = process.env.GITHUB_OUTPUT; -const GITHUB_STEP_SUMMARY = process.env.GITHUB_STEP_SUMMARY; - -/** - * Execute shell command and return output - */ -function execCommand(command, options = {}) { - try { - return execSync(command, { - encoding: 'utf8', - stdio: options.silent ? 'pipe' : 'inherit', - ...options - }).toString().trim(); - } catch (error) { - if (!options.ignoreError) { - throw error; - } - return options.defaultValue || 'ERROR'; - } -} - -/** - * Write output to GitHub Actions output file - */ -function setOutput(key, value) { - if (!GITHUB_OUTPUT) return; - - if (typeof value === 'string' && value.includes('\n')) { - // Handle multi-line content with EOF delimiter - appendFileSync(GITHUB_OUTPUT, `${key}< 0 && parsedResult.testFileBreakdown) { - testMatrix = parsedResult.testFileBreakdown - .filter(breakdown => breakdown.recommendedSplits > 0) - .flatMap(breakdown => { - const splits = Array.from({ length: breakdown.recommendedSplits }, (_, i) => i + 1); - return splits.map(split => ({ - tag: breakdown.tag, - fileCount: breakdown.fileCount, - split: split, - totalSplits: breakdown.recommendedSplits - })); - }); -} - -const testMatrixJson = JSON.stringify(testMatrix); -console.log(`šŸ”¢ Generated test matrix: ${testMatrixJson}`); - -// Set outputs for GitHub Actions (only test_matrix is used by the action) -setOutput('test_matrix', testMatrixJson); - -// Set additional outputs for internal script use (step summary, PR comments) -setOutput('tags', tags); -setOutput('tags_display', tagDisplay); -setOutput('risk_level', riskLevel); -setOutput('reasoning', reasoning); -setOutput('confidence', confidence); - -// Handle multi-line breakdown content -if (parsedResult.testFileBreakdown) { - const breakdown = parsedResult.testFileBreakdown - .map(item => ` - ${item.tag}: ${item.fileCount} files → ${item.recommendedSplits} splits`) - .join('\n'); - setOutput('breakdown', breakdown); -} - -// Log summary -const matrixLength = testMatrix.length; -if (tagCount === 0) { - console.log('ā„¹ļø No E2E tests recommended - AI determined changes are very low risk'); -} else if (matrixLength > 0) { - console.log(`āœ… Generated test matrix with ${matrixLength} job(s)`); -} else { - console.log('ā„¹ļø Selected tags have no test files'); -} - -// Create readable test plan with file breakdown -appendStepSummary('## šŸ” AI E2E Analysis Report'); -if (tagCount === 0) { - appendStepSummary('- **Selected E2E tags**: None (no tests recommended)'); - appendStepSummary(`- **Risk Level**: ${riskLevel}`); - appendStepSummary(`- **AI Confidence**: ${confidence}%`); -} else { - appendStepSummary(`- **Selected E2E tags**: ${tagDisplay}`); - appendStepSummary(`- **Risk Level**: ${riskLevel}`); - appendStepSummary(`- **AI Confidence**: ${confidence}%`); -} - -// Add AI reasoning in expandable section -appendStepSummary(''); -appendStepSummary('
'); -appendStepSummary('click to see šŸ¤– AI reasoning details'); -appendStepSummary(''); -appendStepSummary(reasoning); - -// Add test file breakdown if available -if (parsedResult.testFileBreakdown && parsedResult.testFileBreakdown.length > 0) { - const breakdown = parsedResult.testFileBreakdown - .map(item => ` - ${item.tag}: ${item.fileCount} files → ${item.recommendedSplits} splits`) - .join('\n'); - - if (breakdown) { - appendStepSummary(''); - appendStepSummary('### šŸ“Š Test File Breakdown'); - appendStepSummary(breakdown); - } -} - -appendStepSummary(''); -appendStepSummary('
'); - -console.log('āœ… AI analysis script completed successfully'); \ No newline at end of file diff --git a/.github/scripts/e2e-smart-selection.mjs b/.github/scripts/e2e-smart-selection.mjs new file mode 100644 index 000000000000..504800006619 --- /dev/null +++ b/.github/scripts/e2e-smart-selection.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +import { execSync } from 'child_process'; +import { appendFileSync, writeFileSync, readFileSync } from 'fs'; + +/** + * Runs the Smart E2E selection script, + * Generates the Github outputs, Step Summary and PR Comment Body +*/ + +const env = { + PR_NUMBER: process.env.PR_NUMBER || '', + GITHUB_OUTPUT: process.env.GITHUB_OUTPUT || '', + GITHUB_STEP_SUMMARY: process.env.GITHUB_STEP_SUMMARY || '', +}; + +const PR_COMMENT_FILE = 'pr_comment.md'; + +function setGithubOutputs(key, value) { + if (!env.GITHUB_OUTPUT) return; + + if (typeof value === 'string' && value.includes('\n')) { + // Handle multi-line content with EOF delimiter + appendFileSync(env.GITHUB_OUTPUT, `${key}< 0 ? selectedTags.join(', ') : 'None (no tests recommended)', + tagCount: selectedTags.length, + riskLevel: parsedResult.riskLevel || '', + confidence: parsedResult.confidence || '', + reasoning: parsedResult.reasoning || '', + }; + + setGitHubOutputs(analysis); + const summaryContent = generateAnalysisSummary(analysis); + appendGithubSummary('## šŸ” Smart E2E Test Selection\n' + summaryContent); + generatePRComment(summaryContent); + + } catch (error) { + console.error('āŒ Error running AI analysis:', error.message || error); + process.exit(1); + } +} + +main().catch(error => { + console.error('\nāŒ Unexpected error:', error); + process.exit(1); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20e33d172a39..11e8f897744c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,34 +227,35 @@ jobs: needs_e2e_build: uses: ./.github/workflows/needs-e2e-build.yml - ai-e2e-analysis: - name: 'AI E2E Analysis' + smart-e2e-selection: + name: 'Smart E2E Selection' runs-on: ubuntu-latest continue-on-error: true permissions: contents: read pull-requests: write outputs: - test-matrix: ${{ steps.ai-analysis.outputs.test-matrix }} + ai_e2e_test_tags: ${{ steps.e2e-selection.outputs.ai_e2e_test_tags }} + ai_confidence: ${{ steps.e2e-selection.outputs.ai_confidence }} steps: - name: Checkout for action definition uses: actions/checkout@v4 with: sparse-checkout: | - .github/actions/ai-e2e-analysis + .github/actions/smart-e2e-selection sparse-checkout-cone-mode: false fetch-depth: 1 - - name: Run AI E2E Analysis - id: ai-analysis - uses: ./.github/actions/ai-e2e-analysis + - name: Run Smart E2E Selection + id: e2e-selection + uses: ./.github/actions/smart-e2e-selection with: event-name: ${{ github.event_name }} claude-api-key: ${{ secrets.E2E_CLAUDE_API_KEY }} github-token: ${{ github.token }} - pr-number: ${{ github.event.pull_request.number }} + pr-number: ${{ github.event.pull_request.number}} repository: ${{ github.repository }} - post-comment: 'false' + post-comment: 'true' build-android-apks: name: 'Build Android APKs' diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index c28a100cb10a..9fe3994f42a3 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -6,6 +6,7 @@ import { } from '@react-navigation/native'; import { createStackNavigator } from '@react-navigation/stack'; import Login from '../../Views/Login'; +import OAuthRehydration from '../../Views/OAuthRehydration'; import QRTabSwitcher from '../../Views/QRTabSwitcher'; import DataCollectionModal from '../../Views/DataCollectionModal'; import Onboarding from '../../Views/Onboarding'; @@ -278,7 +279,7 @@ const OnboardingNav = () => ( /> @@ -908,6 +909,11 @@ const AppFlow = () => { component={Login} options={{ headerShown: false }} /> + { { state: initialState, }, + true, + false, ); expect(toJSON()).toMatchSnapshot(); }); @@ -46,6 +48,8 @@ describe('BackupAlert', () => { { state: initialState, }, + true, + false, ); const rightButton = getByTestId(PROTECT_WALLET_BUTTON); fireEvent.press(rightButton); diff --git a/app/components/UI/Bridge/utils/index.test.ts b/app/components/UI/Bridge/utils/index.test.ts index eb89d7aecbef..c4fe1f92d0b9 100644 --- a/app/components/UI/Bridge/utils/index.test.ts +++ b/app/components/UI/Bridge/utils/index.test.ts @@ -15,6 +15,10 @@ import { import { Hex } from '@metamask/utils'; import { SolScope } from '@metamask/keyring-api'; import Engine from '../../../../core/Engine'; +import { + formatAddressToAssetId, + isNonEvmChainId, +} from '@metamask/bridge-controller'; jest.mock('../../../../core/AppConstants', () => ({ __esModule: true, @@ -32,11 +36,23 @@ jest.mock('../../../../core/Engine', () => ({ }, })); +jest.mock('@metamask/bridge-controller', () => ({ + ...jest.requireActual('@metamask/bridge-controller'), + formatAddressToAssetId: jest.fn(), + isNonEvmChainId: jest.fn(), +})); + const mockWipeBridgeStatus = Engine.context.BridgeStatusController .wipeBridgeStatus as jest.MockedFunction< typeof Engine.context.BridgeStatusController.wipeBridgeStatus >; +const mockFormatAddressToAssetId = + formatAddressToAssetId as jest.MockedFunction; +const mockIsNonEvmChainId = isNonEvmChainId as jest.MockedFunction< + typeof isNonEvmChainId +>; + describe('Bridge Utils', () => { beforeEach(() => { jest.clearAllMocks(); @@ -55,18 +71,18 @@ describe('Bridge Utils', () => { LINEA_CHAIN_ID, ]; - it('should return true when bridge is active and chain ID is allowed', () => { + it('return true when bridge is active and chain ID is allowed', () => { supportedChainIds.forEach((chainId) => { expect(isBridgeAllowed(chainId)).toBe(true); }); }); - it('should return false when bridge is active but chain ID is not allowed', () => { + it('return false when bridge is active but chain ID is not allowed', () => { const unsupportedChainId = '0x1234' as Hex; expect(isBridgeAllowed(unsupportedChainId)).toBe(false); }); - it('should return false when bridge is inactive', () => { + it('return false when bridge is inactive', () => { Object.defineProperty(AppConstants.BRIDGE, 'ACTIVE', { get: () => false, }); @@ -76,7 +92,7 @@ describe('Bridge Utils', () => { }); }); - it('should handle invalid chain ID formats', () => { + it('handle invalid chain ID formats', () => { const invalidChainIds = ['0x123' as Hex, '0x' as Hex]; invalidChainIds.forEach((chainId) => { @@ -84,7 +100,7 @@ describe('Bridge Utils', () => { }); }); - it('should handle edge cases', () => { + it('handle edge cases', () => { // Test with malformed chain ID expect( isBridgeAllowed( @@ -99,7 +115,9 @@ describe('Bridge Utils', () => { const testAddressLowercase = testAddress.toLowerCase(); const evmChainId = ETH_CHAIN_ID; - it('should call wipeBridgeStatus twice for EVM chains (original and lowercase address)', () => { + it('calls wipeBridgeStatus twice for EVM chains with original and lowercase address', () => { + mockIsNonEvmChainId.mockReturnValue(false); + wipeBridgeStatus(testAddress, evmChainId); expect(mockWipeBridgeStatus).toHaveBeenCalledTimes(2); @@ -113,7 +131,9 @@ describe('Bridge Utils', () => { }); }); - it('should call wipeBridgeStatus only once for Solana chains (original address only)', () => { + it('calls wipeBridgeStatus once for Solana chains with original address only', () => { + mockIsNonEvmChainId.mockReturnValue(true); + wipeBridgeStatus(testAddress, SolScope.Mainnet); expect(mockWipeBridgeStatus).toHaveBeenCalledTimes(1); @@ -125,77 +145,110 @@ describe('Bridge Utils', () => { }); describe('getTokenIconUrl', () => { - it('should return token icon URL for native token on Ethereum', () => { - // Arrange + beforeEach(() => { + mockIsNonEvmChainId.mockReturnValue(false); + }); + + it('returns token icon URL for native token on Ethereum', () => { const nativeTokenAddress = '0x0000000000000000000000000000000000000000'; + mockFormatAddressToAssetId.mockReturnValue('eip155:1/slip44:60'); - // Act const result = getTokenIconUrl(nativeTokenAddress, ETH_CHAIN_ID); - // Assert expect(result).toBe( 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', ); }); - it('should return token icon URL for ERC20 token on Ethereum', () => { - // Arrange + it('returns token icon URL for ERC20 token on Ethereum', () => { const usdcAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + mockFormatAddressToAssetId.mockReturnValue( + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ); - // Act const result = getTokenIconUrl(usdcAddress, ETH_CHAIN_ID); - // Assert expect(result).toBe( 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', ); }); - it('should return token icon URL for Solana native token', () => { - // Arrange + it('returns token icon URL for Solana native token', () => { const solNativeAddress = '0x0000000000000000000000000000000000000000'; + mockIsNonEvmChainId.mockReturnValue(true); + mockFormatAddressToAssetId.mockReturnValue( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ); - // Act const result = getTokenIconUrl(solNativeAddress, SolScope.Mainnet); - // Assert expect(result).toBe( 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44/501.png', ); }); - it('should return token icon URL for Solana SPL token', () => { - // Arrange + it('returns token icon URL for Solana SPL token', () => { const usdcSolanaAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + mockIsNonEvmChainId.mockReturnValue(true); + mockFormatAddressToAssetId.mockReturnValue( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + ); - // Act const result = getTokenIconUrl(usdcSolanaAddress, SolScope.Mainnet); - // Assert expect(result).toBe( 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v.png', ); }); - it('should return undefined for invalid address', () => { - // Arrange - const invalidAddress = 'invalid'; + it('returns undefined when formatAddressToAssetId returns null', () => { + const address = '0x1234567890123456789012345678901234567890'; + // @ts-expect-error Testing null return value + mockFormatAddressToAssetId.mockReturnValue(null); + + const result = getTokenIconUrl(address, ETH_CHAIN_ID); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when formatAddressToAssetId returns undefined', () => { + const address = '0x1234567890123456789012345678901234567890'; + mockFormatAddressToAssetId.mockReturnValue(undefined); + + const result = getTokenIconUrl(address, ETH_CHAIN_ID); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when formatAddressToAssetId throws error for unsupported chain', () => { + const address = '0x1234567890123456789012345678901234567890'; + const unsupportedChainId = '0x9999' as Hex; + mockFormatAddressToAssetId.mockImplementation(() => { + throw new Error('Unsupported chain'); + }); + + const result = getTokenIconUrl(address, unsupportedChainId); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when formatAddressToAssetId throws error for invalid address format', () => { + const invalidAddress = 'invalid-address-format'; + mockFormatAddressToAssetId.mockImplementation(() => { + throw new Error('Invalid address format'); + }); - // Act const result = getTokenIconUrl(invalidAddress, ETH_CHAIN_ID); - // Assert expect(result).toBeUndefined(); }); - it('should return native token icon URL for empty address', () => { - // Arrange + it('returns token icon URL for empty address when formatAddressToAssetId succeeds', () => { const emptyAddress = ''; + mockFormatAddressToAssetId.mockReturnValue('eip155:1/slip44:60'); - // Act const result = getTokenIconUrl(emptyAddress, ETH_CHAIN_ID); - // Assert expect(result).toBe( 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', ); diff --git a/app/components/UI/Bridge/utils/index.ts b/app/components/UI/Bridge/utils/index.ts index beda84604916..30c156995581 100644 --- a/app/components/UI/Bridge/utils/index.ts +++ b/app/components/UI/Bridge/utils/index.ts @@ -68,11 +68,19 @@ export const getTokenIconUrl = ( const isEvmChain = !isNonEvmChainId(chainId); const formattedAddress = isEvmChain ? address.toLowerCase() : address; - const assetId = formatAddressToAssetId(formattedAddress, chainId); - if (!assetId) { + try { + const assetId = formatAddressToAssetId(formattedAddress, chainId); + if (!assetId) { + return undefined; + } + return `https://static.cx.metamask.io/api/v2/tokenIcons/assets/${assetId + .split(':') + .join('/')}.png`; + } catch (error) { + // formatAddressToAssetId may throw for unsupported chains. This is expected behavior, + // so we gracefully handle it by returning undefined rather than propagating the error. + // This prevents the app from crashing when attempting to fetch icons for tokens on + // chains that aren't yet supported by the tokenIcons API. return undefined; } - return `https://static.cx.metamask.io/api/v2/tokenIcons/assets/${assetId - .split(':') - .join('/')}.png`; }; diff --git a/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx b/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx index 0bacb4066b7f..3e18f2f50282 100644 --- a/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx +++ b/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx @@ -185,7 +185,7 @@ export const AddressFields = ({ numberOfLines={1} size={TextFieldSize.Lg} value={zipCode} - keyboardType="number-pad" + keyboardType="default" maxLength={255} accessibilityLabel={strings( 'card.card_onboarding.physical_address.zip_code_label', diff --git a/app/components/Views/Onboarding/FoxAnimation.test.tsx b/app/components/UI/FoxAnimation/FoxAnimation.test.tsx similarity index 70% rename from app/components/Views/Onboarding/FoxAnimation.test.tsx rename to app/components/UI/FoxAnimation/FoxAnimation.test.tsx index 229b94d46c6a..08b07d77c0ee 100644 --- a/app/components/Views/Onboarding/FoxAnimation.test.tsx +++ b/app/components/UI/FoxAnimation/FoxAnimation.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, act } from '@testing-library/react-native'; +import { Platform } from 'react-native'; import FoxAnimation from './FoxAnimation'; import Logger from '../../../util/Logger'; import Device from '../../../util/device'; @@ -8,6 +9,7 @@ import { __clearLastMockedMethods, __resetAllMocks, } from '../../../__mocks__/rive-react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; // Mock dependencies jest.mock('../../../util/Logger'); @@ -299,4 +301,154 @@ describe('FoxAnimation', () => { expect(root).toBeTruthy(); }); }); + + describe('platform-specific positioning', () => { + const mockUseSafeAreaInsets = useSafeAreaInsets as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseSafeAreaInsets.mockReturnValue({ + top: 0, + bottom: 0, + left: 0, + right: 0, + }); + }); + + it('calculates iOS position with footer and safe area insets', () => { + // Arrange + Platform.OS = 'ios'; + mockUseSafeAreaInsets.mockReturnValue({ + top: 0, + bottom: 40, + left: 0, + right: 0, + }); + + // Act + const { root } = render(); + + // Assert + expect(root).toBeTruthy(); + }); + + it('calculates iOS position with basePadding greater than 0', () => { + // Arrange + Platform.OS = 'ios'; + mockUseSafeAreaInsets.mockReturnValue({ + top: 0, + bottom: 30, + left: 0, + right: 0, + }); + + // Act + const { root } = render(); + + // Assert + expect(root).toBeTruthy(); + }); + + it('calculates Android position with basePadding greater than 20 with footer', () => { + // Arrange + Platform.OS = 'android'; + mockUseSafeAreaInsets.mockReturnValue({ + top: 0, + bottom: 30, + left: 0, + right: 0, + }); + + // Act + const { root } = render(); + + // Assert + expect(root).toBeTruthy(); + }); + + it('calculates Android position with basePadding greater than 20 without footer', () => { + // Arrange + Platform.OS = 'android'; + mockUseSafeAreaInsets.mockReturnValue({ + top: 0, + bottom: 30, + left: 0, + right: 0, + }); + + // Act + const { root } = render(); + + // Assert + expect(root).toBeTruthy(); + }); + + it('calculates Android position for standard devices with footer', () => { + // Arrange + Platform.OS = 'android'; + mockUseSafeAreaInsets.mockReturnValue({ + top: 0, + bottom: 10, + left: 0, + right: 0, + }); + + // Act + const { root } = render(); + + // Assert + expect(root).toBeTruthy(); + }); + + it('calculates Android position for standard devices without footer', () => { + // Arrange + Platform.OS = 'android'; + mockUseSafeAreaInsets.mockReturnValue({ + top: 0, + bottom: 10, + left: 0, + right: 0, + }); + + // Act + const { root } = render(); + + // Assert + expect(root).toBeTruthy(); + }); + + it('uses fallback position for other platforms with footer', () => { + // Arrange + Platform.OS = 'windows' as typeof Platform.OS; + mockUseSafeAreaInsets.mockReturnValue({ + top: 0, + bottom: 0, + left: 0, + right: 0, + }); + + // Act + const { root } = render(); + + // Assert + expect(root).toBeTruthy(); + }); + + it('uses fallback position for other platforms without footer', () => { + // Arrange + Platform.OS = 'windows' as typeof Platform.OS; + mockUseSafeAreaInsets.mockReturnValue({ + top: 0, + bottom: 0, + left: 0, + right: 0, + }); + + // Act + const { root } = render(); + + // Assert + expect(root).toBeTruthy(); + }); + }); }); diff --git a/app/components/Views/Onboarding/FoxAnimation.tsx b/app/components/UI/FoxAnimation/FoxAnimation.tsx similarity index 100% rename from app/components/Views/Onboarding/FoxAnimation.tsx rename to app/components/UI/FoxAnimation/FoxAnimation.tsx diff --git a/app/components/Views/Onboarding/__mocks__/FoxAnimation.tsx b/app/components/UI/FoxAnimation/__mocks__/FoxAnimation.tsx similarity index 94% rename from app/components/Views/Onboarding/__mocks__/FoxAnimation.tsx rename to app/components/UI/FoxAnimation/__mocks__/FoxAnimation.tsx index a183b5831e78..5c75b3de9306 100644 --- a/app/components/Views/Onboarding/__mocks__/FoxAnimation.tsx +++ b/app/components/UI/FoxAnimation/__mocks__/FoxAnimation.tsx @@ -13,7 +13,7 @@ const FoxAnimation = ({ return ( ({
{isRead ? 'Read Icon' : 'Unread Icon'}
)), Content: jest.fn(() =>
Mocked Content
), + Cta: jest.fn(() => null), }, })); @@ -157,7 +158,7 @@ describe('useNotificationOnClick', () => { ); const notification = processNotification(createMockNotificationEthSent()); - await act(() => hook.result.current(notification)); + await act(() => hook.result.current.onNotificationClick(notification)); // Assert - Controller Action expect(mocks.mockMarkNotificationAsRead).toHaveBeenCalledWith([ diff --git a/app/components/UI/Notification/List/index.tsx b/app/components/UI/Notification/List/index.tsx index 2f915d1e4791..d9aae57bbc58 100644 --- a/app/components/UI/Notification/List/index.tsx +++ b/app/components/UI/Notification/List/index.tsx @@ -1,7 +1,9 @@ -import { NavigationProp, ParamListBase } from '@react-navigation/native'; import React, { useCallback, useMemo } from 'react'; -import NotificationsService from '../../../../util/notifications/services/NotificationService'; import { ActivityIndicator, FlatList, FlatListProps, View } from 'react-native'; +import { NavigationProp, ParamListBase } from '@react-navigation/native'; +import { Box } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import NotificationsService from '../../../../util/notifications/services/NotificationService'; import { NotificationsViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/NotificationsView.selectors'; import { hasNotificationComponents, @@ -55,7 +57,8 @@ export function useNotificationOnClick( ) { const { markNotificationAsRead } = useMarkNotificationAsRead(); const { trackEvent, createEventBuilder } = useMetrics(); - const onNotificationClick = useCallback( + + const handleNotificationClickMetricsAndUpdates = useCallback( (item: INotification) => { markNotificationAsRead([ { @@ -64,19 +67,18 @@ export function useNotificationOnClick( isRead: item.isRead, }, ]); - if (hasNotificationModal(item?.type)) { - props.navigation.navigate(Routes.NOTIFICATIONS.DETAILS, { - notification: item, - }); - } - NotificationsService.getBadgeCount().then((count) => { - if (count > 0) { - NotificationsService.decrementBadgeCount(count - 1); - } else { - NotificationsService.setBadgeCount(0); + const otherNotificationProperties = () => { + if ( + 'notification_type' in item && + item.notification_type === 'on-chain' && + item.payload?.chain_id + ) { + return { chain_id: item.payload.chain_id }; } - }); + + return undefined; + }; trackEvent( createEventBuilder(MetaMetricsEvents.NOTIFICATION_CLICKED) @@ -84,19 +86,49 @@ export function useNotificationOnClick( notification_id: item.id, notification_type: item.type, previously_read: item.isRead, - ...('chain_id' in item && { chain_id: item.chain_id }), + ...otherNotificationProperties(), + data: item, // data blob for feature teams to analyse their notification shapes }) .build(), ); + + NotificationsService.getBadgeCount().then((count) => { + if (count > 0) { + NotificationsService.decrementBadgeCount(1); + } else { + NotificationsService.setBadgeCount(0); + } + }); + }, + [createEventBuilder, markNotificationAsRead, trackEvent], + ); + + const onNavigation = useCallback( + (item: INotification) => { + if (hasNotificationModal(item?.type)) { + props.navigation.navigate(Routes.NOTIFICATIONS.DETAILS, { + notification: item, + }); + } + }, + [props.navigation], + ); + + const onNotificationClick = useCallback( + (item: INotification) => { + handleNotificationClickMetricsAndUpdates(item); + onNavigation(item); }, - [markNotificationAsRead, props.navigation, trackEvent, createEventBuilder], + [handleNotificationClickMetricsAndUpdates, onNavigation], ); - return onNotificationClick; + return { onNotificationClick, handleNotificationClickMetricsAndUpdates }; } export function NotificationsListItem(props: NotificationsListItemProps) { - const onNotificationClick = useNotificationOnClick(props); + const { onNotificationClick, handleNotificationClickMetricsAndUpdates } = + useNotificationOnClick(props); + const tw = useTailwind(); const menuItemState = useMemo(() => { const notificationState = @@ -117,12 +149,21 @@ export function NotificationsListItem(props: NotificationsListItemProps) { handleOnPress={() => onNotificationClick(props.notification)} isRead={props.notification.isRead} testID={NotificationMenuViewSelectorsIDs.ITEM(props.notification.id)} + style={tw`gap-2`} > - + + +
+ + handleNotificationClickMetricsAndUpdates(props.notification) + } /> - ); } diff --git a/app/components/UI/Notification/List/styles.ts b/app/components/UI/Notification/List/styles.ts index 48ada68086ce..62cae719c6fe 100644 --- a/app/components/UI/Notification/List/styles.ts +++ b/app/components/UI/Notification/List/styles.ts @@ -27,23 +27,6 @@ export const createStyles = ({ colors }: Theme) => paddingHorizontal: 16, backgroundColor: colors.background.default, }, - unreadDot: { - width: 4, - height: 4, - borderRadius: 2, - backgroundColor: colors.info.default, - position: 'absolute', - marginTop: 16, - marginLeft: 8, - }, - readDot: { - width: 4, - height: 4, - borderRadius: 2, - position: 'absolute', - marginTop: 16, - marginLeft: 8, - }, wrapper: { flex: 1, paddingVertical: 10, @@ -59,10 +42,6 @@ export const createStyles = ({ colors }: Theme) => justifyContent: 'center', alignItems: 'center', }, - menuItemContainer: { - flexDirection: 'row', - gap: 16, - }, loader: { backgroundColor: colors.background.default, flex: 1, diff --git a/app/components/UI/Notification/NotificationMenuItem/Content.test.tsx b/app/components/UI/Notification/NotificationMenuItem/Content.test.tsx index c227b206ec5c..870209563295 100644 --- a/app/components/UI/Notification/NotificationMenuItem/Content.test.tsx +++ b/app/components/UI/Notification/NotificationMenuItem/Content.test.tsx @@ -8,34 +8,24 @@ describe('NotificationContent', () => { const yesterday = new Date().setDate(new Date().getDate() - 1); const createdAt = new Date(yesterday).toISOString(); // Relative date: one day before current date const description = { - start: - 'We are excited to announce the launch of our brand new website and app!', + start: 'Some starting text', end: 'Ethereum', }; - it('render matches snapshot', () => { - const { toJSON } = renderWithProvider( - , - ); - expect(toJSON()).toMatchSnapshot(); - }); - - it('renders title and 1 part of description', () => { - const titleWithTo = 'Sent 0.01 ETH to 0x10000'; + it('renders title and description', () => { const { getByText } = renderWithProvider( , ); - expect(getByText(titleWithTo)).toBeTruthy(); + expect(getByText(title)).toBeOnTheScreen(); + expect(getByText(description.start)).toBeOnTheScreen(); + expect(getByText(description.end)).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Notification/NotificationMenuItem/Cta.test.tsx b/app/components/UI/Notification/NotificationMenuItem/Cta.test.tsx new file mode 100644 index 000000000000..9e9e5be6b676 --- /dev/null +++ b/app/components/UI/Notification/NotificationMenuItem/Cta.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { userEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; + +import NotificationCta from './Cta'; +import SharedDeeplinkManager from '../../../../core/DeeplinkManager/SharedDeeplinkManager'; +import { Linking } from 'react-native'; + +describe('NotificationCta', () => { + const ctaContent = 'Test Link'; + const ctaDeeplink = 'https://link.metamask.io/foo'; + const ctaExternalLink = 'https://www.google.com'; + + it('does not render CTA when not available', () => { + const { root } = renderWithProvider( + , + ); + expect(root).toBeUndefined(); + }); + + it('handles universal CTAs', async () => { + const mockParseDeeplink = jest + .spyOn(SharedDeeplinkManager, 'parse') + .mockImplementation(jest.fn()); + const { root, getByText } = renderWithProvider( + , + ); + + expect(root).toBeOnTheScreen(); + await userEvent.press(getByText(ctaContent)); + expect(mockParseDeeplink).toHaveBeenCalled(); + }); + + it('handles external CTAs', async () => { + const mockOpenUrl = jest + .spyOn(Linking, 'openURL') + .mockImplementation(jest.fn()); + + const { root, getByText } = renderWithProvider( + , + ); + + expect(root).toBeOnTheScreen(); + await userEvent.press(getByText(ctaContent)); + expect(mockOpenUrl).toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Notification/NotificationMenuItem/Cta.tsx b/app/components/UI/Notification/NotificationMenuItem/Cta.tsx new file mode 100644 index 000000000000..f1c9fb5593a2 --- /dev/null +++ b/app/components/UI/Notification/NotificationMenuItem/Cta.tsx @@ -0,0 +1,58 @@ +import React, { useCallback } from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, + IconName, +} from '@metamask/design-system-react-native'; +import { NotificationMenuItem } from '../../../../util/notifications/notification-states/types/NotificationMenuItem'; +import AppConstants from '../../../../core/AppConstants'; +import SharedDeeplinkManager from '../../../../core/DeeplinkManager/SharedDeeplinkManager'; +import { Linking } from 'react-native'; + +type NotificationCtaProps = Pick & { + onClick: () => void; +}; + +function NotificationCta({ cta, onClick }: NotificationCtaProps) { + const handleClick = useCallback(() => { + if (!cta?.link) { + return; + } + + try { + onClick(); + + // Handle deeplinks + if (cta.link.includes(AppConstants.MM_IO_UNIVERSAL_LINK_HOST)) { + SharedDeeplinkManager.parse(cta.link, { + origin: AppConstants.DEEPLINKS.ORIGIN_DEEPLINK, + }); + return; + } + + // Fallback to native link opening + Linking.openURL(cta.link); + } catch (e) { + console.warn(`Failed to open NotificationCTA link ${cta.link}`, e); + } + }, [cta?.link, onClick]); + + if (!cta?.content || !cta?.link) { + return null; + } + + return ( + + ); +} + +export default NotificationCta; diff --git a/app/components/UI/Notification/NotificationMenuItem/Icon.test.tsx b/app/components/UI/Notification/NotificationMenuItem/Icon.test.tsx index 48eeface53fe..1ba2c26978cb 100644 --- a/app/components/UI/Notification/NotificationMenuItem/Icon.test.tsx +++ b/app/components/UI/Notification/NotificationMenuItem/Icon.test.tsx @@ -1,24 +1,10 @@ import React from 'react'; -import { Linking } from 'react-native'; - +import { ReactTestInstance } from 'react-test-renderer'; import renderWithProvider from '../../../../util/test/renderWithProvider'; - -import NotificationIcon from './Icon'; +import NotificationIcon, { TEST_IDS } from './Icon'; import { IconName } from '../../../../component-library/components/Icons/Icon'; -import initialBackgroundState from '../../../../util/test/initial-background-state.json'; - import SVG_ETH_LOGO_PATH from '../../../../component-library/components/Icons/Icon/assets/ethereum.svg'; -import type { RootState } from '../../../../reducers'; - -Linking.openURL = jest.fn(() => Promise.resolve('opened https://metamask.io!')); - -const mockInitialState = { - engine: { - backgroundState: { - ...initialBackgroundState, - }, - }, -} as unknown as RootState; +import { BADGE_WRAPPER_BADGE_TEST_ID } from '../../../../component-library/components/Badges/BadgeWrapper/BadgeWrapper.constants'; describe('NotificationIcon', () => { const walletNotification = { @@ -26,14 +12,33 @@ describe('NotificationIcon', () => { imageUrl: SVG_ETH_LOGO_PATH, }; - it('matches snapshot when icon is provided', () => { - const { toJSON } = renderWithProvider( - , - { state: mockInitialState }, - ); - expect(toJSON()).toMatchSnapshot(); - }); + const badgeTests = [ + { + hasBadge: true, + assertion: (elem: ReactTestInstance | null) => + expect(elem).toBeOnTheScreen(), + }, + { + hasBadge: false, + assertion: (elem: ReactTestInstance | null) => + expect(elem).not.toBeOnTheScreen(), + }, + ]; + + it.each(badgeTests)( + 'manages container rendering when badge is added: $hasBadge', + ({ hasBadge, assertion }) => { + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); + + expect(getByTestId(TEST_IDS.CONTAINER)).toBeOnTheScreen(); + expect(getByTestId(TEST_IDS.ICON)).toBeOnTheScreen(); + assertion(queryByTestId(BADGE_WRAPPER_BADGE_TEST_ID)); + }, + ); }); diff --git a/app/components/UI/Notification/NotificationMenuItem/Icon.tsx b/app/components/UI/Notification/NotificationMenuItem/Icon.tsx index f62c855db64a..0265698f263f 100644 --- a/app/components/UI/Notification/NotificationMenuItem/Icon.tsx +++ b/app/components/UI/Notification/NotificationMenuItem/Icon.tsx @@ -1,22 +1,33 @@ +import { View } from 'react-native'; +import { Image } from 'expo-image'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { NotificationMenuItem } from '../../../../util/notifications/notification-states/types/NotificationMenuItem'; -import React, { useMemo } from 'react'; +import React, { + type FC, + type PropsWithChildren, + useCallback, + useMemo, +} from 'react'; import useStyles from '../List/useStyles'; import BadgeWrapper from '../../../../component-library/components/Badges/BadgeWrapper'; import Badge, { BadgeVariant, } from '../../../../component-library/components/Badges/Badge'; import { BOTTOM_BADGEWRAPPER_BADGEPOSITION } from '../../../../component-library/components/Badges/BadgeWrapper/BadgeWrapper.constants'; -import { Image } from 'expo-image'; - import METAMASK_FOX from '../../../../images/branding/fox.png'; -import { View } from 'react-native'; + +export const TEST_IDS = { + CONTAINER: 'notification-menu-item-icon:container', + ICON: 'notification-menu-item-icon:icon', +}; type NotificationIconProps = Pick< NotificationMenuItem, - 'image' | 'badgeIcon' | 'isRead' ->; + 'image' | 'badgeIcon' +> & { isRead: boolean }; function MenuIcon(props: NotificationIconProps) { + const tw = useTailwind(); const { styles } = useStyles(); const menuIconStyles = { @@ -34,24 +45,20 @@ function MenuIcon(props: NotificationIconProps) { return props.image.url; }, [props.image?.url]); - const imageStyles = useMemo(() => { - const size = source === METAMASK_FOX ? '80%' : '100%'; - return { width: size, height: size, margin: 'auto' } as const; - }, [source]); - return ( - - + + ); } function NotificationIcon(props: NotificationIconProps) { + const tw = useTailwind(); const { styles } = useStyles(); - return ( - - + const MaybeBadgeContainer: FC = useCallback( + ({ children }) => + props.badgeIcon ? ( - + {children} + ) : ( + <>{children} + ), + [props.badgeIcon, styles.badgeWrapper], + ); + + return ( + + + + + + - ); } diff --git a/app/components/UI/Notification/NotificationMenuItem/Root.tsx b/app/components/UI/Notification/NotificationMenuItem/Root.tsx index aca35fc43107..06858f52a7dd 100644 --- a/app/components/UI/Notification/NotificationMenuItem/Root.tsx +++ b/app/components/UI/Notification/NotificationMenuItem/Root.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import { TouchableOpacity } from 'react-native'; +import { TouchableOpacity, ViewStyle } from 'react-native'; import useStyles from '../List/useStyles'; interface NotificationRootProps { children: React.ReactNode; handleOnPress: () => void; + style?: ViewStyle; isRead?: boolean; testID?: string; } @@ -13,6 +14,7 @@ function NotificationRoot({ handleOnPress, isRead, testID, + style, }: NotificationRootProps) { const { styles } = useStyles(); @@ -20,8 +22,8 @@ function NotificationRoot({ diff --git a/app/components/UI/Notification/NotificationMenuItem/__snapshots__/Content.test.tsx.snap b/app/components/UI/Notification/NotificationMenuItem/__snapshots__/Content.test.tsx.snap deleted file mode 100644 index 458673ac32b5..000000000000 --- a/app/components/UI/Notification/NotificationMenuItem/__snapshots__/Content.test.tsx.snap +++ /dev/null @@ -1,92 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`NotificationContent render matches snapshot 1`] = ` - - - - Welcome to the new Test! - - - Yesterday - - - - - We are excited to announce the launch of our brand new website and app! - - - Ethereum - - - -`; diff --git a/app/components/UI/Notification/NotificationMenuItem/__snapshots__/Icon.test.tsx.snap b/app/components/UI/Notification/NotificationMenuItem/__snapshots__/Icon.test.tsx.snap deleted file mode 100644 index 1f217b3a1883..000000000000 --- a/app/components/UI/Notification/NotificationMenuItem/__snapshots__/Icon.test.tsx.snap +++ /dev/null @@ -1,121 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`NotificationIcon matches snapshot when icon is provided 1`] = ` -[ - - - - - - - - - - - - - - , - , -] -`; diff --git a/app/components/UI/Notification/NotificationMenuItem/index.tsx b/app/components/UI/Notification/NotificationMenuItem/index.tsx index 99596dc79705..90e38745b357 100644 --- a/app/components/UI/Notification/NotificationMenuItem/index.tsx +++ b/app/components/UI/Notification/NotificationMenuItem/index.tsx @@ -2,9 +2,11 @@ import NotificationRoot from './Root'; import NotificationIcon from './Icon'; import NotificationContent from './Content'; +import NotificationCta from './Cta'; export const NotificationMenuItem = { Root: NotificationRoot, Icon: NotificationIcon, Content: NotificationContent, + Cta: NotificationCta, }; diff --git a/app/components/Views/Onboarding/OnboardingAnimation.test.tsx b/app/components/UI/OnboardingAnimation/OnboardingAnimation.test.tsx similarity index 100% rename from app/components/Views/Onboarding/OnboardingAnimation.test.tsx rename to app/components/UI/OnboardingAnimation/OnboardingAnimation.test.tsx diff --git a/app/components/Views/Onboarding/OnboardingAnimation.tsx b/app/components/UI/OnboardingAnimation/OnboardingAnimation.tsx similarity index 97% rename from app/components/Views/Onboarding/OnboardingAnimation.tsx rename to app/components/UI/OnboardingAnimation/OnboardingAnimation.tsx index d2721a2f5506..08120537ead9 100644 --- a/app/components/Views/Onboarding/OnboardingAnimation.tsx +++ b/app/components/UI/OnboardingAnimation/OnboardingAnimation.tsx @@ -125,7 +125,7 @@ const OnboardingAnimation = ({ return ( <> - + {children} diff --git a/app/components/Views/Onboarding/__mocks__/OnboardingAnimation.tsx b/app/components/UI/OnboardingAnimation/__mocks__/OnboardingAnimation.tsx similarity index 100% rename from app/components/Views/Onboarding/__mocks__/OnboardingAnimation.tsx rename to app/components/UI/OnboardingAnimation/__mocks__/OnboardingAnimation.tsx diff --git a/app/components/UI/PaymentRequest/index.js b/app/components/UI/PaymentRequest/index.js index 6ca871c8fc49..d46a8e34f183 100644 --- a/app/components/UI/PaymentRequest/index.js +++ b/app/components/UI/PaymentRequest/index.js @@ -669,6 +669,8 @@ class PaymentRequest extends PureComponent { const { conversionRate, contractExchangeRates, currentCurrency } = this.props; const currencySymbol = currencySymbols[currentCurrency]; + // Normalize amount: trim whitespace and replace comma with period + amount = amount?.replace(',', '.')?.trim(); const exchangeRate = selectedAsset && selectedAsset.address && @@ -682,9 +684,9 @@ class PaymentRequest extends PureComponent { conversionRate && (exchangeRate || selectedAsset.isETH) ) { - res = this.handleFiatPrimaryCurrency(amount?.replace(',', '.')); + res = this.handleFiatPrimaryCurrency(amount); } else { - res = this.handleETHPrimaryCurrency(amount?.replace(',', '.')); + res = this.handleETHPrimaryCurrency(amount); } const { cryptoAmount, symbol } = res; if (amount && amount[0] === currencySymbol) amount = amount.substr(1); diff --git a/app/components/UI/PaymentRequest/index.test.tsx b/app/components/UI/PaymentRequest/index.test.tsx index b9d002b2d4c3..06b8a8b48f3f 100644 --- a/app/components/UI/PaymentRequest/index.test.tsx +++ b/app/components/UI/PaymentRequest/index.test.tsx @@ -216,6 +216,29 @@ describe('PaymentRequest', () => { expect(amountInput.props.value).toBe('1.5'); }); + it('trims leading and trailing spaces from amount input', async () => { + const { getByText, getByPlaceholderText } = renderComponent(); + + await userEvent.press(getByText('ETH')); + + const amountInput = getByPlaceholderText('0.00'); + fireEvent.changeText(amountInput, ' 1.5 '); + + expect(amountInput.props.value).toBe('1.5'); + }); + + it('handles whitespace-only input without throwing', async () => { + const { getByText, getByPlaceholderText } = renderComponent(); + + await userEvent.press(getByText('ETH')); + + const amountInput = getByPlaceholderText('0.00'); + + expect(() => { + fireEvent.changeText(amountInput, ' '); + }).not.toThrow(); + }); + it('displays an error when an invalid amount is entered', async () => { const { getByText, getByPlaceholderText, queryByText } = renderComponent(); diff --git a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx index 1b4064118aa1..c9f09c4983bf 100644 --- a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx @@ -256,6 +256,10 @@ jest.mock('../../../../../component-library/components/Skeleton', () => { }; }); +jest.mock('../../../../../contexts/FeatureFlagOverrideContext', () => ({ + FeatureFlagOverrideProvider: jest.fn(({ children }) => children), +})); + jest.mock('react-native', () => { const RN = jest.requireActual('react-native'); return { diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx index 41614bbe758b..536bcdd17edb 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx @@ -688,7 +688,10 @@ const PerpsMarketTabs: React.FC = ({ // Sync TabsList to active tab after remount (when key changes) useEffect(() => { - if (tabsListRef.current && activeIndex >= 0) { + // Enabled only in test mode + // https://github.com/MetaMask/metamask-mobile/pull/22632 + const isInTestMode = process.env.JEST_WORKER_ID || process.env.E2E; + if (tabsListRef.current && activeIndex >= 0 && isInTestMode) { tabsListRef.current.goToTabIndex(activeIndex); } }, [tabsKey, activeIndex, activeTabId]); diff --git a/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx b/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx index f7a287385c88..68d060fada60 100644 --- a/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx +++ b/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx @@ -94,7 +94,7 @@ describe('PredictActivity', () => { expect(screen.getByText('Buy')).toBeOnTheScreen(); expect(screen.getByText(baseItem.marketTitle)).toBeOnTheScreen(); expect(screen.getByText('-$1,234.50')).toBeOnTheScreen(); - expect(screen.getByText('+1.50%')).toBeOnTheScreen(); + expect(screen.getByText('2%')).toBeOnTheScreen(); }); it('renders SELL activity with plus-signed amount and negative percent', () => { diff --git a/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx b/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx index 9ed59172078a..b90fa5d2c82e 100644 --- a/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx +++ b/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx @@ -199,7 +199,7 @@ describe('PredictActivityDetail', () => { expect(screen.getByText(expectedPricePerShare)).toBeOnTheScreen(); expect(screen.getByText('Price impact')).toBeOnTheScreen(); - expect(screen.getByText('+1.50%')).toBeOnTheScreen(); + expect(screen.getByText('2%')).toBeOnTheScreen(); expect(screen.queryByLabelText('USDC')).toBeNull(); }); diff --git a/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx b/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx index 4ec66566c739..317f9d182c40 100644 --- a/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx +++ b/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx @@ -169,7 +169,7 @@ describe('PredictBalance', () => { }); // Assert - expect(getByText(/\$123\.46/)).toBeOnTheScreen(); + expect(getByText(/\$123\.45/)).toBeOnTheScreen(); }); it('displays zero balance', () => { @@ -209,7 +209,7 @@ describe('PredictBalance', () => { }); // Assert - expect(getByText(/\$1,234,567\.89/)).toBeOnTheScreen(); + expect(getByText(/\$1,234,567\.88/)).toBeOnTheScreen(); }); it('renders container with correct test ID', () => { diff --git a/app/components/UI/Predict/components/PredictFeeSummary/PredictFeeSummary.tsx b/app/components/UI/Predict/components/PredictFeeSummary/PredictFeeSummary.tsx index 12daa31c3c87..47a6aef3d0b0 100644 --- a/app/components/UI/Predict/components/PredictFeeSummary/PredictFeeSummary.tsx +++ b/app/components/UI/Predict/components/PredictFeeSummary/PredictFeeSummary.tsx @@ -55,7 +55,7 @@ const PredictFeeSummary: React.FC = ({ {/* Fees Row with Info Icon */} - + {strings('predict.fee_summary.fees')} diff --git a/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.styles.ts b/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.styles.ts index ef6ee78405e5..126904a6e68b 100644 --- a/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.styles.ts +++ b/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.styles.ts @@ -10,6 +10,8 @@ const BASE_WIDTH = 375; const BASE_HEIGHT_IOS = 812; // iPhone X/11/12/13/14/15 Pro base const BASE_HEIGHT_ANDROID = 736; // Common Android base +const MIN_SCREEN_HEIGHT_FOR_SMALL_SCREEN_STYLES = 750; + // Calculate platform-aware scaling factors const isIOS = Platform.OS === 'ios'; const baseHeight = isIOS ? BASE_HEIGHT_IOS : BASE_HEIGHT_ANDROID; @@ -49,7 +51,11 @@ const createStyles = (theme: Theme) => right: 0, bottom: 0, width: screenWidth * 1.07, // 7% wider for edge coverage - height: screenHeight * 1.12, // 12% taller for edge coverage + height: + screenHeight * + (screenHeight < MIN_SCREEN_HEIGHT_FOR_SMALL_SCREEN_STYLES + ? 1.12 + : 1.14), // 12% taller for edge coverage resizeMode: 'cover', }, contentContainer: { @@ -60,26 +66,38 @@ const createStyles = (theme: Theme) => paddingHorizontal: scaleHorizontal(16), paddingVertical: scaleVertical(16), }, + poweredByImage: { + width: scaleHorizontal(200), + height: scaleVertical(24), + marginBottom: 8, + }, spacer: { flex: 1, }, title: { fontFamily: Platform.OS === 'ios' ? 'MM Poly' : 'MM Poly Regular', fontWeight: '400', - fontSize: 50, - lineHeight: 50, // 100% of font size + // make it smaller on smaller screens + fontSize: + screenHeight < MIN_SCREEN_HEIGHT_FOR_SMALL_SCREEN_STYLES ? 40 : 50, + lineHeight: + screenHeight < MIN_SCREEN_HEIGHT_FOR_SMALL_SCREEN_STYLES ? 40 : 50, // 100% of font size letterSpacing: 0, textAlign: 'center', - paddingTop: scaleVertical(12), + paddingTop: scaleVertical( + screenHeight < MIN_SCREEN_HEIGHT_FOR_SMALL_SCREEN_STYLES ? 8 : 12, + ), color: theme.colors.accent02.light, }, titleDescription: { + // make it smaller on smaller screens + fontSize: + screenHeight < MIN_SCREEN_HEIGHT_FOR_SMALL_SCREEN_STYLES ? 14 : 16, paddingTop: scaleVertical(10), paddingHorizontal: scaleHorizontal(8), textAlign: 'center', fontFamily: Platform.OS === 'ios' ? 'System' : 'Roboto', // Default system font fontWeight: '500', - fontSize: 16, // BodyMd lineHeight: 24, // Line Height BodyMd letterSpacing: 0, color: theme.colors.accent02.light, diff --git a/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.tsx b/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.tsx index cd7cfb9209c6..df8dbcdabcf6 100644 --- a/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.tsx +++ b/app/components/UI/Predict/components/PredictGTMModal/PredictGTMModal.tsx @@ -16,6 +16,7 @@ import { useMetrics } from '../../../../../components/hooks/useMetrics'; import Routes from '../../../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import PredictMarketingImage from '../../../../../images/predict-marketing.png'; +import PoweredByPolymarketImage from '../../../../../images/powered-by-polymarket.png'; import StorageWrapper from '../../../../../store/storage-wrapper'; import { PREDICT_GTM_MODAL_SHOWN } from '../../../../../constants/storage'; import { useTheme } from '../../../../../util/theme'; @@ -84,6 +85,11 @@ const PredictGTMModal = () => { {/* Header Section */} + {titleText} diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx index 0f0e150cfad2..2f1fc91ca145 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.test.tsx @@ -105,7 +105,7 @@ describe('PredictMarketMultiple', () => { ).toBeOnTheScreen(); expect(getByText('Bitcoin Price Prediction')).toBeOnTheScreen(); - expect(getByText('65.00%')).toBeOnTheScreen(); + expect(getByText('65%')).toBeOnTheScreen(); expect(getByText(/\$1M.*Vol\./)).toBeOnTheScreen(); }); @@ -197,7 +197,7 @@ describe('PredictMarketMultiple', () => { expect(getByText('Market 1')).toBeOnTheScreen(); expect(getByText('Market 2')).toBeOnTheScreen(); - expect(getByText('75.00%')).toBeOnTheScreen(); + expect(getByText('75%')).toBeOnTheScreen(); }); it('handle market with recurrence', () => { diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx index 294a877d5754..528cfdb00585 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx @@ -40,7 +40,7 @@ import { PredictEntryPoint, } from '../../types/navigation'; import { PredictEventValues } from '../../constants/eventNames'; -import { formatVolume } from '../../utils/format'; +import { formatPercentage, formatVolume } from '../../utils/format'; import styleSheet from './PredictMarketMultiple.styles'; interface PredictMarketMultipleProps { market: PredictMarket; @@ -68,7 +68,7 @@ const PredictMarketMultiple: React.FC = ({ (outcome) => outcome.tokens[0].price !== 0 && outcome.tokens[0].price !== 1, ); - const getFirstOutcomePrice = ( + const getOutcomePercentage = ( outcomePrices?: number[], ): string | undefined => { if (!outcomePrices) { @@ -79,7 +79,7 @@ const PredictMarketMultiple: React.FC = ({ const parsed = outcomePrices; if (Array.isArray(parsed) && parsed.length > 0) { const firstValue = parsed[0]; - return (firstValue * 100).toFixed(2); + return formatPercentage(firstValue * 100); } } catch (error) { DevLogger.log('PredictMarketMultiple: Failed to parse outcomePrices', { @@ -213,10 +213,9 @@ const PredictMarketMultiple: React.FC = ({ variant={TextVariant.BodySMMedium} color={TextColor.Alternative} > - {getFirstOutcomePrice( + {getOutcomePercentage( outcome.tokens.map((token) => token.price), ) ?? '0'} - % diff --git a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx index 9d7bbcca5a2a..8cc30c8b60ba 100644 --- a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.test.tsx @@ -114,7 +114,7 @@ describe('PredictMarketOutcome', () => { ); expect(getByText('Crypto Markets')).toBeOnTheScreen(); - expect(getByText('+65%')).toBeOnTheScreen(); + expect(getByText('65%')).toBeOnTheScreen(); expect(getByText(/\$1M.*Vol\./)).toBeOnTheScreen(); }); @@ -374,7 +374,7 @@ describe('PredictMarketOutcome', () => { // The component now shows the groupItemTitle directly, even if it's undefined // We can verify the component renders without errors by checking other elements - expect(getByText('+65%')).toBeOnTheScreen(); + expect(getByText('65%')).toBeOnTheScreen(); expect(getByText(/\$1M.*Vol\./)).toBeOnTheScreen(); }); diff --git a/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx b/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx index 60f9a115b8da..91e2f9a1d31a 100644 --- a/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx +++ b/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx @@ -50,13 +50,13 @@ describe('PredictPosition', () => { screen.getByText('$123.45 on Yes Ā· 10 shares at 34Ā¢'), ).toBeOnTheScreen(); expect(screen.getByText('$2,345.67')).toBeOnTheScreen(); - expect(screen.getByText('+5.25%')).toBeOnTheScreen(); + expect(screen.getByText('5%')).toBeOnTheScreen(); }); it.each([ - { value: -3.5, expected: '-3.50%' }, + { value: -3.5, expected: '-3%' }, { value: 0, expected: '0%' }, - { value: 7.5, expected: '+7.50%' }, + { value: 7.5, expected: '8%' }, ])('formats percentPnl $value as $expected', ({ value, expected }) => { renderComponent({ percentPnl: value }); @@ -71,7 +71,9 @@ describe('PredictPosition', () => { size: 10, }); - expect(screen.getByText('$50 on No Ā· 10 shares at 70Ā¢')).toBeOnTheScreen(); + expect( + screen.getByText('$50.00 on No Ā· 10 shares at 70Ā¢'), + ).toBeOnTheScreen(); }); it('displays singular share when size is 1', () => { @@ -82,7 +84,7 @@ describe('PredictPosition', () => { size: 1, }); - expect(screen.getByText('$50 on No Ā· 1 share at 70Ā¢')).toBeOnTheScreen(); + expect(screen.getByText('$50.00 on No Ā· 1 share at 70Ā¢')).toBeOnTheScreen(); }); it('renders icon image with correct URI', () => { @@ -168,14 +170,16 @@ describe('PredictPosition', () => { it('formats initialValue without decimals when minimumDecimals is 0', () => { renderComponent({ initialValue: 100, size: 3 }); - expect(screen.getByText('$100 on Yes Ā· 3 shares at 34Ā¢')).toBeOnTheScreen(); + expect( + screen.getByText('$100.00 on Yes Ā· 3 shares at 34Ā¢'), + ).toBeOnTheScreen(); }); it('formats size with 2 decimal places', () => { renderComponent({ size: 10.5555, initialValue: 200 }); expect( - screen.getByText('$200 on Yes Ā· 10.56 shares at 34Ā¢'), + screen.getByText('$200.00 on Yes Ā· 10.56 shares at 34Ā¢'), ).toBeOnTheScreen(); }); @@ -209,7 +213,7 @@ describe('PredictPosition', () => { screen.getByText('$75.25 on Maybe Ā· 7.50 shares at 62.5Ā¢'), ).toBeOnTheScreen(); expect(screen.getByText('$100.75')).toBeOnTheScreen(); - expect(screen.getByText('+15.75%')).toBeOnTheScreen(); + expect(screen.getByText('16%')).toBeOnTheScreen(); }); describe('optimistic updates UI', () => { @@ -229,7 +233,7 @@ describe('PredictPosition', () => { renderComponent({ optimistic: false }); expect(screen.getByText('$2,345.67')).toBeOnTheScreen(); - expect(screen.getByText('+5.25%')).toBeOnTheScreen(); + expect(screen.getByText('5%')).toBeOnTheScreen(); }); it('shows initial value line when optimistic', () => { diff --git a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx index 807e95a02f6b..98af747dc777 100644 --- a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx +++ b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx @@ -191,14 +191,14 @@ describe('PredictPositionDetail', () => { ).toBeOnTheScreen(); expect(screen.getByText('$2,345.67')).toBeOnTheScreen(); - expect(screen.getByText('+5.25%')).toBeOnTheScreen(); + expect(screen.getByText('5%')).toBeOnTheScreen(); expect(screen.getByText('Cash out')).toBeOnTheScreen(); }); it.each([ - { value: -3.5, expected: '-3.50%' }, + { value: -3.5, expected: '-3%' }, { value: 0, expected: '0%' }, - { value: 7.5, expected: '+7.50%' }, + { value: 7.5, expected: '8%' }, ])('formats percentPnl %p as %p for open market', ({ value, expected }) => { renderComponent({ percentPnl: value }); @@ -233,7 +233,7 @@ describe('PredictPositionDetail', () => { PredictMarketStatus.CLOSED, ); - expect(screen.getByText('Lost $321.09')).toBeOnTheScreen(); + expect(screen.getByText('Lost $321.08')).toBeOnTheScreen(); expect(screen.queryByText('Cash out')).toBeNull(); }); diff --git a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx index 7b2de7429a9a..ca1f4c2df06b 100644 --- a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx +++ b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx @@ -11,7 +11,7 @@ import { PredictMarket, PredictMarketStatus, } from '../../types'; -import { formatPercentage, formatPrice } from '../../utils/format'; +import { formatCents, formatPercentage, formatPrice } from '../../utils/format'; import Button, { ButtonVariants, ButtonSize, @@ -136,8 +136,8 @@ const PredictPosition: React.FC = ({ variant={TextVariant.BodySMMedium} color={TextColor.Alternative} > - ${initialValue.toFixed(2)} on {outcome} •{' '} - {(avgPrice * 100).toFixed(0)}Ā¢ + {formatPrice(initialValue, { maximumDecimals: 2 })} on {outcome} •{' '} + {formatCents(avgPrice)} diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx index c203d67aa8e0..2cf6b8a8c242 100644 --- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx +++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx @@ -509,7 +509,7 @@ describe('MarketsWonCard', () => { expect(screen.getByText('Available Balance')).toBeOnTheScreen(); expect(screen.getByText('$100.50')).toBeOnTheScreen(); expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - expect(screen.getByText('+$8.63 (+3.9%)')).toBeOnTheScreen(); + expect(screen.getByText('+$8.63 (+4%)')).toBeOnTheScreen(); }); it('renders claim button without loading indicator when isLoading is false', () => { setupMarketsWonCardTest({ isLoading: false }); @@ -532,7 +532,7 @@ describe('MarketsWonCard', () => { }, ); - expect(screen.getByText('+$123.46 (+5.7%)')).toBeOnTheScreen(); + expect(screen.getByText('+$123.46 (+6%)')).toBeOnTheScreen(); }); it('formats negative unrealized amount correctly', () => { @@ -547,7 +547,7 @@ describe('MarketsWonCard', () => { }, ); - expect(screen.getByText('-$50.25 (-2.1%)')).toBeOnTheScreen(); + expect(screen.getByText('-$50.25 (-2%)')).toBeOnTheScreen(); }); it('handles zero unrealized amount correctly', () => { @@ -562,7 +562,7 @@ describe('MarketsWonCard', () => { }, ); - expect(screen.getByText('+$0.00 (+0.0%)')).toBeOnTheScreen(); + expect(screen.getByText('+$0.00 (+0%)')).toBeOnTheScreen(); }); it('formats available balance to 2 decimal places', () => { @@ -632,7 +632,7 @@ describe('MarketsWonCard', () => { expect(screen.getByText('Available Balance')).toBeOnTheScreen(); expect(screen.getByText('$75.50')).toBeOnTheScreen(); expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - expect(screen.getByText('+$100.00 (+10.0%)')).toBeOnTheScreen(); + expect(screen.getByText('+$100.00 (+10%)')).toBeOnTheScreen(); }); }); @@ -649,7 +649,7 @@ describe('MarketsWonCard', () => { }, ); - expect(screen.getByText('+$999999.99 (+999.9%)')).toBeOnTheScreen(); + expect(screen.getByText('+$999999.99 (+>99%)')).toBeOnTheScreen(); }); it('handles very small unrealized amounts', () => { @@ -664,7 +664,7 @@ describe('MarketsWonCard', () => { }, ); - expect(screen.getByText('+$0.01 (+0.1%)')).toBeOnTheScreen(); + expect(screen.getByText('+$0.01 (+<1%)')).toBeOnTheScreen(); }); it('handles very large available balance', () => { @@ -692,7 +692,7 @@ describe('MarketsWonCard', () => { ); expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - expect(screen.getByText('+$50.00 (+5.0%)')).toBeOnTheScreen(); + expect(screen.getByText('+$50.00 (+5%)')).toBeOnTheScreen(); }); }); @@ -720,7 +720,7 @@ describe('MarketsWonCard', () => { expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); // Should show fallback values when there's an error - expect(screen.getByText('+$0.00 (+0.0%)')).toBeOnTheScreen(); + expect(screen.getByText('+$0.00 (+0%)')).toBeOnTheScreen(); }); it('handles null unrealized P&L data gracefully', () => { @@ -739,7 +739,7 @@ describe('MarketsWonCard', () => { expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); // Should show fallback values when data is null - expect(screen.getByText('+$0.00 (+0.0%)')).toBeOnTheScreen(); + expect(screen.getByText('+$0.00 (+0%)')).toBeOnTheScreen(); }); it('displays correct unrealized P&L data from hook', () => { @@ -757,7 +757,7 @@ describe('MarketsWonCard', () => { ); expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - expect(screen.getByText('-$15.75 (-8.2%)')).toBeOnTheScreen(); + expect(screen.getByText('-$15.75 (-8%)')).toBeOnTheScreen(); }); it('does not show unrealized P&L section when hook returns null data', () => { @@ -863,32 +863,71 @@ describe('MarketsWonCard', () => { }); }); - describe('User Interactions', () => { - it('calls onClaimPress when claim button is pressed', () => { - const mockOnClaimPress = jest.fn(); - const { props } = setupMarketsWonCardTest({ - onClaimPress: mockOnClaimPress, - }); + describe('View All Navigation', () => { + it('navigates to market list when available balance card is pressed', () => { + setupMarketsWonCardTest({ availableBalance: 100.5 }); - // Verify the callback was passed correctly - expect(props.onClaimPress).toBe(mockOnClaimPress); + const balanceTouchable = + screen.getByTestId('markets-won-count').parent?.parent; + if (balanceTouchable) { + fireEvent.press(balanceTouchable); + } + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); }); - it('navigates to predict modals when available balance is pressed', () => { - setupMarketsWonCardTest(); + it('navigates when balance is present and not loading', () => { + setupMarketsWonCardTest({ availableBalance: 50.25, isLoading: false }); const balanceTouchable = screen.getByTestId('markets-won-count').parent?.parent; if (balanceTouchable) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fireEvent.press(balanceTouchable as any); + fireEvent.press(balanceTouchable); } + expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_LIST, }); }); + it('does not render touchable area when balance is undefined', () => { + setupMarketsWonCardTest({ availableBalance: undefined }); + + expect(screen.queryByTestId('markets-won-count')).not.toBeOnTheScreen(); + }); + + it('navigates with correct route structure', () => { + setupMarketsWonCardTest({ availableBalance: 200 }); + + const balanceTouchable = + screen.getByTestId('markets-won-count').parent?.parent; + if (balanceTouchable) { + fireEvent.press(balanceTouchable); + } + + expect(mockNavigate).toHaveBeenCalledWith( + expect.stringContaining('Predict'), + expect.objectContaining({ + screen: expect.any(String), + }), + ); + }); + }); + + describe('User Interactions', () => { + it('calls onClaimPress when claim button is pressed', () => { + const mockOnClaimPress = jest.fn(); + const { props } = setupMarketsWonCardTest({ + onClaimPress: mockOnClaimPress, + }); + + // Verify the callback was passed correctly + expect(props.onClaimPress).toBe(mockOnClaimPress); + }); + it('calls refresh method and triggers data reloading', async () => { const mockLoadUnrealizedPnL = jest.fn(); const { ref } = setupMarketsWonCardTest( diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx index fa5b0b310fec..a0a623573348 100644 --- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx +++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.tsx @@ -35,7 +35,7 @@ import { POLYMARKET_PROVIDER_ID } from '../../providers/polymarket/constants'; import { selectPredictWonPositions } from '../../selectors/predictController'; import { PredictPosition } from '../../types'; import { PredictNavigationParamList } from '../../types/navigation'; -import { formatPrice } from '../../utils/format'; +import { formatPercentage, formatPrice } from '../../utils/format'; import ButtonHero from '../../../../../component-library/components-temp/Buttons/ButtonHero'; import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; import { PredictEventValues } from '../../constants/eventNames'; @@ -136,7 +136,7 @@ const PredictPositionsHeader = forwardRef< const formatPercent = (percent: number) => { const sign = percent >= 0 ? '+' : ''; - return `${sign}${percent.toFixed(1)}%`; + return `${sign}${formatPercentage(percent)}`; }; const hasClaimableAmount = diff --git a/app/components/UI/Predict/constants/errors.ts b/app/components/UI/Predict/constants/errors.ts index 4e1296c582f9..e1c50dad2a1b 100644 --- a/app/components/UI/Predict/constants/errors.ts +++ b/app/components/UI/Predict/constants/errors.ts @@ -7,7 +7,7 @@ export type PredictErrorCode = * Predict feature constants for error handling and logging */ export const PREDICT_CONSTANTS = { - FEATURE_NAME: 'Predict', + FEATURE_NAME: 'Predict', // For Sentry error filtering - enables "feature:Predict" queries CONTROLLER_NAME: 'PredictController', } as const; diff --git a/app/components/UI/Predict/constants/eventNames.ts b/app/components/UI/Predict/constants/eventNames.ts index 368c9564f2da..360133165a76 100644 --- a/app/components/UI/Predict/constants/eventNames.ts +++ b/app/components/UI/Predict/constants/eventNames.ts @@ -30,6 +30,9 @@ export const PredictEventProperties = { ORDER_ID: 'order_id', USER_ADDRESS: 'user_address', + // Trade status + STATUS: 'status', + // Performance metrics COMPLETION_DURATION: 'completion_duration', @@ -97,17 +100,22 @@ export const PredictEventValues = { } as const; /** - * Event type constants for analytics tracking + * Trade transaction status values for analytics tracking + * Used as the 'status' property in PREDICT_TRADE_TRANSACTION event */ -export const PredictEventType = { - INITIATED: 'INITIATED', - SUBMITTED: 'SUBMITTED', - COMPLETED: 'COMPLETED', - FAILED: 'FAILED', +export const PredictTradeStatus = { + INITIATED: 'initiated', + SUBMITTED: 'submitted', + SUCCEEDED: 'succeeded', + FAILED: 'failed', } as const; -export type PredictEventTypeValue = - (typeof PredictEventType)[keyof typeof PredictEventType]; +export type PredictTradeStatusValue = + (typeof PredictTradeStatus)[keyof typeof PredictTradeStatus]; + +// Legacy export for backward compatibility during transition +export const PredictEventType = PredictTradeStatus; +export type PredictEventTypeValue = PredictTradeStatusValue; /** * GTM Modal constants for analytics tracking diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 65a0c3dc70e6..bc3310c2a7e3 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -29,11 +29,17 @@ import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuil import Engine from '../../../../core/Engine'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import Logger, { type LoggerErrorOptions } from '../../../../util/Logger'; +import { + trace, + endTrace, + TraceName, + TraceOperation, +} from '../../../../util/trace'; import { addTransactionBatch } from '../../../../util/transaction-controller'; import { PredictEventProperties, - PredictEventType, - PredictEventTypeValue, + PredictTradeStatus, + PredictTradeStatusValue, } from '../constants/eventNames'; import { PolymarketProvider } from '../providers/polymarket/PolymarketProvider'; import { @@ -425,6 +431,23 @@ export class PredictController extends BaseController< * Get available markets with optional filtering */ async getMarkets(params: GetMarketsParams): Promise { + // Start Sentry trace for get markets operation + const traceId = `get-markets-${Date.now()}`; + let traceData: + | { success: boolean; error?: string; marketCount?: number } + | undefined; + + trace({ + name: TraceName.PredictGetMarkets, + op: TraceOperation.PredictDataFetch, + id: traceId, + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + providerId: params.providerId ?? 'unknown', + ...(params.category && { category: params.category }), + }, + }); + try { const providerIds = params.providerId ? [params.providerId] @@ -451,6 +474,7 @@ export class PredictController extends BaseController< state.lastUpdateTimestamp = Date.now(); }); + traceData = { success: true, marketCount: markets.length }; return markets; } catch (error) { const errorMessage = @@ -464,6 +488,8 @@ export class PredictController extends BaseController< state.lastUpdateTimestamp = Date.now(); }); + traceData = { success: false, error: errorMessage }; + // Log to Sentry with market query context Logger.error( ensureError(error), @@ -479,6 +505,12 @@ export class PredictController extends BaseController< // Re-throw the error so components can handle it appropriately throw error; + } finally { + endTrace({ + name: TraceName.PredictGetMarkets, + id: traceId, + data: traceData, + }); } } @@ -498,6 +530,20 @@ export class PredictController extends BaseController< throw new Error('marketId is required'); } + // Start Sentry trace for get market operation + const traceId = `get-market-${Date.now()}`; + let traceData: { success: boolean; error?: string } | undefined; + + trace({ + name: TraceName.PredictGetMarket, + op: TraceOperation.PredictDataFetch, + id: traceId, + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + providerId: providerId ?? 'unknown', + }, + }); + try { await this.initializeProviders(); @@ -517,6 +563,7 @@ export class PredictController extends BaseController< state.lastUpdateTimestamp = Date.now(); }); + traceData = { success: true }; return market; } catch (error) { const errorMessage = @@ -529,6 +576,8 @@ export class PredictController extends BaseController< state.lastUpdateTimestamp = Date.now(); }); + traceData = { success: false, error: errorMessage }; + // Log to Sentry with market details context Logger.error( ensureError(error), @@ -543,6 +592,12 @@ export class PredictController extends BaseController< } throw new Error(PREDICT_ERROR_CODES.MARKET_DETAILS_FAILED); + } finally { + endTrace({ + name: TraceName.PredictGetMarket, + id: traceId, + data: traceData, + }); } } @@ -552,6 +607,23 @@ export class PredictController extends BaseController< async getPriceHistory( params: GetPriceHistoryParams, ): Promise { + // Start Sentry trace for get price history operation + const traceId = `get-price-history-${Date.now()}`; + let traceData: + | { success: boolean; error?: string; pointCount?: number } + | undefined; + + trace({ + name: TraceName.PredictGetPriceHistory, + op: TraceOperation.PredictDataFetch, + id: traceId, + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + providerId: params.providerId ?? 'unknown', + ...(params.interval && { interval: params.interval }), + }, + }); + try { const providerIds = params.providerId ? [params.providerId] @@ -577,6 +649,7 @@ export class PredictController extends BaseController< state.lastUpdateTimestamp = Date.now(); }); + traceData = { success: true, pointCount: priceHistory.length }; return priceHistory; } catch (error) { const errorMessage = @@ -589,6 +662,8 @@ export class PredictController extends BaseController< state.lastUpdateTimestamp = Date.now(); }); + traceData = { success: false, error: errorMessage }; + // Log to Sentry with price history context Logger.error( ensureError(error), @@ -601,6 +676,12 @@ export class PredictController extends BaseController< ); throw error; + } finally { + endTrace({ + name: TraceName.PredictGetPriceHistory, + id: traceId, + data: traceData, + }); } } @@ -612,6 +693,25 @@ export class PredictController extends BaseController< * SELL = what you'd receive to sell */ async getPrices(params: GetPriceParams): Promise { + // Start Sentry trace for get prices operation + const traceId = `get-prices-${Date.now()}`; + let traceData: + | { success: boolean; error?: string; priceCount?: number } + | undefined; + + trace({ + name: TraceName.PredictGetPrices, + op: TraceOperation.PredictDataFetch, + id: traceId, + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + providerId: params.providerId ?? 'unknown', + }, + data: { + queryCount: params.queries?.length, + }, + }); + try { const providerId = params.providerId ?? 'polymarket'; const provider = this.providers.get(providerId); @@ -627,6 +727,7 @@ export class PredictController extends BaseController< state.lastUpdateTimestamp = Date.now(); }); + traceData = { success: true, priceCount: response.results?.length ?? 0 }; return response; } catch (error) { const errorMessage = @@ -639,6 +740,8 @@ export class PredictController extends BaseController< state.lastUpdateTimestamp = Date.now(); }); + traceData = { success: false, error: errorMessage }; + // Log to Sentry with prices context Logger.error( ensureError(error), @@ -649,6 +752,12 @@ export class PredictController extends BaseController< ); throw error; + } finally { + endTrace({ + name: TraceName.PredictGetPrices, + id: traceId, + data: traceData, + }); } } @@ -656,6 +765,23 @@ export class PredictController extends BaseController< * Get user positions */ async getPositions(params: GetPositionsParams): Promise { + // Start Sentry trace for get positions operation + const traceId = `get-positions-${Date.now()}`; + let traceData: + | { success: boolean; error?: string; positionCount?: number } + | undefined; + + trace({ + name: TraceName.PredictGetPositions, + op: TraceOperation.PredictDataFetch, + id: traceId, + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + providerId: params.providerId ?? 'unknown', + claimable: params.claimable ?? false, + }, + }); + try { const { address, providerId = 'polymarket' } = params; @@ -681,6 +807,7 @@ export class PredictController extends BaseController< } }); + traceData = { success: true, positionCount: positions.length }; return positions; } catch (error) { const errorMessage = @@ -694,6 +821,8 @@ export class PredictController extends BaseController< state.lastUpdateTimestamp = Date.now(); }); + traceData = { success: false, error: errorMessage }; + // Log to Sentry with positions query context (no user address) Logger.error( ensureError(error), @@ -706,6 +835,12 @@ export class PredictController extends BaseController< // Re-throw the error so components can handle it appropriately throw error; + } finally { + endTrace({ + name: TraceName.PredictGetPositions, + id: traceId, + data: traceData, + }); } } @@ -716,6 +851,22 @@ export class PredictController extends BaseController< address?: string; providerId?: string; }): Promise { + // Start Sentry trace for get activity operation + const traceId = `get-activity-${Date.now()}`; + let traceData: + | { success: boolean; error?: string; activityCount?: number } + | undefined; + + trace({ + name: TraceName.PredictGetActivity, + op: TraceOperation.PredictDataFetch, + id: traceId, + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + providerId: params.providerId ?? 'unknown', + }, + }); + try { const { address, providerId } = params; const selectedAddress = address ?? this.getSigner().address; @@ -743,6 +894,7 @@ export class PredictController extends BaseController< state.lastError = null; }); + traceData = { success: true, activityCount: activity.length }; return activity; } catch (error) { this.update((state) => { @@ -753,6 +905,11 @@ export class PredictController extends BaseController< state.lastUpdateTimestamp = Date.now(); }); + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + // Log to Sentry with activity query context (no user address) Logger.error( ensureError(error), @@ -762,6 +919,12 @@ export class PredictController extends BaseController< ); throw error; + } finally { + endTrace({ + name: TraceName.PredictGetActivity, + id: traceId, + data: traceData, + }); } } @@ -775,6 +938,20 @@ export class PredictController extends BaseController< address?: string; providerId?: string; }): Promise { + // Start Sentry trace for get unrealized PnL operation + const traceId = `get-unrealized-pnl-${Date.now()}`; + let traceData: { success: boolean; error?: string } | undefined; + + trace({ + name: TraceName.PredictGetUnrealizedPnL, + op: TraceOperation.PredictDataFetch, + id: traceId, + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + providerId: providerId ?? 'unknown', + }, + }); + try { const selectedAddress = address ?? this.getSigner().address; @@ -793,6 +970,7 @@ export class PredictController extends BaseController< state.lastError = null; }); + traceData = { success: true }; return unrealizedPnL; } catch (error) { const errorMessage = @@ -806,6 +984,8 @@ export class PredictController extends BaseController< state.lastUpdateTimestamp = Date.now(); }); + traceData = { success: false, error: errorMessage }; + // Log to Sentry with unrealized PnL context (no user address) Logger.error( ensureError(error), @@ -815,15 +995,22 @@ export class PredictController extends BaseController< ); throw error; + } finally { + endTrace({ + name: TraceName.PredictGetUnrealizedPnL, + id: traceId, + data: traceData, + }); } } /** - * Track Predict order analytics events + * Track Predict trade transaction analytics event + * Uses a single consolidated event with status discriminator * @public */ public async trackPredictOrderEvent({ - eventType, + status, amountUsd, analyticsProperties, providerId, @@ -832,7 +1019,7 @@ export class PredictController extends BaseController< sharePrice, pnl, }: { - eventType: PredictEventTypeValue; + status: PredictTradeStatusValue; amountUsd?: number; analyticsProperties?: PlaceOrderParams['analyticsProperties']; providerId: string; @@ -845,8 +1032,9 @@ export class PredictController extends BaseController< return; } - // Build regular properties (common to all events) + // Build regular properties (common to all statuses) const regularProperties = { + [PredictEventProperties.STATUS]: status, [PredictEventProperties.MARKET_ID]: analyticsProperties.marketId, [PredictEventProperties.MARKET_TITLE]: analyticsProperties.marketTitle, [PredictEventProperties.MARKET_CATEGORY]: @@ -865,11 +1053,11 @@ export class PredictController extends BaseController< ...(analyticsProperties.outcome && { [PredictEventProperties.OUTCOME]: analyticsProperties.outcome, }), - // Add completion duration for COMPLETED and FAILED events + // Add completion duration for succeeded and failed status ...(completionDuration !== undefined && { [PredictEventProperties.COMPLETION_DURATION]: completionDuration, }), - // Add failure reason for FAILED events + // Add failure reason for failed status ...(failureReason && { [PredictEventProperties.FAILURE_REASON]: failureReason, }), @@ -886,37 +1074,16 @@ export class PredictController extends BaseController< }), }; - // Determine event name based on type - let metaMetricsEvent: (typeof MetaMetricsEvents)[keyof typeof MetaMetricsEvents]; - let eventLabel: string; - - switch (eventType) { - case PredictEventType.INITIATED: - metaMetricsEvent = MetaMetricsEvents.PREDICT_ACTION_INITIATED; - eventLabel = 'PREDICT_ACTION_INITIATED'; - break; - case PredictEventType.SUBMITTED: - metaMetricsEvent = MetaMetricsEvents.PREDICT_ACTION_SUBMITTED; - eventLabel = 'PREDICT_ACTION_SUBMITTED'; - break; - case PredictEventType.COMPLETED: - metaMetricsEvent = MetaMetricsEvents.PREDICT_ACTION_COMPLETED; - eventLabel = 'PREDICT_ACTION_COMPLETED'; - break; - case PredictEventType.FAILED: - metaMetricsEvent = MetaMetricsEvents.PREDICT_ACTION_FAILED; - eventLabel = 'PREDICT_ACTION_FAILED'; - break; - } - - DevLogger.log(`šŸ“Š [Analytics] ${eventLabel}`, { + DevLogger.log(`šŸ“Š [Analytics] PREDICT_TRADE_TRANSACTION [${status}]`, { providerId, regularProperties, sensitiveProperties, }); MetaMetrics.getInstance().trackEvent( - MetricsEventBuilder.createEventBuilder(metaMetricsEvent) + MetricsEventBuilder.createEventBuilder( + MetaMetricsEvents.PREDICT_TRADE_TRANSACTION, + ) .addProperties(regularProperties) .addSensitiveProperties(sensitiveProperties) .build(), @@ -1126,6 +1293,28 @@ export class PredictController extends BaseController< ? preview?.maxAmountSpent : preview?.minAmountReceived; + // Start Sentry trace for place order operation + const traceId = `place-order-${Date.now()}`; + let traceData: + | { success: boolean; error?: string; side?: string } + | undefined; + + trace({ + name: TraceName.PredictPlaceOrder, + op: TraceOperation.PredictOrderSubmission, + id: traceId, + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + providerId: providerId ?? 'unknown', + side: preview.side, + }, + data: { + ...(analyticsProperties?.marketId && { + marketId: analyticsProperties.marketId, + }), + }, + }); + try { const provider = this.providers.get(providerId); if (!provider) { @@ -1134,9 +1323,9 @@ export class PredictController extends BaseController< const signer = this.getSigner(); - // Track Predict Action Submitted (fire and forget) + // Track Predict Trade Transaction with submitted status (fire and forget) this.trackPredictOrderEvent({ - eventType: PredictEventType.SUBMITTED, + status: PredictTradeStatus.SUBMITTED, amountUsd, analyticsProperties, providerId, @@ -1194,9 +1383,9 @@ export class PredictController extends BaseController< // If we can't get real share price, continue without it } - // Track Predict Action Completed (fire and forget) + // Track Predict Trade Transaction with succeeded status (fire and forget) this.trackPredictOrderEvent({ - eventType: PredictEventType.COMPLETED, + status: PredictTradeStatus.SUCCEEDED, amountUsd: realAmountUsd, analyticsProperties, providerId, @@ -1204,6 +1393,7 @@ export class PredictController extends BaseController< sharePrice: realSharePrice, }); + traceData = { success: true, side: preview.side }; return result as unknown as Result; } catch (error) { const completionDuration = performance.now() - startTime; @@ -1212,9 +1402,9 @@ export class PredictController extends BaseController< ? error.message : PREDICT_ERROR_CODES.PLACE_ORDER_FAILED; - // Track Predict Action Failed (fire and forget) + // Track Predict Trade Transaction with failed status (fire and forget) this.trackPredictOrderEvent({ - eventType: PredictEventType.FAILED, + status: PredictTradeStatus.FAILED, amountUsd, analyticsProperties, providerId, @@ -1229,14 +1419,7 @@ export class PredictController extends BaseController< state.lastUpdateTimestamp = Date.now(); }); - // Log error for debugging and future Sentry integration - DevLogger.log('PredictController: Place order failed', { - error: errorMessage, - errorDetails: error instanceof Error ? error.stack : undefined, - timestamp: new Date().toISOString(), - providerId, - params, - }); + traceData = { success: false, error: errorMessage }; // Log to Sentry with order context (excluding sensitive data like amounts) Logger.error( @@ -1251,13 +1434,49 @@ export class PredictController extends BaseController< }), ); + // Log error for debugging and future Sentry integration + DevLogger.log('PredictController: Place order failed', { + error: errorMessage, + errorDetails: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + providerId, + params, + }); + throw new Error(errorMessage); + } finally { + endTrace({ + name: TraceName.PredictPlaceOrder, + id: traceId, + data: traceData, + }); } } async claimWithConfirmation({ providerId, }: ClaimParams): Promise { + // Start Sentry trace for claim operation + const traceId = `claim-${Date.now()}`; + let traceData: + | { + success: boolean; + error?: string; + reason?: string; + positionCount?: number; + } + | undefined; + + trace({ + name: TraceName.PredictClaim, + op: TraceOperation.PredictOperation, + id: traceId, + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + providerId: providerId ?? 'unknown', + }, + }); + try { const provider = this.providers.get(providerId); if (!provider) { @@ -1334,10 +1553,13 @@ export class PredictController extends BaseController< state.lastUpdateTimestamp = Date.now(); }); + traceData = { success: true, positionCount: claimablePositions.length }; return predictClaim; } catch (error) { const e = ensureError(error); if (e.message.includes('User denied transaction signature')) { + traceData = { success: false, reason: 'user_cancelled' }; + // ignore error, as the user cancelled the tx return { batchId: 'NA', @@ -1345,6 +1567,14 @@ export class PredictController extends BaseController< status: PredictClaimStatus.CANCELLED, }; } + + const errorMessage = + error instanceof Error + ? error.message + : PREDICT_ERROR_CODES.CLAIM_FAILED; + + traceData = { success: false, error: errorMessage }; + // Log to Sentry with claim context (no user address or amounts) Logger.error( e, @@ -1352,16 +1582,6 @@ export class PredictController extends BaseController< providerId, }), ); - const errorMessage = - error instanceof Error - ? error.message - : PREDICT_ERROR_CODES.CLAIM_FAILED; - - // Update error state for Sentry integration - this.update((state) => { - state.lastError = errorMessage; - state.lastUpdateTimestamp = Date.now(); - }); // Log error for debugging and future Sentry integration DevLogger.log('PredictController: Claim failed', { @@ -1371,8 +1591,20 @@ export class PredictController extends BaseController< providerId, }); + // Update error state for Sentry integration + this.update((state) => { + state.lastError = errorMessage; + state.lastUpdateTimestamp = Date.now(); + }); + // Re-throw the error so the hook can handle it and show the toast throw error; + } finally { + endTrace({ + name: TraceName.PredictClaim, + id: traceId, + data: traceData, + }); } } @@ -1593,6 +1825,20 @@ export class PredictController extends BaseController< public async getAccountState( params: GetAccountStateParams, ): Promise { + // Start Sentry trace for get account state operation + const traceId = `get-account-state-${Date.now()}`; + let traceData: { success: boolean; error?: string } | undefined; + + trace({ + name: TraceName.PredictGetAccountState, + op: TraceOperation.PredictDataFetch, + id: traceId, + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + providerId: params.providerId ?? 'unknown', + }, + }); + try { const provider = this.providers.get(params.providerId); if (!provider) { @@ -1600,11 +1846,19 @@ export class PredictController extends BaseController< } const selectedAddress = this.getSigner().address; - return provider.getAccountState({ + const accountState = await provider.getAccountState({ ...params, ownerAddress: selectedAddress, }); + + traceData = { success: true }; + return accountState; } catch (error) { + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + // Log to Sentry with account state context (no user address) Logger.error( ensureError(error), @@ -1614,10 +1868,32 @@ export class PredictController extends BaseController< ); throw error; + } finally { + endTrace({ + name: TraceName.PredictGetAccountState, + id: traceId, + data: traceData, + }); } } public async getBalance(params: GetBalanceParams): Promise { + // Start Sentry trace for get balance operation + const traceId = `get-balance-${Date.now()}`; + let traceData: + | { success: boolean; error?: string; cached?: boolean } + | undefined; + + trace({ + name: TraceName.PredictGetBalance, + op: TraceOperation.PredictDataFetch, + id: traceId, + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + providerId: params.providerId ?? 'unknown', + }, + }); + try { const provider = this.providers.get(params.providerId); if (!provider) { @@ -1628,6 +1904,7 @@ export class PredictController extends BaseController< const cachedBalance = this.state.balances[params.providerId]?.[address]; if (cachedBalance && cachedBalance.validUntil > Date.now()) { + traceData = { success: true, cached: true }; return cachedBalance.balance; } @@ -1648,8 +1925,15 @@ export class PredictController extends BaseController< validUntil: Date.now() + 1000, }; }); + + traceData = { success: true, cached: false }; return balance; } catch (error) { + traceData = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + // Log to Sentry with balance query context (no user address) Logger.error( ensureError(error), @@ -1659,6 +1943,12 @@ export class PredictController extends BaseController< ); throw error; + } finally { + endTrace({ + name: TraceName.PredictGetBalance, + id: traceId, + data: traceData, + }); } } diff --git a/app/components/UI/Predict/hooks/usePredictClaim.test.ts b/app/components/UI/Predict/hooks/usePredictClaim.test.ts index 33e9591434e3..d0b9601d96e0 100644 --- a/app/components/UI/Predict/hooks/usePredictClaim.test.ts +++ b/app/components/UI/Predict/hooks/usePredictClaim.test.ts @@ -11,6 +11,7 @@ import { useConfirmNavigation } from '../../../Views/confirmations/hooks/useConf import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants'; import { usePredictClaim } from './usePredictClaim'; import { usePredictTrading } from './usePredictTrading'; +import { ConfirmationLoader } from '../../../Views/confirmations/components/confirm/confirm-component'; // Create mock functions const mockNavigate = jest.fn(); @@ -143,6 +144,7 @@ describe('usePredictClaim', () => { expect(mockNavigateToConfirmation).toHaveBeenCalledWith({ headerShown: false, stack: Routes.PREDICT.ROOT, + loader: ConfirmationLoader.PredictClaim, }); expect(mockClaimWinnings).toHaveBeenCalledWith({ providerId: POLYMARKET_PROVIDER_ID, @@ -273,6 +275,7 @@ describe('usePredictClaim', () => { expect(mockNavigateToConfirmation).toHaveBeenCalledWith({ headerShown: false, stack: Routes.PREDICT.ROOT, + loader: ConfirmationLoader.PredictClaim, }); expect(mockClaimWinnings).toHaveBeenCalledWith({ providerId: POLYMARKET_PROVIDER_ID, diff --git a/app/components/UI/Predict/hooks/usePredictClaim.ts b/app/components/UI/Predict/hooks/usePredictClaim.ts index 32efc061f134..1d4f57505e0c 100644 --- a/app/components/UI/Predict/hooks/usePredictClaim.ts +++ b/app/components/UI/Predict/hooks/usePredictClaim.ts @@ -12,6 +12,7 @@ import { PREDICT_CONSTANTS } from '../constants/errors'; import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants'; import { ensureError } from '../utils/predictErrorHandler'; import { usePredictTrading } from './usePredictTrading'; +import { ConfirmationLoader } from '../../../Views/confirmations/components/confirm/confirm-component'; interface UsePredictClaimParams { providerId?: string; @@ -31,6 +32,7 @@ export const usePredictClaim = ({ navigateToConfirmation({ headerShown: false, stack: Routes.PREDICT.ROOT, + loader: ConfirmationLoader.PredictClaim, }); await claimWinnings({ providerId }); } catch (err) { diff --git a/app/components/UI/Predict/hooks/usePredictMeasurement.ts b/app/components/UI/Predict/hooks/usePredictMeasurement.ts new file mode 100644 index 000000000000..97135b1cb4af --- /dev/null +++ b/app/components/UI/Predict/hooks/usePredictMeasurement.ts @@ -0,0 +1,206 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { + endTrace, + trace, + TraceName, + TraceOperation, +} from '../../../../util/trace'; +import { PREDICT_CONSTANTS } from '../constants/errors'; + +// Static helper functions - moved outside component to avoid recreation +const allTrue = (conditionArray: boolean[]): boolean => + conditionArray.length > 0 && conditionArray.every(Boolean); + +const anyTrue = (conditionArray: boolean[]): boolean => + conditionArray.some(Boolean); + +interface MeasurementOptions { + traceName: TraceName; + op?: TraceOperation; // Optional operation type, defaults to PredictOperation + + // Simple API - most common case + conditions?: boolean[]; // Start immediately, end when all conditions are true + + // Advanced API - full control + startConditions?: boolean[]; + endConditions?: boolean[]; + resetConditions?: boolean[]; + + debugContext?: Record; +} + +/** + * Unified hook for performance measurement with conditional start/end logic + * + * Replaces manual useEffect patterns with a declarative approach: + * - Automatically starts measurement when ALL startConditions are true (or immediately if none provided) + * - Completes measurement when ALL endConditions are true + * - Resets measurement when ANY resetCondition is true + * + * @example + * // SIMPLE: Immediate single measurement (most common case) + * usePredictMeasurement({ + * traceName: TraceName.PredictFeedView, + * // No conditions = immediate measurement + * // op defaults to PredictOperation + * }); + * + * @example + * // CONDITIONAL: Wait for data before measuring + * usePredictMeasurement({ + * traceName: TraceName.PredictMarketDetailsView, + * conditions: [dataLoaded, !isLoading] // Start immediately, end when both true + * }); + * + * @example + * // MODAL: With auto-reset + * usePredictMeasurement({ + * traceName: TraceName.PredictBuyPreviewView, + * conditions: [isVisible, !!marketData], // Auto-resets when !isVisible + * debugContext: { marketId } + * }); + * + * @example + * // ADVANCED: Full control when needed + * usePredictMeasurement({ + * traceName: TraceName.PredictOrderExecution, + * op: TraceOperation.PredictOrderSubmission, // Override default operation + * startConditions: [userInteracted, dataReady], + * endConditions: [workflowComplete, !hasErrors], + * resetConditions: [userCanceled, sessionExpired] + * }); + */ +export const usePredictMeasurement = ({ + traceName, + op = TraceOperation.PredictOperation, + conditions, + startConditions, + endConditions, + resetConditions, + debugContext = {}, +}: MeasurementOptions) => { + const hasCompleted = useRef(false); + const previousStartState = useRef(false); + const previousEndState = useRef(false); + const traceStarted = useRef(false); + const traceId = useRef(uuidv4()); + + // Memoize smart defaults logic to avoid recalculation on every render + const { actualStartConditions, actualEndConditions, actualResetConditions } = + useMemo(() => { + if (conditions) { + // Simple API: start immediately, end when conditions are met + return { + actualStartConditions: [], + actualEndConditions: conditions, + // Smart default: reset when first condition becomes false (e.g., visibility) + actualResetConditions: + resetConditions || (conditions.length > 0 ? [!conditions[0]] : []), + }; + } + + // Default case - immediate single measurement + if (!startConditions && !endConditions && !resetConditions) { + return { + actualStartConditions: [], + actualEndConditions: [true], // Always true = immediate completion + actualResetConditions: [], // No reset needed for single measurement + }; + } + + // Advanced API: explicit control + return { + actualStartConditions: startConditions || [], + actualEndConditions: endConditions || [true], // Default to immediate completion + actualResetConditions: resetConditions || [], + }; + }, [conditions, startConditions, endConditions, resetConditions]); + + // Memoize condition checks to avoid recalculation + const shouldStart = useMemo( + () => actualStartConditions.length === 0 || allTrue(actualStartConditions), + [actualStartConditions], + ); + + const shouldEnd = useMemo( + () => allTrue(actualEndConditions), + [actualEndConditions], + ); + + const shouldReset = useMemo( + () => anyTrue(actualResetConditions), + [actualResetConditions], + ); + + useEffect(() => { + // Handle reset conditions + if (shouldReset && (traceStarted.current || hasCompleted.current)) { + // End any active trace before resetting + if (traceStarted.current) { + endTrace({ + name: traceName, + id: traceId.current, + data: { + success: false, + reason: 'reset', + }, + }); + traceStarted.current = false; + } + hasCompleted.current = false; + previousStartState.current = false; + previousEndState.current = false; + return; + } + + // Handle start conditions + if (shouldStart && !previousStartState.current && !traceStarted.current) { + // Generate a new trace ID for this measurement cycle + traceId.current = uuidv4(); + + // Start a Sentry trace using the provided trace name + trace({ + name: traceName, + op, + id: traceId.current, + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + }, + data: debugContext as Record, + }); + traceStarted.current = true; + } + + // Handle end conditions + if ( + shouldEnd && + !previousEndState.current && + traceStarted.current && + !hasCompleted.current + ) { + // End the trace - Sentry calculates duration from timestamps automatically + endTrace({ + name: traceName, + id: traceId.current, + data: { success: true }, + }); + traceStarted.current = false; + + hasCompleted.current = true; + } + + // Update previous states for edge detection + previousStartState.current = shouldStart; + previousEndState.current = shouldEnd; + }, [ + traceName, + op, + shouldStart, + shouldEnd, + shouldReset, + debugContext, + actualStartConditions, + actualEndConditions, + ]); +}; diff --git a/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts b/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts index a9fc1080984f..b02efcc421a2 100644 --- a/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts +++ b/app/components/UI/Predict/hooks/usePredictPlaceOrder.ts @@ -6,6 +6,12 @@ import { } from '../../../../component-library/components/Toast'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import Logger from '../../../../util/Logger'; +import { + trace, + endTrace, + TraceName, + TraceOperation, +} from '../../../../util/trace'; import { PlaceOrderParams } from '../providers/types'; import { Side, type Result } from '../types'; import { usePredictTrading } from './usePredictTrading'; @@ -50,6 +56,17 @@ export function usePredictPlaceOrder( const showCashedOutToast = useCallback( (amount: string) => { + // Track cashout confirmation toast display performance + const traceId = `cashout-toast-${Date.now()}`; + trace({ + name: TraceName.PredictCashoutConfirmationToast, + op: TraceOperation.PredictOperation, + id: traceId, + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + }, + }); + toastRef?.current?.showToast({ variant: ToastVariants.Icon, iconName: IconName.Check, @@ -65,22 +82,45 @@ export function usePredictPlaceOrder( ], hasNoTimeout: false, }); + + // End trace immediately after toast is shown + endTrace({ + name: TraceName.PredictCashoutConfirmationToast, + id: traceId, + data: { success: true }, + }); }, [toastRef], ); - const showOrderPlacedToast = useCallback( - () => - toastRef?.current?.showToast({ - variant: ToastVariants.Icon, - iconName: IconName.Check, - labelOptions: [ - { label: strings('predict.order.prediction_placed'), isBold: true }, - ], - hasNoTimeout: false, - }), - [toastRef], - ); + const showOrderPlacedToast = useCallback(() => { + // Track order confirmation toast display performance + const traceId = `order-toast-${Date.now()}`; + trace({ + name: TraceName.PredictOrderConfirmationToast, + op: TraceOperation.PredictOperation, + id: traceId, + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + }, + }); + + toastRef?.current?.showToast({ + variant: ToastVariants.Icon, + iconName: IconName.Check, + labelOptions: [ + { label: strings('predict.order.prediction_placed'), isBold: true }, + ], + hasNoTimeout: false, + }); + + // End trace immediately after toast is shown + endTrace({ + name: TraceName.PredictOrderConfirmationToast, + id: traceId, + data: { success: true }, + }); + }, [toastRef]); const placeOrder = useCallback( async (orderParams: PlaceOrderParams) => { diff --git a/app/components/UI/Predict/utils/format.test.ts b/app/components/UI/Predict/utils/format.test.ts index 6034489fc057..629247f8aaca 100644 --- a/app/components/UI/Predict/utils/format.test.ts +++ b/app/components/UI/Predict/utils/format.test.ts @@ -30,12 +30,6 @@ jest.mock('react-native', () => ({ }, })); -import { formatWithThreshold } from '../../../../util/assets'; - -const mockFormatWithThreshold = formatWithThreshold as jest.MockedFunction< - typeof formatWithThreshold ->; - describe('format utils', () => { beforeEach(() => { jest.clearAllMocks(); @@ -46,28 +40,28 @@ describe('format utils', () => { }); describe('formatPercentage', () => { - it('formats positive decimal percentage with 2 decimal places', () => { + it('formats positive decimal percentage with no decimals', () => { // Arrange & Act const result = formatPercentage(5.25); // Assert - expect(result).toBe('+5.25%'); + expect(result).toBe('5%'); }); - it('formats positive whole number percentage without decimals', () => { + it('formats large percentage as >99%', () => { // Arrange & Act const result = formatPercentage(100); // Assert - expect(result).toBe('+100%'); + expect(result).toBe('>99%'); }); - it('formats negative decimal percentage with 2 decimal places', () => { + it('formats negative decimal percentage with no decimals', () => { // Arrange & Act const result = formatPercentage(-2.75); // Assert - expect(result).toBe('-2.75%'); + expect(result).toBe('-3%'); }); it('formats negative whole number percentage without decimals', () => { @@ -91,7 +85,7 @@ describe('format utils', () => { const result = formatPercentage('3.14159'); // Assert - expect(result).toBe('+3.14%'); + expect(result).toBe('3%'); }); it('handles string input with whole number', () => { @@ -99,7 +93,7 @@ describe('format utils', () => { const result = formatPercentage('42'); // Assert - expect(result).toBe('+42%'); + expect(result).toBe('42%'); }); it('handles string input with negative value', () => { @@ -107,7 +101,7 @@ describe('format utils', () => { const result = formatPercentage('-7.89'); // Assert - expect(result).toBe('-7.89%'); + expect(result).toBe('-8%'); }); it('returns default value for NaN input', () => { @@ -115,7 +109,7 @@ describe('format utils', () => { const result = formatPercentage('not-a-number'); // Assert - expect(result).toBe('0.00%'); + expect(result).toBe('0%'); }); it('returns default value for invalid string', () => { @@ -123,7 +117,7 @@ describe('format utils', () => { const result = formatPercentage('abc'); // Assert - expect(result).toBe('0.00%'); + expect(result).toBe('0%'); }); it('returns default value for empty string', () => { @@ -131,267 +125,168 @@ describe('format utils', () => { const result = formatPercentage(''); // Assert - expect(result).toBe('0.00%'); + expect(result).toBe('0%'); }); it.each([ - [0.01, '+0.01%'], - [0.001, '+0.00%'], - [1.999, '+2.00%'], - [99.999, '+100.00%'], - [-0.01, '-0.01%'], - [-0.001, '-0.00%'], - [-1.999, '-2.00%'], + [0.01, '<1%'], + [0.001, '<1%'], + [0.5, '<1%'], + [0.9, '<1%'], + [1.999, '2%'], + [99, '>99%'], + [99.999, '>99%'], + [100, '>99%'], + [-0.01, '0%'], + [-0.001, '0%'], + [-1.999, '-2%'], ])('formats %f correctly as %s', (input, expected) => { expect(formatPercentage(input)).toBe(expected); }); }); describe('formatPrice', () => { - beforeEach(() => { - mockFormatWithThreshold.mockImplementation( - (value, _threshold, locale, options) => - new Intl.NumberFormat(locale, options).format(Number(value)), - ); - }); - - describe('prices >= 1000', () => { - it('formats prices >= 1000 with default 2 minimum decimals', () => { - // Arrange & Act - const result = formatPrice(1234.5678); - - // Assert - expect(result).toBe('$1,234.57'); - expect(mockFormatWithThreshold).toHaveBeenCalledWith( - 1234.5678, - 1000, - 'en-US', - { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }, - ); - }); + it('formats prices with exactly 2 decimal places (truncated)', () => { + // Arrange & Act + const result = formatPrice(1234.5678); - it('formats prices >= 1000 with custom minimum decimals', () => { - // Arrange & Act - const result = formatPrice(50000, { minimumDecimals: 0 }); - - // Assert - expect(result).toBe('$50,000'); - expect(mockFormatWithThreshold).toHaveBeenCalledWith( - 50000, - 1000, - 'en-US', - { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }, - ); - }); + // Assert + expect(result).toBe('$1,234.56'); + }); - it('formats prices >= 1000 with 4 maximum decimals when minimum is higher', () => { - // Arrange & Act - const result = formatPrice(1234.5678, { minimumDecimals: 4 }); - - // Assert - expect(result).toBe('$1,234.5678'); - expect(mockFormatWithThreshold).toHaveBeenCalledWith( - 1234.5678, - 1000, - 'en-US', - { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 4, - maximumFractionDigits: 4, - }, - ); - }); + it('formats prices ignoring custom minimum decimals option', () => { + // Arrange & Act + const result = formatPrice(50000, { minimumDecimals: 0 }); + + // Assert + expect(result).toBe('$50,000.00'); }); - describe('prices < 1000', () => { - it('formats prices < 1000 with up to 4 decimal places', () => { - // Arrange & Act - const result = formatPrice(0.1234); - - // Assert - expect(result).toBe('$0.1234'); - expect(mockFormatWithThreshold).toHaveBeenCalledWith( - 0.1234, - 0.0001, - 'en-US', - { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 4, - }, - ); - }); + it('formats prices ignoring custom maximum decimals option', () => { + // Arrange & Act + const result = formatPrice(1234.5678, { minimumDecimals: 4 }); - it('formats prices < 1000 with custom minimum decimals', () => { - // Arrange & Act - const result = formatPrice(123.4567, { minimumDecimals: 0 }); - - // Assert - expect(result).toBe('$123.4567'); - expect(mockFormatWithThreshold).toHaveBeenCalledWith( - 123.4567, - 0.0001, - 'en-US', - { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 4, - }, - ); - }); + // Assert + expect(result).toBe('$1,234.56'); + }); - it('formats small prices with 4-decimal rounding', () => { - // Arrange & Act - const result = formatPrice(0.0001234); - - // Assert - expect(result).toBe('$0.0001'); - expect(mockFormatWithThreshold).toHaveBeenCalledWith( - 0.0001234, - 0.0001, - 'en-US', - { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 4, - }, - ); - }); + it('formats small prices with 2 decimal places (truncated)', () => { + // Arrange & Act + const result = formatPrice(0.1234); + + // Assert + expect(result).toBe('$0.12'); }); - describe('string inputs', () => { - it('handles string input with decimal value', () => { - // Arrange & Act - const result = formatPrice('1234.5678'); + it('formats very small prices as $0.00', () => { + // Arrange & Act + const result = formatPrice(0.0001234); - // Assert - expect(result).toBe('$1,234.57'); - }); + // Assert + expect(result).toBe('$0.00'); + }); - it('handles string input with small value', () => { - // Arrange & Act - const result = formatPrice('0.1234'); + it('handles string input with decimal value', () => { + // Arrange & Act + const result = formatPrice('1234.5678'); - // Assert - expect(result).toBe('$0.1234'); - }); + // Assert + expect(result).toBe('$1,234.56'); }); - describe('NaN and invalid inputs', () => { - it('returns default value for NaN with default decimals', () => { - // Arrange & Act - const result = formatPrice('not-a-number'); + it('handles string input with small value', () => { + // Arrange & Act + const result = formatPrice('0.1234'); - // Assert - expect(result).toBe('$0.00'); - }); + // Assert + expect(result).toBe('$0.12'); + }); - it('returns default value for NaN with minimumDecimals 0', () => { - // Arrange & Act - const result = formatPrice(NaN, { minimumDecimals: 0 }); + it('returns default value for NaN with default decimals', () => { + // Arrange & Act + const result = formatPrice('not-a-number'); - // Assert - expect(result).toBe('$0'); - }); + // Assert + expect(result).toBe('$0.00'); + }); - it('returns default value for invalid string', () => { - // Arrange & Act - const result = formatPrice('abc'); + it('returns default value for NaN ignoring options', () => { + // Arrange & Act + const result = formatPrice(NaN, { minimumDecimals: 0 }); - // Assert - expect(result).toBe('$0.00'); - }); + // Assert + expect(result).toBe('$0.00'); + }); - it('returns default value for empty string', () => { - // Arrange & Act - const result = formatPrice(''); + it('returns default value for invalid string', () => { + // Arrange & Act + const result = formatPrice('abc'); - // Assert - expect(result).toBe('$0.00'); - }); + // Assert + expect(result).toBe('$0.00'); }); - describe('edge cases', () => { - it('formats exactly 1000 correctly', () => { - // Arrange & Act - const result = formatPrice(1000); - - // Assert - expect(result).toBe('$1,000.00'); - expect(mockFormatWithThreshold).toHaveBeenCalledWith( - 1000, - 1000, - 'en-US', - { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }, - ); - }); + it('returns default value for empty string', () => { + // Arrange & Act + const result = formatPrice(''); - it('formats negative prices correctly', () => { - // Arrange & Act - const result = formatPrice(-1234.56); + // Assert + expect(result).toBe('$0.00'); + }); - // Assert - expect(result).toBe('-$1,234.56'); - }); + it('formats exactly 1000 correctly', () => { + // Arrange & Act + const result = formatPrice(1000); - it('formats zero correctly', () => { - // Arrange & Act - const result = formatPrice(0); + // Assert + expect(result).toBe('$1,000.00'); + }); - // Assert - expect(result).toBe('$0.00'); - }); + it('formats negative prices correctly', () => { + // Arrange & Act + const result = formatPrice(-1234.56); - it('formats very large numbers correctly', () => { - // Arrange & Act - const result = formatPrice(1000000); + // Assert + expect(result).toBe('-$1,234.56'); + }); - // Assert - expect(result).toBe('$1,000,000.00'); - }); + it('formats zero correctly', () => { + // Arrange & Act + const result = formatPrice(0); + + // Assert + expect(result).toBe('$0.00'); }); - describe('boundary values', () => { - it.each([ - [999.999, '$999.999'], - [1000, '$1,000.00'], - [1000.001, '$1,000.00'], - [0.9999, '$0.9999'], - [0.00009999, '$0.0001'], - ])('formats boundary value %f as %s', (input, expected) => { - const result = formatPrice(input); - expect(result).toBe(expected); - }); + it('formats very large numbers correctly', () => { + // Arrange & Act + const result = formatPrice(1000000); + + // Assert + expect(result).toBe('$1,000,000.00'); }); - }); - describe('formatCurrencyValue', () => { - beforeEach(() => { - mockFormatWithThreshold.mockImplementation( - (value, _threshold, locale, options) => - new Intl.NumberFormat(locale, options).format(Number(value)), - ); + it('truncates not rounds - 1234.999 becomes $1,234.99 not $1,235.00', () => { + // Arrange & Act + const result = formatPrice(1234.999); + + // Assert + expect(result).toBe('$1,234.99'); + }); + + it.each([ + [999.999, '$999.99'], + [1000, '$1,000.00'], + [1000.001, '$1,000.00'], + [0.9999, '$0.99'], + [0.00009999, '$0.00'], + ])('formats boundary value %f as %s', (input, expected) => { + const result = formatPrice(input); + expect(result).toBe(expected); }); + }); + describe('formatCurrencyValue', () => { it.each([ [undefined, undefined], [null, undefined], @@ -421,38 +316,16 @@ describe('format utils', () => { expect(result).toBe(expected); }); - it('uses absolute value and 2 decimals for values >= 1000', () => { + it('uses absolute value and 2 decimals (truncated) for values >= 1000', () => { const result = formatCurrencyValue(-1234.567); - expect(result).toBe('$1,234.57'); - expect(mockFormatWithThreshold).toHaveBeenCalledWith( - 1234.567, - 1000, - 'en-US', - { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }, - ); - }); - - it('uses absolute value and 2 decimals for values < 1000', () => { + expect(result).toBe('$1,234.56'); + }); + + it('uses absolute value and 2 decimals (truncated) for values < 1000', () => { const result = formatCurrencyValue(-0.1234); expect(result).toBe('$0.12'); - expect(mockFormatWithThreshold).toHaveBeenCalledWith( - 0.1234, - 0.0001, - 'en-US', - { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }, - ); }); }); diff --git a/app/components/UI/Predict/utils/format.ts b/app/components/UI/Predict/utils/format.ts index 5e033b1758b8..10e733508c59 100644 --- a/app/components/UI/Predict/utils/format.ts +++ b/app/components/UI/Predict/utils/format.ts @@ -1,80 +1,70 @@ import { Dimensions } from 'react-native'; -import { formatWithThreshold } from '../../../../util/assets'; import { PredictSeries, Recurrence } from '../types'; /** - * Formats a percentage value with sign prefix + * Formats a percentage value with no decimals * @param value - Raw percentage value (e.g., 5.25 for 5.25%, not 0.0525) - * @returns Format: "+X.XX%" or "-X.XX%" (always shows sign, 2 decimals) - * @example formatPercentage(5.25) => "+5.25%" - * @example formatPercentage(-2.75) => "-2.75%" + * @returns Format: "X%" with no decimals + * - For values >= 99: ">99%" + * - For values < 1 (but > 0): "<1%" + * - For negative values: rounded normally (e.g., "-3%", "-99%") + * @example formatPercentage(5.25) => "5%" + * @example formatPercentage(99.5) => ">99%" + * @example formatPercentage(0.5) => "<1%" + * @example formatPercentage(-2.75) => "-3%" + * @example formatPercentage(-99.5) => "-100%" * @example formatPercentage(0) => "0%" - * @example formatPercentage(100) => "+100%" */ export const formatPercentage = (value: string | number): string => { const num = typeof value === 'string' ? parseFloat(value) : value; if (isNaN(num)) { - return '0.00%'; + return '0%'; } - const sign = num >= 0 ? '+' : ''; - const absoluteValue = Math.abs(num); + // Handle special cases for positive numbers only + if (num >= 99) { + return '>99%'; + } - // If the number is a whole number (no decimal places), don't show .00 - if (absoluteValue === Math.floor(absoluteValue)) { - if (num === 0) { - return '0%'; - } - return `${sign}${num}%`; + if (num > 0 && num < 1) { + return '<1%'; } - return `${sign}${num.toFixed(2)}%`; + // Round to nearest integer + return `${Math.round(num)}%`; }; /** - * Formats a price value as USD currency with variable decimal places based on magnitude + * Formats a price value as USD currency with exactly 2 decimal places (truncated, no rounding) * @param price - Raw numeric price value - * @param options - Optional formatting options - * @param options.minimumDecimals - Minimum decimal places (default: 2, use 0 for whole numbers) - * @param options.maximumDecimals - Maximum decimal places (default: 2 for prices >= $1000, 4 for prices < $1000) - * @returns USD formatted string with variable decimals: - * - Prices >= $1000: "$X,XXX.XX" (2 decimals by default) - * - Prices < $1000: "$X.XXXX" (up to 4 decimals) - * @example formatPrice(1234.5678) => "$1,234.57" - * @example formatPrice(0.1234) => "$0.1234" - * @example formatPrice(50000, { minimumDecimals: 0 }) => "$50,000" + * @param options - Optional formatting options (kept for backwards compatibility, but not used) + * @returns USD formatted string with exactly 2 decimals (truncated, not rounded) + * @example formatPrice(1234.5678) => "$1,234.56" + * @example formatPrice(0.1234) => "$0.12" + * @example formatPrice(50000) => "$50,000.00" + * @example formatPrice(1234.999) => "$1,234.99" (truncated, not rounded to $1,235.00) */ export const formatPrice = ( price: string | number, - options?: { minimumDecimals?: number; maximumDecimals?: number }, + _options?: { minimumDecimals?: number; maximumDecimals?: number }, ): string => { const num = typeof price === 'string' ? parseFloat(price) : price; - const minDecimals = options?.minimumDecimals ?? 2; - const maxDecimals = options?.maximumDecimals ?? 4; if (isNaN(num)) { - return minDecimals === 0 ? '$0' : '$0.00'; + return '$0.00'; } - // For prices >= 1000, use specified minimum decimal places - if (num >= 1000) { - return formatWithThreshold(num, 1000, 'en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: minDecimals, - maximumFractionDigits: - options?.maximumDecimals ?? Math.max(minDecimals, 2), - }); - } + // Truncate to 2 decimal places (no rounding) + const truncated = Math.floor(num * 100) / 100; - // For prices < 1000, use up to 4 decimal places - return formatWithThreshold(num, 0.0001, 'en-US', { + // Format with exactly 2 decimal places + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', - minimumFractionDigits: minDecimals, - maximumFractionDigits: maxDecimals, - }); + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(truncated); }; /** diff --git a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx index 859db01a949e..9625f425346d 100644 --- a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx +++ b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.test.tsx @@ -103,20 +103,6 @@ jest.mock('../../hooks/usePredictDeposit', () => ({ }), })); -// Mock rewards feature flag selector -const mockRewardsPredictEnabledState = { value: false }; -jest.mock('../../../../../selectors/featureFlagController/rewards', () => { - const actual = jest.requireActual( - '../../../../../selectors/featureFlagController/rewards', - ); - return { - ...actual, - selectRewardsPredictEnabledFlag: jest.fn( - () => mockRewardsPredictEnabledState.value, - ), - }; -}); - // Mock Skeleton component jest.mock( '../../../../../component-library/components/Skeleton/Skeleton', @@ -326,7 +312,6 @@ describe('PredictBuyPreview', () => { mockBalanceLoading = false; mockMetamaskFee = 0.5; mockProviderFee = 1.0; - mockRewardsPredictEnabledState.value = false; // Setup default mocks mockUseNavigation.mockReturnValue(mockNavigation); @@ -2203,7 +2188,6 @@ describe('PredictBuyPreview', () => { describe('Rewards Calculation', () => { it('calculates estimated points as metamask fee times 100 rounded', () => { - mockRewardsPredictEnabledState.value = true; mockMetamaskFee = 0.5; const mockStore = { ...initialState, @@ -2228,7 +2212,6 @@ describe('PredictBuyPreview', () => { }); it('rounds estimated points to nearest integer', () => { - mockRewardsPredictEnabledState.value = true; mockMetamaskFee = 1.234; renderWithProvider(, { @@ -2239,7 +2222,6 @@ describe('PredictBuyPreview', () => { }); it('calculates zero points when metamask fee is zero', () => { - mockRewardsPredictEnabledState.value = true; mockMetamaskFee = 0; renderWithProvider(, { @@ -2250,7 +2232,6 @@ describe('PredictBuyPreview', () => { }); it('recalculates points when metamask fee changes', () => { - mockRewardsPredictEnabledState.value = true; mockMetamaskFee = 0.5; const { rerender } = renderWithProvider(, { @@ -2267,8 +2248,7 @@ describe('PredictBuyPreview', () => { }); describe('Rewards Display', () => { - it('shows rewards when feature flag is enabled and amount is entered', () => { - mockRewardsPredictEnabledState.value = true; + it('shows rewards when amount is entered', () => { mockMetamaskFee = 0.5; renderWithProvider(, { @@ -2286,28 +2266,7 @@ describe('PredictBuyPreview', () => { // shouldShowRewards = true when rewardsEnabled && currentValue > 0 }); - it('does not show rewards when feature flag is disabled', () => { - mockRewardsPredictEnabledState.value = false; - mockMetamaskFee = 0.5; - - renderWithProvider(, { - state: initialState, - }); - - // Enter amount - act(() => { - capturedOnChange?.({ - value: '10', - valueAsNumber: 10, - }); - }); - - // shouldShowRewards = false when rewardsEnabled is false - }); - it('does not show rewards when amount is zero', () => { - mockRewardsPredictEnabledState.value = true; - renderWithProvider(, { state: initialState, }); diff --git a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx index 6aa28bf056e0..70404de863bb 100644 --- a/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx +++ b/app/components/UI/Predict/views/PredictBuyPreview/PredictBuyPreview.tsx @@ -30,7 +30,6 @@ import { ScrollView, TouchableOpacity, } from 'react-native'; -import { useSelector } from 'react-redux'; import Button, { ButtonSize, ButtonVariants, @@ -47,7 +46,7 @@ import { usePredictOrderPreview } from '../../hooks/usePredictOrderPreview'; import { Side } from '../../types'; import { PredictNavigationParamList } from '../../types/navigation'; import { - PredictEventType, + PredictTradeStatus, PredictEventValues, } from '../../constants/eventNames'; import { formatCents, formatPrice } from '../../utils/format'; @@ -63,7 +62,8 @@ import { usePredictDeposit } from '../../hooks/usePredictDeposit'; import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; import { strings } from '../../../../../../locales/i18n'; import ButtonHero from '../../../../../component-library/components-temp/Buttons/ButtonHero'; -import { selectRewardsPredictEnabledFlag } from '../../../../../selectors/featureFlagController/rewards'; +import { TraceName } from '../../../../../util/trace'; +import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; const PredictBuyPreview = () => { const tw = useTailwind(); @@ -76,9 +76,6 @@ const PredictBuyPreview = () => { const { market, outcome, outcomeToken, entryPoint } = route.params; - // Rewards feature flag - const rewardsEnabled = useSelector(selectRewardsPredictEnabledFlag); - // Prepare analytics properties const analyticsProperties = useMemo( () => ({ @@ -140,6 +137,17 @@ const PredictBuyPreview = () => { autoRefreshTimeout: 1000, }); + // Track screen load performance (balance + initial preview) + usePredictMeasurement({ + traceName: TraceName.PredictBuyPreviewView, + conditions: [!isBalanceLoading, balance !== undefined, !!market], + debugContext: { + marketId: market?.id, + hasBalance: balance !== undefined, + isBalanceLoading, + }, + }); + // Track when user changes input to show skeleton only during user input changes useEffect(() => { if (!isCalculating) { @@ -163,12 +171,12 @@ const PredictBuyPreview = () => { const errorMessage = previewError ?? placeOrderError; - // Track Predict Action Initiated when screen mounts + // Track Predict Trade Transaction with initiated status when screen mounts useEffect(() => { const controller = Engine.context.PredictController; controller.trackPredictOrderEvent({ - eventType: PredictEventType.INITIATED, + status: PredictTradeStatus.INITIATED, analyticsProperties, providerId: outcome.providerId, sharePrice: outcomeToken?.price, @@ -191,8 +199,8 @@ const PredictBuyPreview = () => { [metamaskFee], ); - // Show rewards row if feature is enabled and we have a valid amount - const shouldShowRewards = rewardsEnabled && currentValue > 0; + // Show rewards row if we have a valid amount + const shouldShowRewards = currentValue > 0; // Validation constants and states const MINIMUM_BET = 1; // $1 minimum bet diff --git a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx index 3194502dde52..b311c13b6f2b 100644 --- a/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx +++ b/app/components/UI/Predict/views/PredictFeed/PredictFeed.tsx @@ -9,10 +9,12 @@ import Animated, { useAnimatedStyle } from 'react-native-reanimated'; import { useRoute, RouteProp, useFocusEffect } from '@react-navigation/native'; import { PredictMarketListSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; import { useTheme } from '../../../../../util/theme'; +import { TraceName } from '../../../../../util/trace'; import { PredictBalance } from '../../components/PredictBalance'; import PredictFeedHeader from '../../components/PredictFeedHeader'; import PredictMarketList from '../../components/PredictMarketList'; import { useSharedScrollCoordinator } from '../../hooks/useSharedScrollCoordinator'; +import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; import PredictFeedSessionManager from '../../services/PredictFeedSessionManager'; import { PredictNavigationParamList } from '../../types/navigation'; import type { PredictCategory } from '../../types'; @@ -29,6 +31,16 @@ const PredictFeed = () => { const scrollCoordinator = useSharedScrollCoordinator(); const sessionManager = PredictFeedSessionManager.getInstance(); + // Track screen load performance + usePredictMeasurement({ + traceName: TraceName.PredictFeedView, + conditions: [!isSearchVisible], + debugContext: { + entryPoint: route.params?.entryPoint, + isSearchVisible, + }, + }); + // Initialize session and enable AppState listener on mount useEffect(() => { // Enable AppState listener to detect app backgrounding diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index 2c27bdded29d..45aedb7ed3d1 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -164,6 +164,14 @@ jest.mock('../../utils/format', () => ({ // Simple mock implementation - returns 1 for short text, 2 for longer return text.length > 50 ? 2 : 1; }), + formatCents: jest.fn((dollars: number) => { + const cents = dollars * 100; + const roundedCents = Number(cents.toFixed(1)); + if (roundedCents === Math.floor(roundedCents)) { + return `${Math.floor(roundedCents)}Ā¢`; + } + return `${cents.toFixed(1)}Ā¢`; + }), })); jest.mock('../../hooks/usePredictMarket', () => ({ diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index dfab14ded83f..c65f1456d25c 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -28,9 +28,11 @@ import Text, { } from '../../../../../component-library/components/Texts/Text'; import Routes from '../../../../../constants/navigation/Routes'; import { useTheme } from '../../../../../util/theme'; +import { TraceName } from '../../../../../util/trace'; import { PredictNavigationParamList } from '../../types/navigation'; import { PredictEventValues } from '../../constants/eventNames'; import { formatVolume, estimateLineCount } from '../../utils/format'; +import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; import Engine from '../../../../../core/Engine'; import { PredictMarketDetailsSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; import { @@ -128,6 +130,17 @@ const PredictMarketDetails: React.FC = () => { enabled: Boolean(resolvedMarketId), }); + // Track screen load performance (market details + chart) + usePredictMeasurement({ + traceName: TraceName.PredictMarketDetailsView, + conditions: [!isMarketFetching, !!market, !isRefreshing], + debugContext: { + marketId: market?.id, + hasMarket: !!market, + loadingStates: { isMarketFetching, isRefreshing }, + }, + }); + // calculate sticky header indices based on content structure const stickyHeaderIndices = useMemo(() => { if (isMarketFetching && !market) { diff --git a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx index 25b01df6f4b1..9f8f33225aca 100644 --- a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx +++ b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx @@ -24,7 +24,7 @@ import { usePredictPlaceOrder } from '../../hooks/usePredictPlaceOrder'; import { Side } from '../../types'; import { PredictNavigationParamList } from '../../types/navigation'; import { - PredictEventType, + PredictTradeStatus, PredictEventValues, } from '../../constants/eventNames'; import { formatPercentage, formatPrice } from '../../utils/format'; @@ -38,6 +38,8 @@ import { ButtonSize as ButtonSizeHero, } from '@metamask/design-system-react-native'; import ButtonHero from '../../../../../component-library/components-temp/Buttons/ButtonHero'; +import { TraceName } from '../../../../../util/trace'; +import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; const PredictSellPreview = () => { const tw = useTailwind(); @@ -94,12 +96,23 @@ const PredictSellPreview = () => { autoRefreshTimeout: 1000, }); - // Track Predict Action Initiated when screen mounts + // Track screen load performance (position data + preview) + usePredictMeasurement({ + traceName: TraceName.PredictSellPreviewView, + conditions: [!!position, !!preview, !!market], + debugContext: { + marketId: market?.id, + hasPosition: !!position, + hasPreview: !!preview, + }, + }); + + // Track Predict Trade Transaction with initiated status when screen mounts useEffect(() => { const controller = Engine.context.PredictController; controller.trackPredictOrderEvent({ - eventType: PredictEventType.INITIATED, + status: PredictTradeStatus.INITIATED, analyticsProperties, providerId: position.providerId, sharePrice: position?.price, diff --git a/app/components/UI/Predict/views/PredictTabView/PredictTabView.tsx b/app/components/UI/Predict/views/PredictTabView/PredictTabView.tsx index b53fa28bfc7e..d3a4a2238221 100644 --- a/app/components/UI/Predict/views/PredictTabView/PredictTabView.tsx +++ b/app/components/UI/Predict/views/PredictTabView/PredictTabView.tsx @@ -16,6 +16,8 @@ import { PredictTabViewSelectorsIDs } from '../../../../../../e2e/selectors/Pred import { usePredictWithdrawToasts } from '../../hooks/usePredictWithdrawToasts'; import { selectHomepageRedesignV1Enabled } from '../../../../../selectors/featureFlagController/homepage'; import ConditionalScrollView from '../../../../../component-library/components-temp/ConditionalScrollView'; +import { TraceName } from '../../../../../util/trace'; +import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; interface PredictTabViewProps { isVisible?: boolean; @@ -40,6 +42,25 @@ const PredictTabView: React.FC = ({ isVisible }) => { const hasError = Boolean(positionsError || headerError); + // Track positions tab load performance + usePredictMeasurement({ + traceName: TraceName.PredictTabView, + conditions: [ + !positionsError, + !headerError, + !isRefreshing, + isVisible === true, + ], + debugContext: { + hasErrors: !!(positionsError || headerError), + errorStates: { + positionsError: !!positionsError, + headerError: !!headerError, + }, + isRefreshing, + }, + }); + const handleRefresh = useCallback(async () => { setIsRefreshing(true); // Clear errors before refreshing diff --git a/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx b/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx index dc73ee57bc04..54ea1fd80de4 100644 --- a/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx +++ b/app/components/UI/Predict/views/PredictTransactionsView/PredictTransactionsView.tsx @@ -9,6 +9,8 @@ import { formatCents } from '../../utils/format'; import { strings } from '../../../../../../locales/i18n'; import Engine from '../../../../../core/Engine'; import { PredictEventValues } from '../../constants/eventNames'; +import { TraceName } from '../../../../../util/trace'; +import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; interface PredictTransactionsViewProps { transactions?: unknown[]; @@ -62,6 +64,17 @@ const PredictTransactionsView: React.FC = ({ const tw = useTailwind(); const { activity, isLoading } = usePredictActivity({}); + // Track screen load performance (activity data loaded) + usePredictMeasurement({ + traceName: TraceName.PredictTransactionHistoryView, + conditions: [!isLoading, activity !== undefined, isVisible === true], + debugContext: { + activityCount: activity?.length, + hasActivity: !!activity, + isLoading, + }, + }); + // Track activity list viewed when tab becomes visible useEffect(() => { if (isVisible && !isLoading) { diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.test.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.test.tsx index c5384a4106f7..cdb4b6ab1759 100644 --- a/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.test.tsx @@ -8,6 +8,7 @@ import { getEventDetails } from '../../../utils/eventDetailsUtils'; import { IconName } from '@metamask/design-system-react-native'; import TEST_ADDRESS from '../../../../../../constants/address'; import { useActivityDetailsConfirmAction } from '../../../hooks/useActivityDetailsConfirmAction'; +import { REWARDS_VIEW_SELECTORS } from '../../../Views/RewardsView.constants'; // Mock the utility functions jest.mock('../../../utils/formatUtils', () => ({ @@ -15,6 +16,25 @@ jest.mock('../../../utils/formatUtils', () => ({ formatNumber: jest .fn() .mockImplementation((value) => value?.toString() || '0'), + formatRewardsMusdDepositPayloadDate: jest.fn( + (isoDate: string | undefined) => { + // Mock implementation that matches the real implementation behavior + if ( + !isoDate || + typeof isoDate !== 'string' || + !/^\d{4}-\d{2}-\d{2}$/.test(isoDate) + ) { + return null; + } + const date = new Date(`${isoDate}T00:00:00Z`); + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }).format(date); + }, + ), })); jest.mock('../../../utils/eventDetailsUtils', () => ({ @@ -194,6 +214,34 @@ describe('ActivityEventRow', () => { ...overrides, } as PointsEventDto; + case 'PREDICT': + return { + id: 'predict-event-1', + timestamp: new Date('2025-09-15T10:30:00.000Z'), + type: 'PREDICT' as const, + value: 20, + bonus: null, + accountAddress: '0x069060A475c76C77427CcC8CbD7eCB0B293f5beD', + payload: null, + updatedAt: new Date('2025-09-15T10:30:00.000Z'), + ...overrides, + } as PointsEventDto; + + case 'MUSD_DEPOSIT': + return { + id: 'musd-deposit-event-1', + timestamp: new Date('2025-11-11T10:30:00.000Z'), + type: 'MUSD_DEPOSIT' as const, + value: 10, + bonus: null, + accountAddress: '0x069060A475c76C77427CcC8CbD7eCB0B293f5beD', + payload: { + date: '2025-11-11', + }, + updatedAt: new Date('2025-11-11T10:30:00.000Z'), + ...overrides, + } as PointsEventDto; + default: throw new Error(`Unsupported event type: ${eventType}`); } @@ -516,6 +564,30 @@ describe('ActivityEventRow', () => { expect(getByText('+50%')).toBeOnTheScreen(); expect(mockGetEventDetails).toHaveBeenCalledWith(event, TEST_ADDRESS); }); + + it('renders PREDICT event without description', () => { + // Arrange + const event = createMockEvent({ type: 'PREDICT' }); + mockGetEventDetails.mockReturnValue({ + title: 'Predict', + details: undefined, + icon: IconName.Speedometer, + }); + + // Act + const { getByText, getByTestId } = render( + , + ); + + // Assert + expect(getByText('Predict')).toBeOnTheScreen(); + expect(getByText('+20')).toBeOnTheScreen(); + const detailsElement = getByTestId( + `${REWARDS_VIEW_SELECTORS.ACTIVITY_EVENT_ROW_DETAILS}-${undefined}`, + ); + expect(detailsElement.props.children).toBeUndefined(); + expect(detailsElement).toHaveTextContent(''); + }); }); describe('edge cases', () => { diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.test.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.test.tsx index 408a92ca23a1..c1c2f3b28dc4 100644 --- a/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.test.tsx @@ -37,6 +37,7 @@ jest.mock('../../../../../../../../locales/i18n', () => ({ 'rewards.events.points_base': 'Base', 'rewards.events.points_boost': 'Boost', 'rewards.events.points_total': 'Total', + 'rewards.events.for_deposit_period': 'For deposit period', }; return t[key] || key; }), @@ -46,6 +47,25 @@ jest.mock('../../../../../../../../locales/i18n', () => ({ jest.mock('../../../../utils/formatUtils', () => ({ formatRewardsDate: jest.fn(() => 'Sep 9, 2025'), formatNumber: jest.fn((n: number) => n.toString()), + formatRewardsMusdDepositPayloadDate: jest.fn( + (isoDate: string | undefined) => { + // Mock implementation that matches the real implementation behavior + if ( + !isoDate || + typeof isoDate !== 'string' || + !/^\d{4}-\d{2}-\d{2}$/.test(isoDate) + ) { + return null; + } + const date = new Date(`${isoDate}T00:00:00Z`); + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }).format(date); + }, + ), })); // Mock eventDetailsUtils @@ -140,6 +160,29 @@ describe('ActivityDetailsSheet', () => { expect(screen.getByText('43.25 USDC')).toBeTruthy(); }); + it('renders MusdDepositEventDetails for MUSD_DEPOSIT event type', () => { + const musdDepositEvent: Extract< + PointsEventDto, + { type: 'MUSD_DEPOSIT' } + > = { + ...baseEvent, + type: 'MUSD_DEPOSIT', + payload: { + date: '2025-11-11', + }, + }; + + render( + , + ); + + // Verify GenericEventDetails content is rendered (base component) + expect(screen.getByText('Details')).toBeTruthy(); + // Verify MusdDepositEventDetails specific content + expect(screen.getByText('For deposit period')).toBeTruthy(); + expect(screen.getByText('Nov 11, 2025')).toBeTruthy(); + }); + it('renders GenericEventDetails for other event types', () => { const genericEvent: PointsEventDto = { ...baseEvent, diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.tsx index 55397c3e4da1..0cc13a2e70bc 100644 --- a/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.tsx +++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/ActivityDetailsSheet.tsx @@ -8,6 +8,7 @@ import { getEventDetails } from '../../../../utils/eventDetailsUtils'; import { GenericEventDetails } from './GenericEventDetails'; import { SwapEventDetails } from './SwapEventDetails'; import { CardEventDetails } from './CardEventDetails'; +import { MusdDepositEventDetails } from './MusdDepositEventDetails'; import { PointsEventDto } from '../../../../../../../core/Engine/controllers/rewards-controller/types'; interface ActivityDetailsSheetProps { @@ -26,6 +27,10 @@ export const ActivityDetailsSheet: React.FC = ({ return ; case 'CARD': return ; + case 'MUSD_DEPOSIT': + return ( + + ); default: return ; } diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/MusdDepositEventDetails.test.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/MusdDepositEventDetails.test.tsx new file mode 100644 index 000000000000..2eba0b44db0e --- /dev/null +++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/MusdDepositEventDetails.test.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { MusdDepositEventDetails } from './MusdDepositEventDetails'; +import { AvatarAccountType } from '../../../../../../../component-library/components/Avatars/Avatar'; +import TEST_ADDRESS from '../../../../../../../constants/address'; +import { PointsEventDto } from '../../../../../../../core/Engine/controllers/rewards-controller/types'; +import { formatRewardsMusdDepositPayloadDate } from '../../../../utils/formatUtils'; + +// Mock react-redux +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); +const mockUseSelector = useSelector as jest.MockedFunction; + +// Mock i18n strings used by GenericEventDetails and MusdDepositEventDetails +jest.mock('../../../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => { + const t: Record = { + 'rewards.events.details': 'Details', + 'rewards.events.date': 'Date', + 'rewards.events.account': 'Account', + 'rewards.events.points': 'Points', + 'rewards.events.points_base': 'Base', + 'rewards.events.points_boost': 'Boost', + 'rewards.events.points_total': 'Total', + 'rewards.events.for_deposit_period': 'For deposit period', + }; + return t[key] || key; + }), +})); + +// Mock format utils used by GenericEventDetails and MusdDepositEventDetails +jest.mock('../../../../utils/formatUtils', () => ({ + formatRewardsDate: jest.fn(() => 'Sep 9, 2025'), + formatNumber: jest.fn((n: number) => n.toString()), + formatRewardsMusdDepositPayloadDate: jest.fn( + (isoDate: string | undefined) => { + // Mock implementation that matches the real implementation behavior + if ( + !isoDate || + typeof isoDate !== 'string' || + !/^\d{4}-\d{2}-\d{2}$/.test(isoDate) + ) { + return null; + } + const date = new Date(`${isoDate}T00:00:00Z`); + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }).format(date); + }, + ), +})); + +// Mock SVG used in the component to avoid native rendering issues +jest.mock( + '../../../../../../../images/rewards/metamask-rewards-points.svg', + () => 'SvgMock', +); + +describe('MusdDepositEventDetails', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockReturnValue(AvatarAccountType.JazzIcon); + }); + + const baseMusdDepositEvent: Extract< + PointsEventDto, + { type: 'MUSD_DEPOSIT' } + > = { + id: 'musd-deposit-1', + timestamp: new Date('2025-09-09T09:09:33.000Z'), + type: 'MUSD_DEPOSIT', + value: 100, + bonus: null, + accountAddress: TEST_ADDRESS, + updatedAt: new Date('2025-09-09T09:09:33.000Z'), + payload: { + date: '2025-11-11', + }, + }; + + it('renders deposit period row when payload.date exists', () => { + render( + , + ); + + // Verify GenericEventDetails header is rendered + expect(screen.getByText('Details')).toBeTruthy(); + + // Verify deposit period label and formatted date are displayed + expect(screen.getByText('For deposit period')).toBeTruthy(); + expect(screen.getByText('Nov 11, 2025')).toBeTruthy(); + }); + + it('does not render deposit period row when payload is null', () => { + const eventWithoutPayload: Extract< + PointsEventDto, + { type: 'MUSD_DEPOSIT' } + > = { + ...baseMusdDepositEvent, + payload: null, + }; + + render( + , + ); + + // Verify GenericEventDetails content is rendered + expect(screen.getByText('Details')).toBeTruthy(); + + // Verify deposit period row is not displayed when payload is null + expect(screen.queryByText('For deposit period')).toBeNull(); + }); + + it('renders base points and total correctly', () => { + const eventWithBonus: Extract = { + ...baseMusdDepositEvent, + value: 500, + bonus: { bonusPoints: 100, bips: 0, bonuses: [] }, + }; + + render( + , + ); + + // Verify points section from GenericEventDetails + expect(screen.getByText('Points')).toBeTruthy(); + + // Base = value - bonus = 500 - 100 = 400 + expect(screen.getByText('Base')).toBeTruthy(); + expect(screen.getByText('400')).toBeTruthy(); + + // Boost + expect(screen.getByText('Boost')).toBeTruthy(); + expect(screen.getByText('100')).toBeTruthy(); + + // Total + expect(screen.getByText('Total')).toBeTruthy(); + expect(screen.getByText('500')).toBeTruthy(); + }); + + it('displays account name when provided', () => { + render( + , + ); + + // Verify account name is displayed + expect(screen.getByText('Account')).toBeTruthy(); + expect(screen.getByText('Deposit Account')).toBeTruthy(); + }); + + it('calls formatRewardsMusdDepositPayloadDate with correct ISO date string', () => { + const mockFormatRewardsMusdDepositPayloadDate = + formatRewardsMusdDepositPayloadDate as jest.MockedFunction< + typeof formatRewardsMusdDepositPayloadDate + >; + mockFormatRewardsMusdDepositPayloadDate.mockReturnValue('Dec 25, 2025'); + + const eventWithDate: Extract = { + ...baseMusdDepositEvent, + payload: { + date: '2025-12-25', + }, + }; + + render( + , + ); + + // Verify the formatter was called with the correct ISO date string + expect(mockFormatRewardsMusdDepositPayloadDate).toHaveBeenCalledWith( + '2025-12-25', + ); + + // Verify the formatted date is displayed + expect(screen.getByText('Dec 25, 2025')).toBeTruthy(); + }); +}); diff --git a/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/MusdDepositEventDetails.tsx b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/MusdDepositEventDetails.tsx new file mode 100644 index 000000000000..151e17725bcf --- /dev/null +++ b/app/components/UI/Rewards/components/Tabs/ActivityTab/EventDetails/MusdDepositEventDetails.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { + Text, + TextVariant, + TextColor, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../../../locales/i18n'; +import { GenericEventDetails, DetailsRow } from './GenericEventDetails'; +import { + PointsEventDto, + MusdDepositEventPayload, +} from '../../../../../../../core/Engine/controllers/rewards-controller/types'; +import { formatRewardsMusdDepositPayloadDate } from '../../../../utils/formatUtils'; + +interface MusdDepositEventDetailsProps { + event: PointsEventDto & { + type: 'MUSD_DEPOSIT'; + payload: MusdDepositEventPayload | null; + }; + accountName?: string; +} + +export const MusdDepositEventDetails: React.FC< + MusdDepositEventDetailsProps +> = ({ event, accountName }) => { + const payload = event.payload; + + const formattedDate = formatRewardsMusdDepositPayloadDate(payload?.date); + const extraDetails = formattedDate ? ( + + + {formattedDate} + + + ) : null; + + return ( + + ); +}; diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx index 2bf6f21315c5..1629da43ecf4 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx @@ -1,12 +1,16 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; +import { Linking } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { WaysToEarn, WayToEarnType } from './WaysToEarn'; import Routes from '../../../../../../../constants/navigation/Routes'; import { ModalType } from '../../../../components/RewardsBottomSheetModal'; import { SwapBridgeNavigationLocation } from '../../../../../Bridge/hooks/useSwapBridgeNavigation'; import { selectIsFirstTimePerpsUser } from '../../../../../Perps/selectors/perpsController'; -import { selectRewardsCardSpendFeatureFlags } from '../../../../../../../selectors/featureFlagController/rewards'; +import { + selectRewardsCardSpendFeatureFlags, + selectRewardsMusdDepositEnabledFlag, +} from '../../../../../../../selectors/featureFlagController/rewards'; import { selectPredictEnabledFlag } from '../../../../../Predict/selectors/featureFlags'; import { MetaMetricsEvents } from '../../../../../../hooks/useMetrics'; import { RewardsMetricsButtons } from '../../../../utils'; @@ -20,6 +24,7 @@ const mockCreateEventBuilder = jest.fn(); let mockIsFirstTimePerpsUser = false; let mockIsCardSpendEnabled = false; let mockIsPredictEnabled = false; +let mockIsMusdDepositEnabled = false; jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), @@ -113,6 +118,15 @@ jest.mock('../../../../../../../../locales/i18n', () => ({ 'rewards.ways_to_earn.card.sheet.description': 'Earn points every time you use your MetaMask Card for purchases, plus 1% cash back (3% for Metal cardholders).', 'rewards.ways_to_earn.card.sheet.cta_label': 'Manage Card', + // Deposit MUSD strings + 'rewards.ways_to_earn.deposit_musd.title': 'Deposit mUSD', + 'rewards.ways_to_earn.deposit_musd.description': + '2 points per $100 deposited', + 'rewards.ways_to_earn.deposit_musd.sheet.title': 'Deposit mUSD', + 'rewards.ways_to_earn.deposit_musd.sheet.points': '2 points per $100', + 'rewards.ways_to_earn.deposit_musd.sheet.description': + 'Earn points on every $100 mUSD you deposit.', + 'rewards.ways_to_earn.deposit_musd.sheet.cta_label': 'Deposit mUSD', }; return mockStrings[key] || key; }), @@ -161,11 +175,15 @@ jest.mock( ); describe('WaysToEarn', () => { + let openURLSpy: jest.SpyInstance; + beforeEach(() => { jest.clearAllMocks(); + openURLSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); mockIsFirstTimePerpsUser = false; mockIsCardSpendEnabled = false; mockIsPredictEnabled = false; + mockIsMusdDepositEnabled = false; mockUseNavigation.mockReturnValue({ navigate: mockNavigate, @@ -192,6 +210,9 @@ describe('WaysToEarn', () => { if (selector === selectPredictEnabledFlag) { return mockIsPredictEnabled; } + if (selector === selectRewardsMusdDepositEnabledFlag) { + return mockIsMusdDepositEnabled; + } return undefined; }); }); @@ -217,6 +238,8 @@ describe('WaysToEarn', () => { expect(queryByText('Prediction markets')).not.toBeOnTheScreen(); // MM Card Spend hidden when flag disabled expect(queryByText('MetaMask Card')).not.toBeOnTheScreen(); + // Deposit mUSD hidden when flag disabled + expect(queryByText('Deposit mUSD')).not.toBeOnTheScreen(); }); it('displays correct descriptions for each earning way', () => { @@ -230,6 +253,7 @@ describe('WaysToEarn', () => { expect(getByText('Earn points from past trades')).toBeOnTheScreen(); expect(queryByText('20 points per $10 prediction')).not.toBeOnTheScreen(); expect(queryByText('1 point per $1 spent')).not.toBeOnTheScreen(); + expect(queryByText('Earn points on deposits')).not.toBeOnTheScreen(); }); it('opens referral bottom sheet modal when referral item is pressed', () => { @@ -465,6 +489,7 @@ describe('WaysToEarn', () => { expect(WayToEarnType.LOYALTY).toBe('loyalty'); expect(WayToEarnType.PREDICT).toBe('predict'); expect(WayToEarnType.CARD).toBe('card'); + expect(WayToEarnType.DEPOSIT_MUSD).toBe('deposit_musd'); }); }); @@ -583,6 +608,81 @@ describe('WaysToEarn', () => { }); }); + describe('Deposit mUSD', () => { + it('shows Deposit mUSD earning way only when feature flag is enabled', () => { + // Arrange + const { queryByText, rerender } = render(); + + // Assert hidden by default + expect(queryByText('Deposit mUSD')).not.toBeOnTheScreen(); + + // Enable flag + mockIsMusdDepositEnabled = true; + rerender(); + + // Assert visible now + expect(queryByText('Deposit mUSD')).toBeOnTheScreen(); + expect(queryByText('2 points per $100 deposited')).toBeOnTheScreen(); + }); + + it('opens modal for deposit mUSD earning way when pressed', () => { + // Arrange + mockIsMusdDepositEnabled = true; + const { getByText } = render(); + const depositMusdButton = getByText('Deposit mUSD'); + + // Act + fireEvent.press(depositMusdButton); + + // Assert + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MODAL.REWARDS_BOTTOM_SHEET_MODAL, + expect.objectContaining({ + type: ModalType.Confirmation, + showIcon: false, + showCancelButton: false, + confirmAction: expect.objectContaining({ + label: 'Deposit mUSD', + variant: 'Primary', + }), + }), + ); + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.REWARDS_PAGE_BUTTON_CLICKED, + ); + }); + + it('opens URL when deposit mUSD CTA is pressed', () => { + // Arrange + mockIsMusdDepositEnabled = true; + const { getByText } = render(); + const depositMusdButton = getByText('Deposit mUSD'); + + // Act + fireEvent.press(depositMusdButton); + + // Get the onPress handler from the modal navigation call + const modalCall = mockNavigate.mock.calls.find( + (call) => call[0] === Routes.MODAL.REWARDS_BOTTOM_SHEET_MODAL, + ); + const confirmAction = modalCall?.[1]?.confirmAction; + + // Execute the CTA action + confirmAction?.onPress(); + + // Assert + expect(mockGoBack).toHaveBeenCalled(); + expect(openURLSpy).toHaveBeenCalledWith( + 'https://go.metamask.io/turtle-musd', + ); + expect(mockTrackEvent).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.REWARDS_WAYS_TO_EARN_CTA_CLICKED, + ); + }); + }); + describe('useSwapBridgeNavigation integration', () => { it('configures the hook with correct parameters', () => { // Import the actual hook module to verify mock calls diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx index d842076c0aa3..30522c47d490 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { FlatList } from 'react-native'; +import { FlatList, Linking } from 'react-native'; import { Box, Text, @@ -27,7 +27,10 @@ import { } from '../../../../../Bridge/hooks/useSwapBridgeNavigation'; import { useSelector } from 'react-redux'; import { selectIsFirstTimePerpsUser } from '../../../../../Perps/selectors/perpsController'; -import { selectRewardsCardSpendFeatureFlags } from '../../../../../../../selectors/featureFlagController/rewards'; +import { + selectRewardsCardSpendFeatureFlags, + selectRewardsMusdDepositEnabledFlag, +} from '../../../../../../../selectors/featureFlagController/rewards'; import { selectPredictEnabledFlag } from '../../../../../Predict/selectors/featureFlags'; import { MetaMetricsEvents, @@ -42,6 +45,7 @@ export enum WayToEarnType { LOYALTY = 'loyalty', PREDICT = 'predict', CARD = 'card', + DEPOSIT_MUSD = 'deposit_musd', } interface WayToEarn { @@ -88,6 +92,12 @@ const waysToEarn: WayToEarn[] = [ description: strings('rewards.ways_to_earn.card.description'), icon: IconName.Card, }, + { + type: WayToEarnType.DEPOSIT_MUSD, + title: strings('rewards.ways_to_earn.deposit_musd.title'), + description: strings('rewards.ways_to_earn.deposit_musd.description'), + icon: IconName.Coin, + }, ]; const Separator = () => ; @@ -196,6 +206,21 @@ const getBottomSheetData = (type: WayToEarnType) => { ), ctaLabel: strings('rewards.ways_to_earn.card.sheet.cta_label'), }; + case WayToEarnType.DEPOSIT_MUSD: + return { + title: ( + + ), + description: ( + + {strings('rewards.ways_to_earn.deposit_musd.sheet.description')} + + ), + ctaLabel: strings('rewards.ways_to_earn.deposit_musd.sheet.cta_label'), + }; default: throw new Error(`Unknown earning way type: ${type}`); } @@ -206,6 +231,7 @@ export const WaysToEarn = () => { const isFirstTimePerpsUser = useSelector(selectIsFirstTimePerpsUser); const isCardSpendEnabled = useSelector(selectRewardsCardSpendFeatureFlags); const isPredictEnabled = useSelector(selectPredictEnabledFlag); + const isMusdDepositEnabled = useSelector(selectRewardsMusdDepositEnabledFlag); const { trackEvent, createEventBuilder } = useMetrics(); // Use the swap/bridge navigation hook @@ -251,6 +277,9 @@ export const WaysToEarn = () => { case WayToEarnType.CARD: navigation.navigate(Routes.CARD.ROOT); break; + case WayToEarnType.DEPOSIT_MUSD: + Linking.openURL('https://go.metamask.io/turtle-musd'); + break; } }; @@ -268,7 +297,8 @@ export const WaysToEarn = () => { case WayToEarnType.LOYALTY: case WayToEarnType.PERPS: case WayToEarnType.PREDICT: - case WayToEarnType.CARD: { + case WayToEarnType.CARD: + case WayToEarnType.DEPOSIT_MUSD: { const { title, description, ctaLabel } = getBottomSheetData( wayToEarn.type, ); @@ -311,6 +341,12 @@ export const WaysToEarn = () => { if (wte.type === WayToEarnType.PREDICT && !isPredictEnabled) { return false; } + if ( + wte.type === WayToEarnType.DEPOSIT_MUSD && + !isMusdDepositEnabled + ) { + return false; + } return true; })} keyExtractor={(wayToEarn) => wayToEarn.title} diff --git a/app/components/UI/Rewards/utils/eventDetailsUtils.test.ts b/app/components/UI/Rewards/utils/eventDetailsUtils.test.ts index bffac2018d76..3b76d5f29b3c 100644 --- a/app/components/UI/Rewards/utils/eventDetailsUtils.test.ts +++ b/app/components/UI/Rewards/utils/eventDetailsUtils.test.ts @@ -16,7 +16,7 @@ import { // Mock i18n strings jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => { + strings: jest.fn((key: string, params?: Record) => { const t: Record = { 'rewards.events.to': 'to', 'rewards.events.type.swap': 'Swap', @@ -29,20 +29,47 @@ jest.mock('../../../../../locales/i18n', () => ({ 'rewards.events.type.close_position': 'Closed position', 'rewards.events.type.take_profit': 'Take profit', 'rewards.events.type.stop_loss': 'Stop loss', + 'rewards.events.type.predict': 'Prediction', + 'rewards.events.type.musd_deposit': 'mUSD deposit', + 'rewards.events.musd_deposit_for': 'For {{date}}', 'rewards.events.type.uncategorized_event': 'Uncategorized event', 'perps.market.long': 'Long', 'perps.market.short': 'Short', }; - return t[key] || key; + const template = t[key] || key; + if (params && template.includes('{{date}}')) { + return template.replace('{{date}}', params.date || ''); + } + return template; }), default: { locale: 'en-US', }, })); -// Mock formatNumber utility +// Mock formatUtils jest.mock('./formatUtils', () => ({ formatNumber: jest.fn((value: number) => value.toString()), + formatRewardsMusdDepositPayloadDate: jest.fn( + (isoDate: string | undefined) => { + // Mock implementation that matches the real implementation behavior + if ( + !isoDate || + typeof isoDate !== 'string' || + !/^\d{4}-\d{2}-\d{2}$/.test(isoDate) + ) { + return null; + } + // Mock implementation that formats the date + const date = new Date(`${isoDate}T00:00:00Z`); + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }).format(date); + }, + ), })); describe('eventDetailsUtils', () => { @@ -479,6 +506,20 @@ describe('eventDetailsUtils', () => { type: 'CARD' as const, payload: payload as (PointsEventDto & { type: 'CARD' })['payload'], }; + case 'PREDICT': + return { + ...baseEvent, + type: 'PREDICT' as const, + payload: null, + }; + case 'MUSD_DEPOSIT': + return { + ...baseEvent, + type: 'MUSD_DEPOSIT' as const, + payload: payload as (PointsEventDto & { + type: 'MUSD_DEPOSIT'; + })['payload'], + }; default: return { ...baseEvent, @@ -841,6 +882,175 @@ describe('eventDetailsUtils', () => { }); }); + describe('PREDICT events', () => { + it('returns correct details for PREDICT event', () => { + const event = createMockEvent('PREDICT'); + + const result = getEventDetails(event, TEST_ADDRESS); + + expect(result).toEqual({ + title: 'Prediction', + details: undefined, + icon: IconName.Speedometer, + }); + }); + }); + + describe('MUSD_DEPOSIT events', () => { + it('returns correct details for MUSD_DEPOSIT event with date', () => { + // Given a MUSD_DEPOSIT event with a date + const event = createMockEvent('MUSD_DEPOSIT', { + date: '2025-01-15', + }); + + // When getting event details + const result = getEventDetails(event, TEST_ADDRESS); + + // Then it should return mUSD deposit details with formatted date + expect(result).toEqual({ + title: 'mUSD deposit', + details: 'For Jan 15, 2025', + icon: IconName.Coin, + }); + }); + + it('returns correct details for MUSD_DEPOSIT event with different date format', () => { + // Given a MUSD_DEPOSIT event with a different date + const event = createMockEvent('MUSD_DEPOSIT', { + date: '2025-11-11', + }); + + // When getting event details + const result = getEventDetails(event, TEST_ADDRESS); + + // Then it should return mUSD deposit details with formatted date + expect(result).toEqual({ + title: 'mUSD deposit', + details: 'For Nov 11, 2025', + icon: IconName.Coin, + }); + }); + + it('returns undefined details for MUSD_DEPOSIT event without payload', () => { + // Given a MUSD_DEPOSIT event without payload + const event = createMockEvent('MUSD_DEPOSIT', null); + + // When getting event details + const result = getEventDetails(event, TEST_ADDRESS); + + // Then it should return mUSD deposit title with undefined details + expect(result).toEqual({ + title: 'mUSD deposit', + details: undefined, + icon: IconName.Coin, + }); + }); + + it('returns undefined details for MUSD_DEPOSIT event with payload but no date', () => { + // Given a MUSD_DEPOSIT event with payload but no date + const event = createMockEvent('MUSD_DEPOSIT', { + // @ts-expect-error - We are testing the function with undefined date + date: undefined, + }); + + // When getting event details + const result = getEventDetails(event, TEST_ADDRESS); + + // Then it should return mUSD deposit title with undefined details + expect(result).toEqual({ + title: 'mUSD deposit', + details: undefined, + icon: IconName.Coin, + }); + }); + + it('returns undefined details for MUSD_DEPOSIT event with date that is not a string', () => { + // Given a MUSD_DEPOSIT event with date that is not a string + const event = createMockEvent('MUSD_DEPOSIT', { + // @ts-expect-error - We are testing the function with non-string date + date: 20250115, + }); + + // When getting event details + const result = getEventDetails(event, TEST_ADDRESS); + + // Then it should return mUSD deposit title with undefined details + expect(result).toEqual({ + title: 'mUSD deposit', + details: undefined, + icon: IconName.Coin, + }); + }); + + it('returns undefined details for MUSD_DEPOSIT event with date that does not match YYYY-MM-DD format', () => { + // Given a MUSD_DEPOSIT event with date in wrong format + const event = createMockEvent('MUSD_DEPOSIT', { + date: '2025-1-15', // Missing leading zero in month + }); + + // When getting event details + const result = getEventDetails(event, TEST_ADDRESS); + + // Then it should return mUSD deposit title with undefined details + expect(result).toEqual({ + title: 'mUSD deposit', + details: undefined, + icon: IconName.Coin, + }); + }); + + it('returns undefined details for MUSD_DEPOSIT event with date in ISO format with time', () => { + // Given a MUSD_DEPOSIT event with date in ISO format with time + const event = createMockEvent('MUSD_DEPOSIT', { + date: '2025-01-15T00:00:00Z', // ISO format with time + }); + + // When getting event details + const result = getEventDetails(event, TEST_ADDRESS); + + // Then it should return mUSD deposit title with undefined details + expect(result).toEqual({ + title: 'mUSD deposit', + details: undefined, + icon: IconName.Coin, + }); + }); + + it('returns undefined details for MUSD_DEPOSIT event with invalid date string', () => { + // Given a MUSD_DEPOSIT event with invalid date string + const event = createMockEvent('MUSD_DEPOSIT', { + date: 'invalid-date', + }); + + // When getting event details + const result = getEventDetails(event, TEST_ADDRESS); + + // Then it should return mUSD deposit title with undefined details + expect(result).toEqual({ + title: 'mUSD deposit', + details: undefined, + icon: IconName.Coin, + }); + }); + + it('returns undefined details for MUSD_DEPOSIT event with empty date string', () => { + // Given a MUSD_DEPOSIT event with empty date string + const event = createMockEvent('MUSD_DEPOSIT', { + date: '', + }); + + // When getting event details + const result = getEventDetails(event, TEST_ADDRESS); + + // Then it should return mUSD deposit title with undefined details + expect(result).toEqual({ + title: 'mUSD deposit', + details: undefined, + icon: IconName.Coin, + }); + }); + }); + describe('unknown event types', () => { it('returns uncategorized event details for unknown type', () => { const event = createMockEvent('UNKNOWN_TYPE' as PointsEventDto['type']); diff --git a/app/components/UI/Rewards/utils/eventDetailsUtils.ts b/app/components/UI/Rewards/utils/eventDetailsUtils.ts index 4f63062b3af8..8e9e5c27b492 100644 --- a/app/components/UI/Rewards/utils/eventDetailsUtils.ts +++ b/app/components/UI/Rewards/utils/eventDetailsUtils.ts @@ -11,6 +11,7 @@ import { isNullOrUndefined } from '@metamask/utils'; import { formatUnits } from 'viem'; import { formatWithThreshold } from '../../../../util/assets'; import { PerpsEventType } from './eventConstants'; +import { formatRewardsMusdDepositPayloadDate } from './formatUtils'; /** * Formats an asset amount with proper decimals @@ -212,6 +213,26 @@ export const getEventDetails = ( details: undefined, icon: IconName.Gift, }; + case 'PREDICT': + return { + title: strings('rewards.events.type.predict'), + details: undefined, + icon: IconName.Speedometer, + }; + case 'MUSD_DEPOSIT': { + const formattedDate = formatRewardsMusdDepositPayloadDate( + event.payload?.date, + ); + return { + title: strings('rewards.events.type.musd_deposit'), + details: formattedDate + ? strings('rewards.events.musd_deposit_for', { + date: formattedDate, + }) + : undefined, + icon: IconName.Coin, + }; + } default: return { title: strings('rewards.events.type.uncategorized_event'), diff --git a/app/components/UI/Rewards/utils/formatUtils.test.ts b/app/components/UI/Rewards/utils/formatUtils.test.ts index 24b2194f7430..69249bb33a96 100644 --- a/app/components/UI/Rewards/utils/formatUtils.test.ts +++ b/app/components/UI/Rewards/utils/formatUtils.test.ts @@ -8,6 +8,8 @@ import { formatNumber, getIconName, formatUrl, + formatUTCDate, + formatRewardsMusdDepositPayloadDate, } from './formatUtils'; import { IconName } from '@metamask/design-system-react-native'; import { getTimeDifferenceFromNow } from '../../../../util/date'; @@ -750,4 +752,380 @@ describe('formatUtils', () => { }); }); }); + + describe('formatUTCDate', () => { + it('formats ISO date string in default locale (en-US)', () => { + // Given an ISO date string + const isoDate = '2025-11-11'; + + // When formatting the date + const result = formatUTCDate(isoDate); + + // Then it should return formatted date in en-US format + expect(result).toMatch(/11\/11\/2025|11\.11\.2025|Nov 11, 2025/); + }); + + it('formats ISO date string with custom locale', () => { + // Given an ISO date string and French locale + const isoDate = '2025-11-11'; + const locale = 'fr-FR'; + + // When formatting the date + const result = formatUTCDate(isoDate, locale); + + // Then it should return formatted date in French format + expect(result).toMatch(/11\/11\/2025|11\.11\.2025/); + }); + + it('formats ISO date string with custom options', () => { + // Given an ISO date string with custom formatting options + const isoDate = '2025-11-11'; + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + }; + + // When formatting the date + const result = formatUTCDate(isoDate, 'en-US', options); + + // Then it should return formatted date with long month name + expect(result).toBe('November 11, 2025'); + }); + + it('handles dates at year boundaries correctly', () => { + // Given dates at year boundaries + const newYear = '2025-01-01'; + const endYear = '2025-12-31'; + + // When formatting the dates + const newYearResult = formatUTCDate(newYear); + const endYearResult = formatUTCDate(endYear); + + // Then they should be formatted correctly + expect(newYearResult).toMatch(/1\/1\/2025|1\.1\.2025|Jan 1, 2025/); + expect(endYearResult).toMatch(/12\/31\/2025|31\.12\.2025|Dec 31, 2025/); + }); + + it('prevents timezone shifts by using UTC', () => { + // Given an ISO date string + const isoDate = '2025-11-11'; + + // When formatting the date + const result = formatUTCDate(isoDate, 'en-US'); + + // Then it should always show November 11 regardless of local timezone + // The date should be interpreted as midnight UTC + expect(result).toMatch(/11/); + }); + + it('handles leap year dates correctly', () => { + // Given a leap year date + const leapYearDate = '2024-02-29'; + + // When formatting the date + const result = formatUTCDate(leapYearDate); + + // Then it should format correctly + expect(result).toMatch(/29/); + }); + + it('uses default options when custom options are provided', () => { + // Given an ISO date string with partial custom options + const isoDate = '2025-11-11'; + const options: Intl.DateTimeFormatOptions = { + month: 'short', + }; + + // When formatting the date + const result = formatUTCDate(isoDate, 'en-US', options); + + // Then it should merge with default options (year, day, timeZone) + expect(result).toMatch(/Nov/); + expect(result).toMatch(/11/); + expect(result).toMatch(/2025/); + }); + + it('handles different locales correctly', () => { + // Given an ISO date string + const isoDate = '2025-11-11'; + + // When formatting with different locales + const enResult = formatUTCDate(isoDate, 'en-US'); + const deResult = formatUTCDate(isoDate, 'de-DE'); + const jaResult = formatUTCDate(isoDate, 'ja-JP'); + + // Then they should be formatted according to locale conventions + expect(enResult).toBeTruthy(); + expect(deResult).toBeTruthy(); + expect(jaResult).toBeTruthy(); + // All should contain the date components + expect(enResult).toMatch(/11/); + expect(deResult).toMatch(/11/); + expect(jaResult).toMatch(/11/); + }); + }); + + describe('formatRewardsMusdDepositPayloadDate', () => { + it('formats ISO date string for mUSD deposit with default locale', () => { + // Given an ISO date string + const isoDate = '2025-11-11'; + + // When formatting the date + const result = formatRewardsMusdDepositPayloadDate(isoDate); + + // Then it should return formatted date with short month name + expect(result).toMatch(/Nov 11, 2025|11 Nov 2025/); + }); + + it('formats ISO date string for mUSD deposit with custom locale', () => { + // Given an ISO date string and French locale + const isoDate = '2025-11-11'; + const locale = 'fr-FR'; + + // When formatting the date + const result = formatRewardsMusdDepositPayloadDate(isoDate, locale); + + // Then it should return formatted date in French format + expect(result).toMatch(/nov\.|nov/); + expect(result).toMatch(/11/); + expect(result).toMatch(/2025/); + }); + + it('formats date with correct format (year, short month, day)', () => { + // Given an ISO date string + const isoDate = '2025-12-25'; + + // When formatting the date + const result = formatRewardsMusdDepositPayloadDate(isoDate, 'en-US'); + + // Then it should have year, short month, and day + expect(result).toMatch(/Dec/); + expect(result).toMatch(/25/); + expect(result).toMatch(/2025/); + }); + + it('handles dates at month boundaries', () => { + // Given dates at month boundaries + const firstOfMonth = '2025-01-01'; + const lastOfMonth = '2025-01-31'; + + // When formatting the dates + const firstResult = formatRewardsMusdDepositPayloadDate(firstOfMonth); + const lastResult = formatRewardsMusdDepositPayloadDate(lastOfMonth); + + // Then they should be formatted correctly + expect(firstResult).toMatch(/Jan/); + expect(firstResult).toMatch(/1/); + expect(lastResult).toMatch(/Jan/); + expect(lastResult).toMatch(/31/); + }); + + it('prevents timezone shifts by using UTC', () => { + // Given an ISO date string + const isoDate = '2025-11-11'; + + // When formatting the date + const result = formatRewardsMusdDepositPayloadDate(isoDate); + + // Then it should always show the correct date regardless of local timezone + expect(result).toMatch(/Nov/); + expect(result).toMatch(/11/); + }); + + it('uses I18n.locale as default when no locale provided', () => { + // Given an ISO date string without locale + const isoDate = '2025-11-11'; + + // When formatting the date + const result = formatRewardsMusdDepositPayloadDate(isoDate); + + // Then it should use the default locale from I18n + expect(result).toBeTruthy(); + expect(result).toMatch(/11/); + }); + + it('returns null for undefined input', () => { + // Given undefined input + const isoDate = undefined; + + // When formatting the date + const result = formatRewardsMusdDepositPayloadDate(isoDate); + + // Then it should return null + expect(result).toBeNull(); + }); + + it('returns null for empty string', () => { + // Given an empty string + const isoDate = ''; + + // When formatting the date + const result = formatRewardsMusdDepositPayloadDate(isoDate); + + // Then it should return null + expect(result).toBeNull(); + }); + + it('returns null for non-string input', () => { + // Given non-string inputs + const numberInput = 20251111 as unknown as string; + const objectInput = { date: '2025-11-11' } as unknown as string; + const arrayInput = ['2025', '11', '11'] as unknown as string; + + // When formatting the dates + const numberResult = formatRewardsMusdDepositPayloadDate(numberInput); + const objectResult = formatRewardsMusdDepositPayloadDate(objectInput); + const arrayResult = formatRewardsMusdDepositPayloadDate(arrayInput); + + // Then they should all return null + expect(numberResult).toBeNull(); + expect(objectResult).toBeNull(); + expect(arrayResult).toBeNull(); + }); + + it('returns null for invalid date format - wrong separator', () => { + // Given date strings with wrong separators + const slashDate = '2025/11/11'; + const dotDate = '2025.11.11'; + const spaceDate = '2025 11 11'; + + // When formatting the dates + const slashResult = formatRewardsMusdDepositPayloadDate(slashDate); + const dotResult = formatRewardsMusdDepositPayloadDate(dotDate); + const spaceResult = formatRewardsMusdDepositPayloadDate(spaceDate); + + // Then they should all return null + expect(slashResult).toBeNull(); + expect(dotResult).toBeNull(); + expect(spaceResult).toBeNull(); + }); + + it('returns null for invalid date format - wrong length', () => { + // Given date strings with wrong length + const shortDate = '2025-11'; + const longDate = '2025-11-11-12'; + const noSeparators = '20251111'; + + // When formatting the dates + const shortResult = formatRewardsMusdDepositPayloadDate(shortDate); + const longResult = formatRewardsMusdDepositPayloadDate(longDate); + const noSeparatorsResult = + formatRewardsMusdDepositPayloadDate(noSeparators); + + // Then they should all return null + expect(shortResult).toBeNull(); + expect(longResult).toBeNull(); + expect(noSeparatorsResult).toBeNull(); + }); + + it('returns null for invalid date format - non-numeric characters', () => { + // Given date strings with non-numeric characters + const textDate = 'abcd-ef-gh'; + const mixedDate = '2025-1a-11'; + const lettersDate = 'YYYY-MM-DD'; + + // When formatting the dates + const textResult = formatRewardsMusdDepositPayloadDate(textDate); + const mixedResult = formatRewardsMusdDepositPayloadDate(mixedDate); + const lettersResult = formatRewardsMusdDepositPayloadDate(lettersDate); + + // Then they should all return null + expect(textResult).toBeNull(); + expect(mixedResult).toBeNull(); + expect(lettersResult).toBeNull(); + }); + + it('returns null for invalid date format - incomplete date parts', () => { + // Given date strings with incomplete parts + const oneDigitYear = '5-11-11'; + const oneDigitMonth = '2025-1-11'; + const oneDigitDay = '2025-11-1'; + const twoDigitYear = '25-11-11'; + + // When formatting the dates + const oneDigitYearResult = + formatRewardsMusdDepositPayloadDate(oneDigitYear); + const oneDigitMonthResult = + formatRewardsMusdDepositPayloadDate(oneDigitMonth); + const oneDigitDayResult = + formatRewardsMusdDepositPayloadDate(oneDigitDay); + const twoDigitYearResult = + formatRewardsMusdDepositPayloadDate(twoDigitYear); + + // Then they should all return null + expect(oneDigitYearResult).toBeNull(); + expect(oneDigitMonthResult).toBeNull(); + expect(oneDigitDayResult).toBeNull(); + expect(twoDigitYearResult).toBeNull(); + }); + + it('returns null for date with extra whitespace', () => { + // Given date strings with whitespace + const leadingSpace = ' 2025-11-11'; + const trailingSpace = '2025-11-11 '; + const bothSpaces = ' 2025-11-11 '; + + // When formatting the dates + const leadingResult = formatRewardsMusdDepositPayloadDate(leadingSpace); + const trailingResult = formatRewardsMusdDepositPayloadDate(trailingSpace); + const bothResult = formatRewardsMusdDepositPayloadDate(bothSpaces); + + // Then they should all return null + expect(leadingResult).toBeNull(); + expect(trailingResult).toBeNull(); + expect(bothResult).toBeNull(); + }); + + it('handles leap year dates correctly', () => { + // Given a leap year date + const leapYearDate = '2024-02-29'; + + // When formatting the date + const result = formatRewardsMusdDepositPayloadDate(leapYearDate); + + // Then it should format correctly + expect(result).toBeTruthy(); + expect(result).toMatch(/Feb/); + expect(result).toMatch(/29/); + expect(result).toMatch(/2024/); + }); + + it('handles dates at year boundaries correctly', () => { + // Given dates at year boundaries + const newYear = '2025-01-01'; + const endYear = '2025-12-31'; + + // When formatting the dates + const newYearResult = formatRewardsMusdDepositPayloadDate(newYear); + const endYearResult = formatRewardsMusdDepositPayloadDate(endYear); + + // Then they should be formatted correctly + expect(newYearResult).toBeTruthy(); + expect(newYearResult).toMatch(/Jan/); + expect(newYearResult).toMatch(/1/); + expect(endYearResult).toBeTruthy(); + expect(endYearResult).toMatch(/Dec/); + expect(endYearResult).toMatch(/31/); + }); + + it('handles different locales correctly', () => { + // Given an ISO date string + const isoDate = '2025-11-11'; + + // When formatting with different locales + const enResult = formatRewardsMusdDepositPayloadDate(isoDate, 'en-US'); + const deResult = formatRewardsMusdDepositPayloadDate(isoDate, 'de-DE'); + const jaResult = formatRewardsMusdDepositPayloadDate(isoDate, 'ja-JP'); + + // Then they should be formatted according to locale conventions + expect(enResult).toBeTruthy(); + expect(deResult).toBeTruthy(); + expect(jaResult).toBeTruthy(); + // All should contain the date components + expect(enResult).toMatch(/11/); + expect(deResult).toMatch(/11/); + expect(jaResult).toMatch(/11/); + }); + }); }); diff --git a/app/components/UI/Rewards/utils/formatUtils.ts b/app/components/UI/Rewards/utils/formatUtils.ts index 246e5f699899..67483af465ab 100644 --- a/app/components/UI/Rewards/utils/formatUtils.ts +++ b/app/components/UI/Rewards/utils/formatUtils.ts @@ -41,6 +41,58 @@ export const formatRewardsDate = ( minute: '2-digit', }).format(date); +/** + * Formats a "YYYY-MM-DD" date string into a localized format without timezone shifts. + * @param isoDate - The date string in "YYYY-MM-DD" format. + * @param locale - The locale to format for (e.g., 'en-US', 'fr-FR'). + * @param options - Optional Intl.DateTimeFormat options. + * @returns The localized date string. + */ +export const formatUTCDate = ( + isoDate: string, + locale: string = I18n.locale, + options: Intl.DateTimeFormatOptions = {}, +): string => { + // Create a date at midnight UTC + const date = new Date(`${isoDate}T00:00:00Z`); + + const defaultOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'numeric', + day: 'numeric', + timeZone: 'UTC', // Ensure UTC interpretation + }; + + const finalOptions = { ...defaultOptions, ...options }; + + return new Intl.DateTimeFormat(locale, finalOptions).format(date); +}; + +/** + * Formats a date for mUSD deposit payload + * @param isoDate - The date string in "YYYY-MM-DD" format + * @param locale - Optional locale string, defaults to I18n.locale + * @returns Formatted date string specifically for mUSD deposit payload, or null if invalid + */ +export const formatRewardsMusdDepositPayloadDate = ( + isoDate: string | undefined, + locale: string = I18n.locale, +): string | null => { + if ( + !isoDate || + typeof isoDate !== 'string' || + !/^\d{4}-\d{2}-\d{2}$/.test(isoDate) + ) { + return null; + } + + return formatUTCDate(isoDate, locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +}; + export const formatTimeRemaining = (endDate: Date): string | null => { const { days, hours, minutes } = getTimeDifferenceFromNow(endDate.getTime()); diff --git a/app/components/UI/Stake/components/GasImpactModal/GasImpactModal.test.tsx b/app/components/UI/Stake/components/GasImpactModal/GasImpactModal.test.tsx index 839b5a112154..277f706962f1 100644 --- a/app/components/UI/Stake/components/GasImpactModal/GasImpactModal.test.tsx +++ b/app/components/UI/Stake/components/GasImpactModal/GasImpactModal.test.tsx @@ -94,6 +94,9 @@ const renderGasImpactModal = () => , , + undefined, + true, + false, ); describe('GasImpactModal', () => { diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/CustomNetworkNativeImgMapping.ts b/app/components/UI/Tokens/TokenList/TokenListItem/CustomNetworkNativeImgMapping.ts index 98328c847ff1..f63a635b5527 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/CustomNetworkNativeImgMapping.ts +++ b/app/components/UI/Tokens/TokenList/TokenListItem/CustomNetworkNativeImgMapping.ts @@ -15,6 +15,7 @@ import MegaethTestnetImg from '../../../../../images/megaeth-testnet-logo.png'; import LuksoImg from '../../../../../images/lukso.png'; import InjectiveImg from '../../../../../images/injective.png'; import PlasmaImg from '../../../../../images/plasma-native.png'; +import HypeImg from '../../../../../images/hyperevm.png'; export const CustomNetworkNativeImgMapping: Record = { [NETWORK_CHAIN_ID.FLARE_MAINNET]: FlareMainnetImg, @@ -34,4 +35,5 @@ export const CustomNetworkNativeImgMapping: Record = { [NETWORK_CHAIN_ID.LUKSO]: LuksoImg, [NETWORK_CHAIN_ID.INJECTIVE]: InjectiveImg, [NETWORK_CHAIN_ID.PLASMA]: PlasmaImg, + [NETWORK_CHAIN_ID.HYPE]: HypeImg, }; diff --git a/app/components/UI/TransactionHeader/index.test.tsx b/app/components/UI/TransactionHeader/index.test.tsx index 06928846b421..eb537987463a 100644 --- a/app/components/UI/TransactionHeader/index.test.tsx +++ b/app/components/UI/TransactionHeader/index.test.tsx @@ -66,6 +66,8 @@ describe('TransactionHeader', () => { const wrapper = renderWithProvider( , { state: mockInitialState }, + true, + false, ); expect(wrapper).toMatchSnapshot(); }); @@ -84,6 +86,8 @@ describe('TransactionHeader', () => { }} />, { state: mockInitialState }, + true, + false, ); expect( diff --git a/app/components/Views/AddressSelector/AddressSelector.test.tsx b/app/components/Views/AddressSelector/AddressSelector.test.tsx index b2ad4cc64e5c..6aff0617f4fb 100644 --- a/app/components/Views/AddressSelector/AddressSelector.test.tsx +++ b/app/components/Views/AddressSelector/AddressSelector.test.tsx @@ -19,7 +19,6 @@ import { MAINNET_DISPLAY_NAME, OPTIMISM_DISPLAY_NAME, POLYGON_DISPLAY_NAME, - SEI_DISPLAY_NAME, } from '../../../core/Engine/constants'; jest.mock('../../../core/Engine', () => ({ @@ -138,7 +137,6 @@ describe('AccountSelector', () => { expect(networkNames).toEqual([ MAINNET_DISPLAY_NAME, BNB_DISPLAY_NAME, - SEI_DISPLAY_NAME, POLYGON_DISPLAY_NAME, OPTIMISM_DISPLAY_NAME, ARBITRUM_DISPLAY_NAME, diff --git a/app/components/Views/AddressSelector/__snapshots__/AddressSelector.test.tsx.snap b/app/components/Views/AddressSelector/__snapshots__/AddressSelector.test.tsx.snap index 3fde75c8127a..e1c145701835 100644 --- a/app/components/Views/AddressSelector/__snapshots__/AddressSelector.test.tsx.snap +++ b/app/components/Views/AddressSelector/__snapshots__/AddressSelector.test.tsx.snap @@ -1075,187 +1075,6 @@ exports[`AccountSelector renders correctly and matches snapshot 1`] = ` "flexDirection": "row", } } - > - - - - - - - - Sei - - - 0x4FeC2...fdcB5 - - - - - - - - - - - - - - Step 1 of 3 - - Learn more. + Learn more diff --git a/app/components/Views/ChoosePassword/index.js b/app/components/Views/ChoosePassword/index.js index f422f5b3c328..1abeb569e7ee 100644 --- a/app/components/Views/ChoosePassword/index.js +++ b/app/components/Views/ChoosePassword/index.js @@ -734,18 +734,6 @@ class ChoosePassword extends PureComponent { resetScrollToCoords={{ x: 0, y: 0 }} > - {!this.getOauth2LoginSuccess() && ( - - {strings('choose_password.steps', { - currentStep: 1, - totalSteps: 3, - })} - - )} - ({ @@ -61,6 +66,16 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ }), })); +// Mock Alert +const mockAlert = jest.fn(); +jest.spyOn(Alert, 'alert').mockImplementation(mockAlert); + +// Mock feature flags utility +jest.mock('../../../util/feature-flags', () => ({ + ...jest.requireActual('../../../util/feature-flags'), + isMinimumRequiredVersionSupported: jest.fn(), +})); + describe('FeatureFlagOverride', () => { let mockNavigation: ReturnType; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -112,6 +127,7 @@ describe('FeatureFlagOverride', () => { beforeEach(() => { jest.clearAllMocks(); + mockAlert.mockClear(); mockNavigation = { setOptions: jest.fn(), @@ -127,6 +143,9 @@ describe('FeatureFlagOverride', () => { clearAllOverrides: jest.fn(), featureFlagsList: createMockFeatureFlags(), }); + + // Default mock for version support + (isMinimumRequiredVersionSupported as jest.Mock).mockReturnValue(true); }); describe('Component Rendering', () => { @@ -578,4 +597,332 @@ describe('FeatureFlagOverride', () => { expect(resetButton).toBeTruthy(); }); }); + + describe('Boolean with MinimumVersion Flag', () => { + it('disables switch when version is not supported and flag is not in FeatureFlagNames', () => { + (isMinimumRequiredVersionSupported as jest.Mock).mockReturnValue(false); + + const versionFlag = createMockFeatureFlag( + 'unsupportedVersionFlag', + 'boolean with minimumVersion', + { + enabled: true, + minimumVersion: '2.0.0', + }, + ); + + (useFeatureFlagOverride as jest.Mock).mockReturnValue({ + setOverride: jest.fn(), + removeOverride: jest.fn(), + clearAllOverrides: jest.fn(), + featureFlagsList: [versionFlag], + }); + + render(); + + const switches = screen.getAllByRole('switch'); + const versionSwitch = switches.find( + (switchElement) => switchElement.props.value === true, + ); + expect(versionSwitch?.props.disabled).toBe(true); + }); + + it('enables switch when version is supported even if flag is not in FeatureFlagNames', () => { + (isMinimumRequiredVersionSupported as jest.Mock).mockReturnValue(true); + + const versionFlag = createMockFeatureFlag( + 'supportedVersionFlag', + 'boolean with minimumVersion', + { + enabled: true, + minimumVersion: '1.0.0', + }, + ); + + (useFeatureFlagOverride as jest.Mock).mockReturnValue({ + setOverride: jest.fn(), + removeOverride: jest.fn(), + clearAllOverrides: jest.fn(), + featureFlagsList: [versionFlag], + }); + + render(); + + const switches = screen.getAllByRole('switch'); + const versionSwitch = switches.find( + (switchElement) => switchElement.props.value === true, + ); + expect(versionSwitch?.props.disabled).toBe(false); + }); + + it('enables switch when flag is in FeatureFlagNames even if version is not supported', () => { + (isMinimumRequiredVersionSupported as jest.Mock).mockReturnValue(false); + + const versionFlag = createMockFeatureFlag( + FeatureFlagNames.rewardsEnabled, + 'boolean with minimumVersion', + { + enabled: true, + minimumVersion: '2.0.0', + }, + ); + + (useFeatureFlagOverride as jest.Mock).mockReturnValue({ + setOverride: jest.fn(), + removeOverride: jest.fn(), + clearAllOverrides: jest.fn(), + featureFlagsList: [versionFlag], + }); + + render(); + + const switches = screen.getAllByRole('switch'); + const versionSwitch = switches.find( + (switchElement) => switchElement.props.value === true, + ); + expect(versionSwitch?.props.disabled).toBe(false); + }); + + it('handles toggle for boolean with minimumVersion flag', () => { + const mockSetOverride = jest.fn(); + const versionFlagValue = { + enabled: false, + minimumVersion: '1.0.0', + }; + + (useFeatureFlagOverride as jest.Mock).mockReturnValue({ + setOverride: mockSetOverride, + removeOverride: jest.fn(), + clearAllOverrides: jest.fn(), + featureFlagsList: [ + createMockFeatureFlag( + 'versionFlag', + 'boolean with minimumVersion', + versionFlagValue, + ), + ], + }); + + render(); + + const switches = screen.getAllByRole('switch'); + const versionSwitch = switches[0]; + fireEvent(versionSwitch, 'valueChange', true); + + expect(mockSetOverride).toHaveBeenCalledWith('versionFlag', { + enabled: true, + minimumVersion: '1.0.0', + }); + }); + + it('displays version support indicator correctly when version is supported', () => { + (isMinimumRequiredVersionSupported as jest.Mock).mockReturnValue(true); + + const versionFlag = createMockFeatureFlag( + 'versionFlag', + 'boolean with minimumVersion', + { + enabled: true, + minimumVersion: '1.0.0', + }, + ); + + (useFeatureFlagOverride as jest.Mock).mockReturnValue({ + setOverride: jest.fn(), + removeOverride: jest.fn(), + clearAllOverrides: jest.fn(), + featureFlagsList: [versionFlag], + }); + + render(); + + expect(screen.getByText('Minimum Version: 1.0.0')).toBeTruthy(); + }); + + it('displays version support indicator correctly when version is not supported', () => { + (isMinimumRequiredVersionSupported as jest.Mock).mockReturnValue(false); + + const versionFlag = createMockFeatureFlag( + 'versionFlag', + 'boolean with minimumVersion', + { + enabled: true, + minimumVersion: '2.0.0', + }, + ); + + (useFeatureFlagOverride as jest.Mock).mockReturnValue({ + setOverride: jest.fn(), + removeOverride: jest.fn(), + clearAllOverrides: jest.fn(), + featureFlagsList: [versionFlag], + }); + + render(); + + expect(screen.getByText('Minimum Version: 2.0.0')).toBeTruthy(); + }); + }); + + describe('Boolean Flag Disabled State', () => { + it('disables boolean switch when flag is not in FeatureFlagNames', () => { + (useFeatureFlagOverride as jest.Mock).mockReturnValue({ + setOverride: jest.fn(), + removeOverride: jest.fn(), + clearAllOverrides: jest.fn(), + featureFlagsList: [ + createMockFeatureFlag('unknownBooleanFlag', 'boolean', false), + ], + }); + + render(); + + const switches = screen.getAllByRole('switch'); + const booleanSwitch = switches[0]; + expect(booleanSwitch.props.disabled).toBe(true); + }); + + it('enables boolean switch when flag is in FeatureFlagNames', () => { + (useFeatureFlagOverride as jest.Mock).mockReturnValue({ + setOverride: jest.fn(), + removeOverride: jest.fn(), + clearAllOverrides: jest.fn(), + featureFlagsList: [ + createMockFeatureFlag( + FeatureFlagNames.rewardsEnabled, + 'boolean', + false, + ), + ], + }); + + render(); + + const switches = screen.getAllByRole('switch'); + const booleanSwitch = switches[0]; + expect(booleanSwitch.props.disabled).toBe(false); + }); + }); + + describe('Empty State Messages', () => { + it('shows correct message when search and type filter both active with no results', () => { + (useFeatureFlagOverride as jest.Mock).mockReturnValue({ + setOverride: jest.fn(), + removeOverride: jest.fn(), + clearAllOverrides: jest.fn(), + featureFlagsList: [ + createMockFeatureFlag('onlyString', 'string', 'value'), + ], + }); + + render(); + + const searchInput = screen.getByPlaceholderText( + 'Search feature flags...', + ); + fireEvent.changeText(searchInput, 'nonexistent'); + + const filterButton = screen.getByText('All (1)'); + fireEvent.press(filterButton); + + expect( + screen.getByText('No boolean feature flags match your search.'), + ).toBeTruthy(); + }); + + it('shows correct message when only type filter is active with no results', () => { + (useFeatureFlagOverride as jest.Mock).mockReturnValue({ + setOverride: jest.fn(), + removeOverride: jest.fn(), + clearAllOverrides: jest.fn(), + featureFlagsList: [ + createMockFeatureFlag('onlyString', 'string', 'value'), + ], + }); + + render(); + + const filterButton = screen.getByText('All (1)'); + fireEvent.press(filterButton); + + expect( + screen.getByText('No boolean feature flags available.'), + ).toBeTruthy(); + }); + + it('shows correct message when only search is active with no results', () => { + (useFeatureFlagOverride as jest.Mock).mockReturnValue({ + setOverride: jest.fn(), + removeOverride: jest.fn(), + clearAllOverrides: jest.fn(), + featureFlagsList: createMockFeatureFlags(), + }); + + render(); + + const searchInput = screen.getByPlaceholderText( + 'Search feature flags...', + ); + fireEvent.changeText(searchInput, 'nonexistent'); + + expect( + screen.getByText('No feature flags match your search.'), + ).toBeTruthy(); + }); + }); + + describe('Default Case in renderValueEditor', () => { + it('renders default text display for unknown flag types', () => { + // Create a flag with a type that doesn't match any case + // We'll use 'boolean' type but with a value that might trigger default + (useFeatureFlagOverride as jest.Mock).mockReturnValue({ + setOverride: jest.fn(), + removeOverride: jest.fn(), + clearAllOverrides: jest.fn(), + featureFlagsList: [ + { + key: 'unknownTypeFlag', + value: 'some value', + originalValue: 'some value', + type: 'boolean' as FeatureFlagInfo['type'], + description: undefined, + isOverridden: false, + }, + ], + }); + + render(); + + // The component should still render the flag + expect(screen.getByText('unknownTypeFlag')).toBeTruthy(); + }); + }); + + describe('Filtered Count Display', () => { + it('shows filtered count when type filter is active', () => { + render(); + + const filterButton = screen.getByText('All (6)'); + fireEvent.press(filterButton); + + expect(screen.getByText('Showing: 2 flags')).toBeTruthy(); + }); + + it('shows filtered count when search is active', () => { + render(); + + const searchInput = screen.getByPlaceholderText( + 'Search feature flags...', + ); + fireEvent.changeText(searchInput, 'boolean'); + + expect(screen.getByText('Showing: 1 flags')).toBeTruthy(); + }); + + it('does not show filtered count when no filters are active', () => { + render(); + + expect(screen.queryByText(/Showing: \d+ flags/)).toBeNull(); + }); + }); }); diff --git a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx index cc0c136bf222..ff31fda0f3ea 100644 --- a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx +++ b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx @@ -23,6 +23,7 @@ import { } from '../../../util/feature-flags'; import { useFeatureFlagOverride } from '../../../contexts/FeatureFlagOverrideContext'; import { useFeatureFlagStats } from '../../../hooks/useFeatureFlagStats'; +import { FeatureFlagNames } from '../../hooks/useFeatureFlag'; interface FeatureFlagRowProps { flag: FeatureFlagInfo; @@ -56,13 +57,19 @@ const FeatureFlagRow: React.FC = ({ flag, onToggle }) => { { - setLocalValue({ + const updatedValue = { ...(localValue as MinimumVersionFlagValue), enabled: newValue, - }); - onToggle(flag.key, newValue); + }; + setLocalValue(updatedValue); + onToggle(flag.key, updatedValue); }} trackColor={{ true: theme.colors.primary.default, @@ -89,7 +96,11 @@ const FeatureFlagRow: React.FC = ({ flag, onToggle }) => { case 'boolean': return ( { setLocalValue(newValue); diff --git a/app/components/Views/Login/__snapshots__/index.test.tsx.snap b/app/components/Views/Login/__snapshots__/index.test.tsx.snap index ec8ae820f352..9c9f50f77a76 100644 --- a/app/components/Views/Login/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Login/__snapshots__/index.test.tsx.snap @@ -3,15 +3,24 @@ exports[`Login renders matching snapshot 1`] = ` - - - - - - Welcome Back! - - - Password - - + + + - - - - - + - - Unlock - - - - + Unlock + + + - Forgot password? - - + + Forgot password? + + + @@ -362,21 +320,66 @@ exports[`Login renders matching snapshot 1`] = ` } } /> + + + `; exports[`Login renders matching snapshot when password input is focused 1`] = ` - - - - - - Welcome Back! - - - Password - - + + + - - - - - + - - Unlock - - - - + Unlock + + + - Forgot password? - - + + Forgot password? + + + @@ -726,5 +678,41 @@ exports[`Login renders matching snapshot when password input is focused 1`] = ` } } /> + + + `; diff --git a/app/components/Views/Login/index.test.tsx b/app/components/Views/Login/index.test.tsx index d490c79a195c..8f0486c316a4 100644 --- a/app/components/Views/Login/index.test.tsx +++ b/app/components/Views/Login/index.test.tsx @@ -3,7 +3,9 @@ import Login from './'; import renderWithProvider from '../../../util/test/renderWithProvider'; import { fireEvent, act } from '@testing-library/react-native'; import { LoginViewSelectors } from '../../../../e2e/selectors/wallet/LoginView.selectors'; -import { InteractionManager, BackHandler, Alert } from 'react-native'; +import { InteractionManager, BackHandler, Alert, Image } from 'react-native'; +import METAMASK_NAME from '../../../images/branding/metamask-name.png'; +import FOX_LOGO from '../../../images/branding/fox.png'; import Routes from '../../../constants/navigation/Routes'; import { Authentication } from '../../../core'; import { strings } from '../../../../locales/i18n'; @@ -25,6 +27,9 @@ import { TRUE, } from '../../../constants/storage'; import { useMetrics } from '../../hooks/useMetrics'; +import styleSheet from './styles'; +import { colors as importedColors } from '../../../styles/common'; +import { Theme } from '../../../util/theme/models'; import { setExistingUser } from '../../../actions/user'; const mockNavigate = jest.fn(); @@ -74,13 +79,16 @@ jest.mock('../../../util/password', () => ({ passwordRequirementsMet: jest.fn(), })); -// Mock react-native with Keyboard -jest.mock('react-native', () => ({ - ...jest.requireActual('react-native'), - Keyboard: { - dismiss: jest.fn(), - }, -})); +// Mock react-native Keyboard +jest.mock('react-native', () => { + const RN = jest.requireActual('react-native'); + return { + ...RN, + Keyboard: { + dismiss: jest.fn(), + }, + }; +}); // Mock StorageWrapper jest.mock('../../../store/storage-wrapper', () => ({ @@ -102,6 +110,7 @@ jest.mock('../../../core/Authentication', () => ({ userEntryAuth: jest.fn(), appTriggeredAuth: jest.fn(), lockApp: jest.fn(), + checkIsSeedlessPasswordOutdated: jest.fn().mockResolvedValue(false), })); jest.mock('../../../actions/security', () => ({ @@ -135,6 +144,28 @@ jest.mock('../../../core/BackupVault', () => ({ getVaultFromBackup: jest.fn(), })); +// Mock animation components +jest.mock('../../UI/OnboardingAnimation/OnboardingAnimation'); + +jest.mock('../../UI/FoxAnimation/FoxAnimation'); + +// Mock Rive animations +jest.mock('rive-react-native', () => ({ + __esModule: true, + default: () => null, + Fit: { Contain: 'contain' }, + Alignment: { Center: 'center' }, +})); + +// Mock safe area context +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), + // eslint-disable-next-line @typescript-eslint/no-require-imports + SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children, + // eslint-disable-next-line @typescript-eslint/no-require-imports + SafeAreaView: ({ children }: { children: React.ReactNode }) => children, +})); + jest.mock('../../../util/validators', () => ({ parseVaultValue: jest.fn(), })); @@ -314,9 +345,6 @@ describe('Login', () => { // Assert expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { screen: Routes.MODAL.DELETE_WALLET, - params: { - oauthLoginSuccess: false, - }, }); }); @@ -968,17 +996,10 @@ describe('Login', () => { it('displays invalid password error when decryption fails', async () => { // Arrange - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: true, - }, - }); ( Authentication.componentAuthenticationType as jest.Mock ).mockResolvedValue({ currentAuthType: 'password', - oauth2Login: true, }); (Authentication.userEntryAuth as jest.Mock).mockRejectedValue( new Error('Decrypt failed'), @@ -1001,36 +1022,13 @@ describe('Login', () => { expect(errorElement.props.children).toEqual( strings('login.invalid_password'), ); - expect(mockTrackOnboarding).toHaveBeenCalled(); - const rehydrationCall = mockTrackOnboarding.mock.calls.find( - (call: unknown[]) => - call[0] && - typeof call[0] === 'object' && - 'name' in call[0] && - call[0].name === 'Rehydration Password Failed' && - 'properties' in call[0] && - call[0].properties && - typeof call[0].properties === 'object' && - 'account_type' in call[0].properties && - call[0].properties.account_type === 'social' && - 'error_type' in call[0].properties && - call[0].properties.error_type === 'incorrect_password', - ); - expect(rehydrationCall).toBeDefined(); }); it('displays invalid password error for Android BAD_DECRYPT error', async () => { - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: true, - }, - }); ( Authentication.componentAuthenticationType as jest.Mock ).mockResolvedValue({ currentAuthType: 'password', - oauth2Login: true, }); (Authentication.userEntryAuth as jest.Mock).mockRejectedValue( new Error( @@ -1053,36 +1051,13 @@ describe('Login', () => { expect(errorElement.props.children).toEqual( strings('login.invalid_password'), ); - expect(mockTrackOnboarding).toHaveBeenCalled(); - const rehydrationCall = mockTrackOnboarding.mock.calls.find( - (call: unknown[]) => - call[0] && - typeof call[0] === 'object' && - 'name' in call[0] && - call[0].name === 'Rehydration Password Failed' && - 'properties' in call[0] && - call[0].properties && - typeof call[0].properties === 'object' && - 'account_type' in call[0].properties && - call[0].properties.account_type === 'social' && - 'error_type' in call[0].properties && - call[0].properties.error_type === 'incorrect_password', - ); - expect(rehydrationCall).toBeDefined(); }); it('displays invalid password error for Android DoCipher error', async () => { - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: true, - }, - }); ( Authentication.componentAuthenticationType as jest.Mock ).mockResolvedValue({ currentAuthType: 'password', - oauth2Login: true, }); (Authentication.userEntryAuth as jest.Mock).mockRejectedValue( new Error('error in DoCipher, status: 2'), @@ -1103,22 +1078,6 @@ describe('Login', () => { expect(errorElement.props.children).toEqual( strings('login.invalid_password'), ); - expect(mockTrackOnboarding).toHaveBeenCalled(); - const rehydrationCall = mockTrackOnboarding.mock.calls.find( - (call: unknown[]) => - call[0] && - typeof call[0] === 'object' && - 'name' in call[0] && - call[0].name === 'Rehydration Password Failed' && - 'properties' in call[0] && - call[0].properties && - typeof call[0].properties === 'object' && - 'account_type' in call[0].properties && - call[0].properties.account_type === 'social' && - 'error_type' in call[0].properties && - call[0].properties.error_type === 'incorrect_password', - ); - expect(rehydrationCall).toBeDefined(); }); it('displays invalid password error when password requirements not met', async () => { @@ -1153,14 +1112,6 @@ describe('Login', () => { expect(errorElement.props.children).toEqual( strings('login.invalid_password'), ); - const rehydrationCall = mockTrackOnboarding.mock.calls.find( - (call: unknown[]) => - call[0] && - typeof call[0] === 'object' && - 'name' in call[0] && - call[0].name === 'Rehydration Password Failed', - ); - expect(rehydrationCall).toBeUndefined(); }); it('displays generic error message for unexpected errors', async () => { @@ -1184,46 +1135,6 @@ describe('Login', () => { 'Error: Some unexpected error', ); }); - - it('traces OnboardingPasswordLoginError during onboarding flow', async () => { - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: false, - onboardingTraceCtx: 'mockTraceContext', - }, - }); - - (Authentication.userEntryAuth as jest.Mock).mockRejectedValue( - new Error('Some unexpected error'), - ); - - const { getByTestId } = renderWithProvider(); - const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); - - await act(async () => { - fireEvent.changeText(passwordInput, 'valid-password123'); - }); - await act(async () => { - fireEvent(passwordInput, 'submitEditing'); - }); - - const errorElement = getByTestId(LoginViewSelectors.PASSWORD_ERROR); - expect(errorElement).toBeOnTheScreen(); - expect(errorElement.props.children).toEqual( - 'Error: Some unexpected error', - ); - - expect(mockTrace).toHaveBeenCalledWith({ - name: TraceName.OnboardingPasswordLoginError, - op: TraceOperation.OnboardingError, - tags: { errorMessage: 'Error: Some unexpected error' }, - parentContext: 'mockTraceContext', - }); - expect(mockEndTrace).toHaveBeenCalledWith({ - name: TraceName.OnboardingPasswordLoginError, - }); - }); }); describe('Passcode Error Handling', () => { @@ -1382,7 +1293,7 @@ describe('Login', () => { ); }); - it('handleBackPress locks app when oauthLoginSuccess is false', () => { + it('locks app when back button is pressed', () => { mockRoute.mockReturnValue({ params: { locked: false, @@ -1399,26 +1310,137 @@ describe('Login', () => { expect(mockGoBack).not.toHaveBeenCalled(); expect(result).toBe(false); }); + }); - it('handleBackPress navigates back when oauthLoginSuccess is true', () => { - mockRoute.mockReturnValue({ - params: { - locked: false, - oauthLoginSuccess: true, + describe('Login Styles', () => { + it('returns correct textField background color for light theme', () => { + // Arrange + const mockTheme = { + colors: { + background: { default: '#FFFFFF' }, + text: { default: '#000000', alternative: '#666666' }, + border: { default: '#E5E5E5' }, + error: { default: '#FF0000' }, + icon: { default: '#000000' }, }, + themeAppearance: 'light', + typography: {}, + shadows: {}, + brandColors: {}, + } as unknown as Theme; + + // Act + const styles = styleSheet({ theme: mockTheme }); + + // Assert + expect(styles.textField.backgroundColor).toBe( + importedColors.gettingStartedPageBackgroundColorLightMode, + ); + }); + + it('returns correct textField background color for dark theme', () => { + // Arrange + const mockDarkTheme = { + colors: { + background: { default: '#000000' }, + text: { default: '#FFFFFF', alternative: '#CCCCCC' }, + border: { default: '#333333' }, + error: { default: '#FF6B6B' }, + icon: { default: '#FFFFFF' }, + }, + themeAppearance: 'dark', + typography: {}, + shadows: {}, + brandColors: {}, + } as unknown as Theme; + + // Act + const styles = styleSheet({ theme: mockDarkTheme }); + + // Assert + expect(styles.textField.backgroundColor).toBe( + importedColors.gettingStartedTextColor, + ); + }); + }); + + describe('Conditional Rendering Based on OAuth Status', () => { + describe('Regular Login', () => { + beforeEach(() => { + mockRoute.mockReturnValue({ + params: { + locked: false, + oauthLoginSuccess: false, + }, + }); }); - renderWithProvider(); + it('renders animations and hides OAuth-specific elements', () => { + // Arrange & Act + const { getByTestId, queryByTestId, UNSAFE_root } = renderWithProvider( + , + ); - const handleBackPress = mockBackHandlerAddEventListener.mock.calls[0][1]; - const result = handleBackPress(); + // Assert - Animations are rendered + expect(getByTestId('onboarding-animation-mock')).toBeDefined(); + expect(getByTestId('fox-animation-mock')).toBeDefined(); - expect(mockGoBack).toHaveBeenCalled(); - expect(Authentication.lockApp).not.toHaveBeenCalled(); - expect(result).toBe(false); + // Assert - Regular login elements + expect(getByTestId(LoginViewSelectors.RESET_WALLET)).toBeDefined(); + + // Assert - OAuth elements are hidden + expect(queryByTestId(LoginViewSelectors.TITLE_ID)).toBeNull(); + expect( + queryByTestId(LoginViewSelectors.OTHER_METHODS_BUTTON), + ).toBeNull(); + + // Assert - Static images are not rendered + const images = UNSAFE_root.findAllByType(Image); + const hasMetaMaskLogo = images.some( + (img) => img.props.source === METAMASK_NAME, + ); + const hasStaticFox = images.some( + (img) => img.props.source === FOX_LOGO, + ); + expect(hasMetaMaskLogo).toBe(false); + expect(hasStaticFox).toBe(false); + }); + + it('starts onboarding animation after delay', () => { + // Arrange + jest.useFakeTimers(); + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + + // Act + renderWithProvider(); + + // Assert + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 100); + + setTimeoutSpy.mockRestore(); + jest.useRealTimers(); + }); + }); + + describe('Common Elements', () => { + it('renders core login elements', () => { + mockRoute.mockReturnValue({ + params: { + locked: false, + oauthLoginSuccess: false, + }, + }); + + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(LoginViewSelectors.CONTAINER)).toBeDefined(); + expect(getByTestId(LoginViewSelectors.PASSWORD_INPUT)).toBeDefined(); + expect(getByTestId(LoginViewSelectors.LOGIN_BUTTON_ID)).toBeDefined(); + }); }); }); }); + // it('should navigate back and reset OAuth state when using other methods', async () => { // mockRoute.mockReturnValue({ // params: { diff --git a/app/components/Views/Login/index.tsx b/app/components/Views/Login/index.tsx index cb99bfe6c5cb..308467cc60ec 100644 --- a/app/components/Views/Login/index.tsx +++ b/app/components/Views/Login/index.tsx @@ -1,18 +1,15 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import { Alert, View, SafeAreaView, - Image, BackHandler, TouchableOpacity, TextInput, + Platform, } from 'react-native'; -import { captureException } from '@sentry/react-native'; -import Text, { - TextColor, - TextVariant, -} from '../../../component-library/components/Texts/Text'; +import { colors as importedColors } from '../../../styles/common'; +import { TextVariant } from '../../../component-library/components/Texts/Text'; import StorageWrapper from '../../../store/storage-wrapper'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; import Button, { @@ -62,13 +59,11 @@ import { trace, TraceName, TraceOperation, - TraceContext, endTrace, } from '../../../util/trace'; import TextField, { TextFieldSize, } from '../../../component-library/components/Form/TextField'; -import Label from '../../../component-library/components/Form/Label'; import HelpText, { HelpTextSeverity, } from '../../../component-library/components/Form/HelpText'; @@ -93,29 +88,17 @@ import stylesheet from './styles'; import ReduxService from '../../../core/redux'; import { StackNavigationProp } from '@react-navigation/stack'; import { BIOMETRY_TYPE } from 'react-native-keychain'; -import METAMASK_NAME from '../../../images/branding/metamask-name.png'; -import OAuthService from '../../../core/OAuthService/OAuthService'; import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding'; -import { - SeedlessOnboardingControllerErrorMessage, - RecoveryError as SeedlessOnboardingControllerRecoveryError, -} from '@metamask/seedless-onboarding-controller'; import { IMetaMetricsEvent, ITrackingEvent, } from '../../../core/Analytics/MetaMetrics.types'; import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; import { useMetrics } from '../../hooks/useMetrics'; -import { - SeedlessOnboardingControllerError, - SeedlessOnboardingControllerErrorType, -} from '../../../core/Engine/controllers/seedless-onboarding-controller/error'; import { selectIsSeedlessPasswordOutdated } from '../../../selectors/seedlessOnboardingController'; -import FOX_LOGO from '../../../images/branding/fox.png'; -import { usePromptSeedlessRelogin } from '../../hooks/SeedlessHooks'; -import { useNetInfo } from '@react-native-community/netinfo'; -import { SuccessErrorSheetParams } from '../SuccessErrorSheet/interface'; import { LoginOptionsSwitch } from '../../UI/LoginOptionsSwitch'; +import FoxAnimation from '../../UI/FoxAnimation/FoxAnimation'; +import OnboardingAnimation from '../../UI/OnboardingAnimation/OnboardingAnimation'; // In android, having {} will cause the styles to update state // using a constant will prevent this @@ -123,8 +106,6 @@ const EmptyRecordConstant = {}; interface LoginRouteParams { locked: boolean; - oauthLoginSuccess?: boolean; - onboardingTraceCtx?: TraceContext; isVaultRecovery?: boolean; } @@ -136,7 +117,6 @@ interface LoginProps { * View where returning users can authenticate */ const Login: React.FC = ({ saveOnboardingEvent }) => { - const [disabledInput, setDisabledInput] = useState(false); const { isEnabled: isMetricsEnabled } = useMetrics(); const fieldRef = useRef(null); @@ -149,10 +129,14 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { const [biometryChoice, setBiometryChoice] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [errorToThrow, setErrorToThrow] = useState(null); const [hasBiometricCredentials, setHasBiometricCredentials] = useState(false); - const [rehydrationFailedAttempts, setRehydrationFailedAttempts] = useState(0); + const [startOnboardingAnimation, setStartOnboardingAnimation] = + useState(false); + const [startFoxAnimation, setStartFoxAnimation] = useState< + undefined | 'Start' | 'Loader' + >(undefined); + const navigation = useNavigation>(); const route = useRoute>(); const dispatch = useDispatch(); @@ -162,25 +146,17 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { } = useStyles(stylesheet, EmptyRecordConstant); const setAllowLoginWithRememberMe = (enabled: boolean) => setAllowLoginWithRememberMeUtil(enabled); - const passwordLoginAttemptTraceCtxRef = useRef(null); - - // coming from oauth onboarding flow flag - const isComingFromOauthOnboarding = route?.params?.oauthLoginSuccess ?? false; // coming from vault recovery flow flag const isComingFromVaultRecovery = route?.params?.isVaultRecovery ?? false; - const { isDeletingInProgress, promptSeedlessRelogin } = - usePromptSeedlessRelogin(); - - const finalLoading = useMemo( - () => loading || isDeletingInProgress, - [loading, isDeletingInProgress], - ); - const isSeedlessPasswordOutdated = useSelector( selectIsSeedlessPasswordOutdated, ); + const setStartFoxAnimationCallback = () => { + setStartFoxAnimation('Start'); + }; + const track = ( event: IMetaMetricsEvent, properties: Record, @@ -194,18 +170,17 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { }; const handleBackPress = () => { - if (!isComingFromOauthOnboarding) { - Authentication.lockApp(); - } else { - navigation.goBack(); - } + Authentication.lockApp(); return false; }; - const updateBiometryChoice = async (newBiometryChoice: boolean) => { - await updateAuthTypeStorageFlags(newBiometryChoice); - setBiometryChoice(newBiometryChoice); - }; + const updateBiometryChoice = useCallback( + async (newBiometryChoice: boolean) => { + await updateAuthTypeStorageFlags(newBiometryChoice); + setBiometryChoice(newBiometryChoice); + }, + [setBiometryChoice], + ); useEffect(() => { trace({ @@ -214,39 +189,24 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { }); track(MetaMetricsEvents.LOGIN_SCREEN_VIEWED, {}); BackHandler.addEventListener('hardwareBackPress', handleBackPress); + + const timeoutId = setTimeout(async () => { + if (await Authentication.checkIsSeedlessPasswordOutdated()) { + navigation.replace('Rehydrate', { + isSeedlessPasswordOutdated: true, + }); + } else { + setStartOnboardingAnimation(true); + } + }, 100); + return () => { + clearTimeout(timeoutId); BackHandler.removeEventListener('hardwareBackPress', handleBackPress); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect(() => { - const onboardingTraceCtxFromRoute = route.params?.onboardingTraceCtx; - if (onboardingTraceCtxFromRoute) { - passwordLoginAttemptTraceCtxRef.current = trace({ - name: TraceName.OnboardingPasswordLoginAttempt, - op: TraceOperation.OnboardingUserJourney, - parentContext: onboardingTraceCtxFromRoute, - }); - } - }, [route.params?.onboardingTraceCtx]); - - const [refreshAuthPref, setRefreshAuthPref] = useState(false); - useEffect(() => { - if (isSeedlessPasswordOutdated) { - setError(strings('login.seedless_password_outdated')); - // password outdated, reset biometric password and choice - Authentication.resetPassword() - .then(() => { - // set to fupate authPref - setRefreshAuthPref(true); - }) - .catch((e) => { - Logger.error(e); - }); - } - }, [isSeedlessPasswordOutdated]); - useEffect(() => { const getUserAuthPreferences = async () => { const authData = await Authentication.getType(); @@ -279,16 +239,16 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { }; getUserAuthPreferences(); - }, [route?.params?.locked, refreshAuthPref]); + }, [route?.params?.locked]); - const handleVaultCorruption = async () => { + const handleVaultCorruption = useCallback(async () => { const LOGIN_VAULT_CORRUPTION_TAG = 'Login/ handleVaultCorruption:'; // Track vault corruption handling attempt trackVaultCorruption(VAULT_ERROR, { error_type: 'vault_corruption_handling', context: 'vault_corruption_recovery_attempt', - oauth_login: isComingFromOauthOnboarding, + oauth_login: false, }); // No need to check password requirements here, it will be checked in onLogin @@ -330,7 +290,7 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { trackVaultCorruption((e as Error).message, { error_type: 'vault_corruption_handling_failed', context: 'vault_corruption_recovery_failed', - oauth_login: isComingFromOauthOnboarding, + oauth_login: false, }); Logger.error(e as Error); @@ -338,13 +298,13 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { setError(strings('login.invalid_password')); } - }; + }, [password, biometryChoice, rememberMe, navigation]); - const navigateToHome = async () => { + const navigateToHome = useCallback(async () => { navigation.replace(Routes.ONBOARDING.HOME_NAV); - }; + }, [navigation]); - const checkMetricsUISeen = async (): Promise => { + const checkMetricsUISeen = useCallback(async (): Promise => { const isOptinMetaMetricsUISeen = await StorageWrapper.getItem( OPTIN_META_METRICS_UI_SEEN, ); @@ -366,302 +326,79 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { } else { navigateToHome(); } - }; - - const handleUseOtherMethod = () => { - if (isComingFromOauthOnboarding) { - track(MetaMetricsEvents.USE_DIFFERENT_LOGIN_METHOD_CLICKED, { - account_type: 'social', - }); - } - navigation.goBack(); - OAuthService.resetOauthState(); - }; - - const isMountedRef = useRef(true); - - useEffect( - () => () => { - isMountedRef.current = false; - }, - [], - ); + }, [navigation, navigateToHome, isMetricsEnabled]); - const tooManyAttemptsError = async (initialRemainingTime: number) => { - const lockEnd = Date.now() + initialRemainingTime * 1000; + const handlePasswordError = useCallback((loginErrorMessage: string) => { + setLoading(false); + setError(strings('login.invalid_password')); + trackErrorAsAnalytics('Login: Invalid Password', loginErrorMessage); + }, []); - setDisabledInput(true); - while (Date.now() < lockEnd) { - const remainingTime = Math.floor((lockEnd - Date.now()) / 1000); - if (remainingTime <= 0) { - break; - } + const handleLoginError = useCallback( + async (loginErr: unknown) => { + const loginError = loginErr as Error; + const loginErrorMessage = loginError.toString(); - if (!isMountedRef.current) { - setError(null); - setDisabledInput(false); - return; // Exit early if component unmounted - } + const isWrongPasswordError = + toLowerCaseEquals(loginErrorMessage, WRONG_PASSWORD_ERROR) || + toLowerCaseEquals(loginErrorMessage, WRONG_PASSWORD_ERROR_ANDROID) || + toLowerCaseEquals(loginErrorMessage, WRONG_PASSWORD_ERROR_ANDROID_2); - const remainingHours = Math.floor(remainingTime / 3600); - const remainingMinutes = Math.floor((remainingTime % 3600) / 60); - const remainingSeconds = remainingTime % 60; - const displayRemainingTime = `${remainingHours}:${remainingMinutes - .toString() - .padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; - - setError( - strings('login.too_many_attempts', { - remainingTime: displayRemainingTime, - }), - ); - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - if (isMountedRef.current) { - setError(null); - setDisabledInput(false); - } - }; - - const netInfo = useNetInfo(); - const handleSeedlessOnboardingControllerError = ( - seedlessError: - | Error - | SeedlessOnboardingControllerRecoveryError - | SeedlessOnboardingControllerError, - ) => { - setLoading(false); + const isPasswordError = + isWrongPasswordError || + loginErrorMessage.includes(PASSWORD_REQUIREMENTS_NOT_MET); - // if no network available - if (!netInfo.isConnected || !netInfo.isInternetReachable) { - const params: SuccessErrorSheetParams = { - title: strings(`error_sheet.no_internet_connection_title`), - description: strings(`error_sheet.no_internet_connection_description`), - descriptionAlign: 'left', - primaryButtonLabel: strings( - `error_sheet.no_internet_connection_button`, - ), - closeOnPrimaryButtonPress: true, - type: 'error', - }; - navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.SUCCESS_ERROR_SHEET, - params, - }); - return; - } - - if (seedlessError instanceof SeedlessOnboardingControllerRecoveryError) { - if ( - seedlessError.message === - SeedlessOnboardingControllerErrorMessage.IncorrectPassword - ) { - if (isComingFromOauthOnboarding) { - track(MetaMetricsEvents.REHYDRATION_PASSWORD_FAILED, { - account_type: 'social', - failed_attempts: rehydrationFailedAttempts, - error_type: 'incorrect_password', - }); - } - setError(strings('login.invalid_password')); + if (isPasswordError) { + handlePasswordError(loginErrorMessage); + // return and skip capture error to sentry return; + } else if (loginErrorMessage === PASSCODE_NOT_SET_ERROR) { + Alert.alert( + strings('login.security_alert_title'), + strings('login.security_alert_desc'), + ); } else if ( - seedlessError.message === - SeedlessOnboardingControllerErrorMessage.TooManyLoginAttempts - ) { - // Synchronize rehydrationFailedAttempts with numberOfAttempts from the error data - if (seedlessError.data?.numberOfAttempts !== undefined) { - setRehydrationFailedAttempts(seedlessError.data.numberOfAttempts); - } - if (isComingFromOauthOnboarding) { - track(MetaMetricsEvents.REHYDRATION_PASSWORD_FAILED, { - account_type: 'social', - failed_attempts: - seedlessError.data?.numberOfAttempts ?? rehydrationFailedAttempts, - error_type: 'incorrect_password', - }); - } - if (typeof seedlessError.data?.remainingTime === 'number') { - tooManyAttemptsError(seedlessError.data?.remainingTime).catch( - () => null, - ); - } - return; - } - } else if (seedlessError instanceof SeedlessOnboardingControllerError) { - if ( - seedlessError.code === - SeedlessOnboardingControllerErrorType.PasswordRecentlyUpdated + containsErrorMessage(loginError, VAULT_ERROR) || + containsErrorMessage(loginError, JSON_PARSE_ERROR_UNEXPECTED_TOKEN) ) { - if (isComingFromOauthOnboarding) { - track(MetaMetricsEvents.REHYDRATION_PASSWORD_FAILED, { - account_type: 'social', - failed_attempts: rehydrationFailedAttempts, - error_type: 'unknown_error', - }); - } - setError(strings('login.seedless_password_outdated')); - return; - } - } else if (!isComingFromOauthOnboarding) { - // for non oauth login (rehydration) failure, prompt user to reset and rehydrate - // do we want to capture and report the error? - if (isMetricsEnabled()) { - captureException(seedlessError, { - tags: { - view: 'Re-login', - context: - 'seedless flow unlock wallet failed - user consented to analytics', - }, + // Track vault corruption detected + trackVaultCorruption(loginErrorMessage, { + error_type: containsErrorMessage(loginError, VAULT_ERROR) + ? 'vault_error' + : 'json_parse_error', + context: 'login_authentication', + oauth_login: false, }); - } - Logger.error(seedlessError, 'Error in Unlock Screen'); - promptSeedlessRelogin(); - return; - } - const errMessage = seedlessError.message.replace( - 'SeedlessOnboardingController - ', - '', - ); - setError(errMessage); - - // capture unexpected exception for oauth login (rehydration) failures - if (isComingFromOauthOnboarding) { - track(MetaMetricsEvents.REHYDRATION_PASSWORD_FAILED, { - account_type: 'social', - failed_attempts: rehydrationFailedAttempts, - error_type: 'unknown_error', - }); - // If user has already consented to analytics, report error using regular Sentry - if (isMetricsEnabled()) { - captureException(seedlessError, { - tags: { - view: 'Login', - context: 'OAuth rehydration failed - user consented to analytics', - }, - }); + await handleVaultCorruption(); + } else if (toLowerCaseEquals(loginErrorMessage, DENY_PIN_ERROR_ANDROID)) { + updateBiometryChoice(false); } else { - // User hasn't consented to analytics yet, use ErrorBoundary onboarding flow - setErrorToThrow( - new Error(`OAuth rehydration failed: ${seedlessError.message}`), - ); + setError(loginErrorMessage); } - } - }; - - const handlePasswordError = (loginErrorMessage: string) => { - setLoading(false); - - setError(strings('login.invalid_password')); - trackErrorAsAnalytics('Login: Invalid Password', loginErrorMessage); - }; - - const handleLoginError = async (loginErr: unknown) => { - const loginError = loginErr as Error; - const loginErrorMessage = loginError.toString(); - - // Check if we are in the onboarding flow - const onboardingTraceCtxFromRoute = route.params?.onboardingTraceCtx; - if (onboardingTraceCtxFromRoute) { - trace({ - name: TraceName.OnboardingPasswordLoginError, - op: TraceOperation.OnboardingError, - tags: { errorMessage: loginErrorMessage }, - parentContext: onboardingTraceCtxFromRoute, - }); - endTrace({ name: TraceName.OnboardingPasswordLoginError }); - } - - if (loginErrorMessage.includes('SeedlessOnboardingController')) { - handleSeedlessOnboardingControllerError(loginError); - return; - } - - const isWrongPasswordError = - toLowerCaseEquals(loginErrorMessage, WRONG_PASSWORD_ERROR) || - toLowerCaseEquals(loginErrorMessage, WRONG_PASSWORD_ERROR_ANDROID) || - toLowerCaseEquals(loginErrorMessage, WRONG_PASSWORD_ERROR_ANDROID_2); - - if (isWrongPasswordError && isComingFromOauthOnboarding) { - track(MetaMetricsEvents.REHYDRATION_PASSWORD_FAILED, { - account_type: 'social', - failed_attempts: rehydrationFailedAttempts, - error_type: 'incorrect_password', - }); - } - - const isPasswordError = - isWrongPasswordError || - loginErrorMessage.includes(PASSWORD_REQUIREMENTS_NOT_MET); - - if (isPasswordError) { - handlePasswordError(loginErrorMessage); - // return and skip capture error to sentry - return; - } else if (loginErrorMessage === PASSCODE_NOT_SET_ERROR) { - Alert.alert( - strings('login.security_alert_title'), - strings('login.security_alert_desc'), - ); - } else if ( - containsErrorMessage(loginError, VAULT_ERROR) || - containsErrorMessage(loginError, JSON_PARSE_ERROR_UNEXPECTED_TOKEN) - ) { - // Track vault corruption detected - trackVaultCorruption(loginErrorMessage, { - error_type: containsErrorMessage(loginError, VAULT_ERROR) - ? 'vault_error' - : 'json_parse_error', - context: 'login_authentication', - oauth_login: isComingFromOauthOnboarding, - }); - - await handleVaultCorruption(); - } else if (toLowerCaseEquals(loginErrorMessage, DENY_PIN_ERROR_ANDROID)) { - updateBiometryChoice(false); - } else { - setError(loginErrorMessage); - } - if (isComingFromOauthOnboarding) { - track(MetaMetricsEvents.REHYDRATION_PASSWORD_FAILED, { - account_type: 'social', - failed_attempts: rehydrationFailedAttempts, - error_type: 'unknown_error', - }); - } - - setLoading(false); - Logger.error(loginErr as Error, 'Failed to unlock'); - }; + setLoading(false); + Logger.error(loginErr as Error, 'Failed to unlock'); + }, + [handlePasswordError, handleVaultCorruption, updateBiometryChoice], + ); - const onLogin = async () => { + const onLogin = useCallback(async () => { endTrace({ name: TraceName.LoginUserInteraction }); - if (isComingFromOauthOnboarding) { - track(MetaMetricsEvents.REHYDRATION_PASSWORD_ATTEMPTED, { - account_type: 'social', - biometrics: biometryChoice, - }); - } try { const locked = !passwordRequirementsMet(password); if (locked) { throw new Error(PASSWORD_REQUIREMENTS_NOT_MET); } - if (finalLoading || locked) return; + if (loading || locked) return; setLoading(true); - // latest ux changes - we are forcing user to enable biometric by default const authType = await Authentication.componentAuthenticationType( biometryChoice, rememberMe, ); - if (isComingFromOauthOnboarding) { - authType.oauth2Login = true; - } await trace( { @@ -680,40 +417,32 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { dispatch(setExistingUser(true)); } - if (isComingFromOauthOnboarding) { - track(MetaMetricsEvents.REHYDRATION_COMPLETED, { - account_type: 'social', - biometrics: biometryChoice, - failed_attempts: rehydrationFailedAttempts, - }); - } - - if (passwordLoginAttemptTraceCtxRef.current) { - endTrace({ name: TraceName.OnboardingPasswordLoginAttempt }); - passwordLoginAttemptTraceCtxRef.current = null; - } - endTrace({ name: TraceName.OnboardingExistingSocialLogin }); - endTrace({ name: TraceName.OnboardingJourneyOverall }); + await checkMetricsUISeen(); - if (isComingFromOauthOnboarding) { - await navigateToHome(); - } else { - await checkMetricsUISeen(); - } - - // Only way to land back on Login is to log out, which clears credentials (meaning we should not show biometric button) - setPassword(''); setLoading(false); - setHasBiometricCredentials(false); setError(null); - fieldRef.current?.clear(); } catch (loginErr: unknown) { await handleLoginError(loginErr); } + }, [ + password, + biometryChoice, + rememberMe, + loading, + handleLoginError, + checkMetricsUISeen, + dispatch, + isComingFromVaultRecovery, + ]); + + const handleLogin = async () => { + await onLogin(); + setPassword(''); + setHasBiometricCredentials(false); + fieldRef.current?.clear(); }; - const tryBiometric = async () => { - fieldRef.current?.blur(); + const tryBiometric = useCallback(async () => { try { setLoading(true); await trace( @@ -726,51 +455,41 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { }, ); - if (isComingFromOauthOnboarding) { - await navigateToHome(); - } else { - await checkMetricsUISeen(); - } + await checkMetricsUISeen(); - // Only way to land back on Login is to log out, which clears credentials (meaning we should not show biometric button) - setPassword(''); - setHasBiometricCredentials(false); setLoading(false); - fieldRef.current?.clear(); } catch (tryBiometricError) { setHasBiometricCredentials(true); setLoading(false); Logger.log(tryBiometricError); } + }, [checkMetricsUISeen]); + + const handleTryBiometric = async () => { fieldRef.current?.blur(); + await tryBiometric(); + setPassword(''); + setHasBiometricCredentials(false); + fieldRef.current?.clear(); }; - // show biometric switch to true even if biometric is disabled const shouldRenderBiometricLogin = biometryType; - const renderSwitch = () => { - const handleUpdateRememberMe = (rememberMeChoice: boolean) => { - setRememberMe(rememberMeChoice); - }; - - return ( - - ); - }; + // Redirect users to OAuthRehydration screen + useEffect(() => { + if (isSeedlessPasswordOutdated) { + // User with outdated password + navigation.replace('Rehydrate', { + isSeedlessPasswordOutdated: true, + }); + } + }, [isSeedlessPasswordOutdated, navigation]); const toggleWarningModal = () => { track(MetaMetricsEvents.FORGOT_PASSWORD_CLICKED, {}); navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { screen: Routes.MODAL.DELETE_WALLET, - params: { - oauthLoginSuccess: isComingFromOauthOnboarding, - }, }); }; @@ -781,162 +500,122 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { downloadStateLogs(fullState, false); }; - // for rehydration and when global password is outdated - // hide biometric button const shouldHideBiometricAccessoryButton = !( - !isComingFromOauthOnboarding && - !isSeedlessPasswordOutdated && biometryChoice && biometryType && hasBiometricCredentials && !route?.params?.locked ); - // Component that throws error if needed (to be caught by ErrorBoundary) - const ThrowErrorIfNeeded = () => { - if (errorToThrow) { - throw errorToThrow; - } - return null; - }; - const handlePasswordChange = (newPassword: string) => { setPassword(newPassword); setError(null); }; return ( - - - + + - - - - - - - - {strings('login.title')} - - - - - - - - {!!error && ( - - {error} - - )} - - - - {renderSwitch()} -