diff --git a/.eslintrc.js b/.eslintrc.js index 74e6acd32d4..147bbca7708 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,136 @@ /* eslint-disable import-x/no-commonjs */ + +/** + * Files still allowed to import deprecated `app/util/number/index.js` during + * the BN.js → BigInt migration. Kept in one array so the default import-fence + * override can exclude them while the follow-up override below re-applies only + * the expo-haptics / perps restrictions (see comments on those overrides). + */ +const utilNumberImportBurndownFiles = [ + 'app/component-library/components-temp/CustomSpendCap/CustomInput/CustomInput.tsx', + 'app/component-library/components-temp/CustomSpendCap/CustomSpendCap.tsx', + 'app/component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentage.tsx', + 'app/component-library/components-temp/Price/AggregatedPercentage/utils.ts', + 'app/components/UI/AccountInfoCard/index.js', + 'app/components/UI/AssetOverview/Price/Price.advanced.tsx', + 'app/components/UI/AssetOverview/Price/Price.legacy.tsx', + 'app/components/UI/AssetOverview/utils/marketDetails.ts', + 'app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.tsx', + 'app/components/UI/Bridge/components/QuoteSelectorView/index.tsx', + 'app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts', + 'app/components/UI/Bridge/hooks/useFormattedBalanceWithThreshold/index.ts', + 'app/components/UI/Bridge/hooks/useHasSufficientGas/index.ts', + 'app/components/UI/Bridge/hooks/useInsufficientBalance/index.ts', + 'app/components/UI/Bridge/hooks/useTokenBalanceInUsd/index.ts', + 'app/components/UI/Bridge/hooks/useTokensWithBalance/index.ts', + 'app/components/UI/Bridge/utils/exchange-rates.ts', + 'app/components/UI/Bridge/utils/formatNetworkFee.test.ts', + 'app/components/UI/Bridge/utils/formatNetworkFee.ts', + 'app/components/UI/Bridge/utils/transaction-history.ts', + 'app/components/UI/Card/hooks/useAssetBalances.tsx', + 'app/components/UI/Card/hooks/useCardDelegation.test.ts', + 'app/components/UI/Card/hooks/useCardDelegation.ts', + 'app/components/UI/Card/hooks/useNeedsGasFaucet.ts', + 'app/components/UI/Card/sdk/CardSDK.ts', + 'app/components/UI/CollectibleOverview/index.js', + 'app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx', + 'app/components/UI/Earn/Views/EarnLendingDepositConfirmationView/components/Erc20TokenHero/index.tsx', + 'app/components/UI/Earn/Views/EarnLendingDepositConfirmationView/index.tsx', + 'app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx', + 'app/components/UI/Earn/Views/EarnWithdrawInputView/EarnWithdrawInputView.tsx', + 'app/components/UI/Earn/components/EarnLendingBalance/index.tsx', + 'app/components/UI/Earn/components/Earnings/EarningsHistory/EarningsHistory.utils.ts', + 'app/components/UI/Earn/components/InputDisplay/InputDisplay.test.tsx', + 'app/components/UI/Earn/hooks/useEarnGasFee.ts', + 'app/components/UI/Earn/hooks/useEarnInput.ts', + 'app/components/UI/Earn/hooks/useEarnings.ts', + 'app/components/UI/Earn/hooks/useInput.ts', + 'app/components/UI/Earn/hooks/useMultichainInputHandlers.ts', + 'app/components/UI/Earn/hooks/useMusdBalance.ts', + 'app/components/UI/Earn/hooks/useMusdCtaVisibility.ts', + 'app/components/UI/Earn/utils/number.ts', + 'app/components/UI/Earn/utils/token/index.ts', + 'app/components/UI/Earn/utils/tron.ts', + 'app/components/UI/HardwareWallet/AccountDetails/index.tsx', + 'app/components/UI/Money/constants/activityStyles.ts', + 'app/components/UI/Money/hooks/useMoneyAccountBalance.ts', + 'app/components/UI/Money/utils/moneyActivityFiat.ts', + 'app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx', + 'app/components/UI/Notification/TransactionNotification/index.js', + 'app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx', + 'app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx', + 'app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.tsx', + 'app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.tsx', + 'app/components/UI/Ramp/Aggregator/components/OrderDetails.tsx', + 'app/components/UI/Ramp/Aggregator/components/OrderListItem/OrderListItem.tsx', + 'app/components/UI/Ramp/Aggregator/components/Quote/Quote.tsx', + 'app/components/UI/Ramp/Aggregator/hooks/useBalance.test.ts', + 'app/components/UI/Ramp/Aggregator/hooks/useBalance.ts', + 'app/components/UI/Ramp/Aggregator/hooks/useERC20GasLimitEstimation.ts', + 'app/components/UI/Ramp/Aggregator/hooks/useHandleSuccessfulOrder.ts', + 'app/components/UI/Ramp/Aggregator/hooks/useIntentAmount.ts', + 'app/components/UI/Ramp/Aggregator/utils/index.ts', + 'app/components/UI/Ramp/Deposit/utils/index.ts', + 'app/components/UI/Ramp/utils/getOrderAmount.ts', + 'app/components/UI/Ramp/utils/v2OrderToast.ts', + 'app/components/UI/Stake/components/StakingBalance/StakingBanners/ClaimBanner/ClaimBanner.tsx', + 'app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.test.tsx', + 'app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.tsx', + 'app/components/UI/Stake/components/StakingConfirmation/YouReceiveCard/YouReceiveCard.test.tsx', + 'app/components/UI/Stake/components/StakingConfirmation/YouReceiveCard/YouReceiveCard.tsx', + 'app/components/UI/Stake/hooks/useBalance.ts', + 'app/components/UI/Tokens/util/deriveBalanceFromAssetMarketDetails.test.ts', + 'app/components/UI/Tokens/util/deriveBalanceFromAssetMarketDetails.ts', + 'app/components/UI/TransactionElement/utils-gas.js', + 'app/components/UI/TransactionElement/utils.js', + 'app/components/UI/UrlAutocomplete/Result.tsx', + 'app/components/Views/AssetDetails/index.tsx', + 'app/components/Views/DetectedTokens/components/Token.tsx', + 'app/components/Views/GasEducationCarousel/index.js', + 'app/components/Views/NetworksManagement/NetworkDetailsView/hooks/useNetworkValidation.ts', + 'app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyBottomSheet.ts', + 'app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuyBottomSheet/useQuickBuyQuotes.ts', + 'app/components/Views/SocialLeaderboard/utils/formatters.ts', + 'app/components/Views/UnifiedTransactionsView/useUnifiedTxActions.test.ts', + 'app/components/Views/confirmations/components/gas/max-base-fee-input/max-base-fee-input.tsx', + 'app/components/Views/confirmations/components/gas/priority-fee-input/priority-fee-input.tsx', + 'app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/native-value-display/native-value-display.tsx', + 'app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/value-display/value-display.tsx', + 'app/components/Views/confirmations/components/transactions/custom-amount/custom-amount.tsx', + 'app/components/Views/confirmations/context/send-context/utils.ts', + 'app/components/Views/confirmations/external/staking/hooks/useStakingDetails.ts', + 'app/components/Views/confirmations/hooks/earn/useCustomAmount.tsx', + 'app/components/Views/confirmations/hooks/gas/useCancelSpeedupGas/useCancelSpeedupGas.ts', + 'app/components/Views/confirmations/hooks/send/useBalance.ts', + 'app/components/Views/confirmations/hooks/send/useCurrencyConversions.ts', + 'app/components/Views/confirmations/hooks/send/usePercentageAmount.ts', + 'app/components/Views/confirmations/hooks/useTokenAmount.ts', + 'app/components/Views/confirmations/legacy/components/CustomNonceModal/index.js', + 'app/components/Views/confirmations/legacy/components/WatchAssetRequest/index.js', + 'app/components/Views/confirmations/utils/send.ts', + 'app/components/hooks/useAddressBalance/useAddressBalance.ts', + 'app/components/hooks/useGetFormattedTokensPerChain.tsx', + 'app/components/hooks/useGetTotalFiatBalanceCrossChains.tsx', + 'app/core/Engine/Engine.ts', + 'app/core/Engine/controllers/gas-fee-controller/gas-fee-controller-init.test.ts', + 'app/core/GasPolling/GasPolling.ts', + 'app/core/NotificationManager.js', + 'app/selectors/assets/assets-list.ts', + 'app/selectors/earnController/earn/index.ts', + 'app/selectors/multichain/evm.ts', + // `app/util/**` importers of `./number` or `../number` (resolves to `index.js`); + // same burndown contract as feature files — remove when migrated to + // `../number/bigint` (or `./number/bigint` from `app/util/`). + 'app/util/confirm-tx.js', + 'app/util/conversions.js', + 'app/util/confirmation/gas.ts', + 'app/util/confirmation/transactions.ts', + 'app/util/custom-gas/index.js', + 'app/util/networks/index.js', + 'app/util/transactions/index.js', + 'app/util/transactions/index.test.ts', +]; + module.exports = { root: true, parser: '@typescript-eslint/parser', @@ -42,6 +174,11 @@ module.exports = { 'interface', ], '@typescript-eslint/no-explicit-any': 'error', + // Surface JSDoc @deprecated annotations at every use-site (warn for now; + // ratchet to 'error' once the BN.js → BigInt migration is complete). + // Pairs with the `import-x/no-restricted-paths` fence on + // `app/util/number/index.js` in the app import-fence override below. + '@typescript-eslint/no-deprecated': 'warn', // Under discussion '@typescript-eslint/no-duplicate-enum-values': 'off', '@typescript-eslint/no-shadow': [ @@ -248,6 +385,33 @@ module.exports = { ], }, }, + { + files: ['**/*.test.{js,ts,tsx,jsx}', '**/*.spec.{js,ts,tsx,jsx}'], + plugins: ['jest'], + rules: { + // Prevent new file-based snapshots. Inline snapshots (toMatchInlineSnapshot) + // are still allowed as they keep assertions co-located with the test. + 'jest/no-restricted-matchers': [ + 'error', + { + toMatchSnapshot: + 'Use toMatchInlineSnapshot() or an explicit assertion instead. File-based snapshots are being phased out.', + }, + ], + }, + }, + { + // Matches CODEOWNERS `**/snaps/**` and `**/Snaps/**` (@MetaMask/core-platform). + // ESLint cannot read CODEOWNERS. + files: [ + '**/snaps/**/*.{test,spec}.{js,ts,tsx,jsx}', + '**/Snaps/**/*.{test,spec}.{js,ts,tsx,jsx}', + ], + plugins: ['jest'], + rules: { + 'jest/no-restricted-matchers': 'off', + }, + }, // ── Perps controller Core-alignment override ── // Enforces the same ESLint rules that Core's @metamask/eslint-config // applies to packages/perps-controller so that code written in mobile @@ -466,11 +630,78 @@ module.exports = { }, }, { - files: ['app/**/*.{ts,tsx}'], + // Default app import fences (expo-haptics, perps, deprecated util/number/index.js). + // `excludedFiles` applies to the whole override — listing burn-down paths + // here would incorrectly skip expo/perps for those files, so burn-down is + // excluded from *this* block only and picked up by the next override. + files: ['app/**/*.{ts,tsx,js,jsx}'], excludedFiles: [ - 'app/controllers/perps/**/*.{ts,tsx}', - 'app/util/haptics/**/*.{ts,tsx}', + // Perps controller is exempt from importing itself. + 'app/controllers/perps/**/*.{ts,tsx,js,jsx}', + // Designated expo-haptics wrapper — only this tree may import expo-haptics. + 'app/util/haptics/**/*.{ts,tsx,js,jsx}', + // Legacy number utils + parity tests. + 'app/util/number/**', + // BN.js → BigInt burn-down: still allowed util/number imports; see next override. + ...utilNumberImportBurndownFiles, ], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'expo-haptics', + message: + 'Import from app/util/haptics instead of expo-haptics directly.', + }, + ], + patterns: [ + { + group: ['**/controllers/perps', '**/controllers/perps/**'], + message: + 'Use @metamask/perps-controller instead of relative imports into app/controllers/perps/.', + }, + { + group: ['expo-haptics/*'], + message: + 'Import from app/util/haptics instead of expo-haptics directly.', + }, + ], + }, + ], + // Fences the deprecated `app/util/number/index.js` module. We use + // `import-x/no-restricted-paths` (not `no-restricted-imports`) because + // it resolves each import to its absolute file, which means a single + // entry catches every spelling that lands on `index.js` — bare + // (`from '../util/number'`), explicit (`'../util/number/index'`), + // and explicit-with-extension. Sibling modules like `bigint`, + // `bignumber`, and `subscriptNotation` resolve to different files + // and are unaffected, so no negation list is needed. Inherits the + // burn-down allowlist from this override's `excludedFiles`; the + // burn-down override below intentionally does not re-declare this + // rule, so allow-listed files remain exempt. + 'import-x/no-restricted-paths': [ + 'error', + { + zones: [ + { + target: 'app', + from: 'app/util/number/index.js', + message: + 'app/util/number/index.js is deprecated. Import the BigInt-based replacement from app/util/number/bigint instead. See app/util/number/bigint-migration-reference.test.ts for migration patterns.', + }, + ], + }, + ], + }, + }, + { + // Re-apply expo-haptics + perps only for burn-down files. A second + // override is required because ESLint replaces `no-restricted-imports` + // when the same rule is set again — we cannot use one override with only + // `excludedFiles` for util/number without silently dropping other fences. + files: utilNumberImportBurndownFiles, rules: { 'no-restricted-imports': [ 'error', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6eac88a5261..7f86f316cca 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -43,6 +43,10 @@ app/core/Engine/README.md @MetaMask/mobile-pla app/core/Engine/types.ts @MetaMask/mobile-platform app/core/Engine/controllers/remote-feature-flag-controller/ @MetaMask/mobile-platform app/core/DeeplinkManager @MetaMask/mobile-platform +# Deprecated BN.js helpers. Gated to discourage adding new exports; consumers +# should migrate to app/util/number/bigint. See .eslintrc.js for the import +# fence and bigint-migration-reference.test.ts for migration patterns. +app/util/number/index.js @MetaMask/mobile-platform scripts/build.sh @MetaMask/mobile-platform fingerprint.config.js @MetaMask/mobile-platform builds.yml @MetaMask/mobile-platform @@ -154,7 +158,12 @@ app/components/UI/TemplateRenderer @MetaMask/confirmations @MetaMask/core-plat app/components/UI/Stake @MetaMask/earn app/core/Engine/controllers/earn-controller @MetaMask/earn app/core/Engine/messengers/earn-controller-messenger @MetaMask/earn +app/core/Engine/controllers/chomp-api-service-init* @MetaMask/earn +app/core/Engine/controllers/money-account-upgrade-controller-init* @MetaMask/earn +app/core/Engine/messengers/chomp-api-service-messenger* @MetaMask/earn +app/core/Engine/messengers/money-account-upgrade-controller-messenger* @MetaMask/earn app/selectors/earnController @MetaMask/earn +app/selectors/featureFlagController/chompApi/ @MetaMask/earn **/Earn/** @MetaMask/earn **/earn/** @MetaMask/earn **/Money/** @MetaMask/earn @@ -334,6 +343,7 @@ tests/tools/ @MetaMask/qa tests/websocket/ @MetaMask/qa # QA Team - CI +.github/guidelines/E2E_DECISION_TREE.md @MetaMask/qa .github/actions/smart-e2e-selection/ @MetaMask/qa .github/workflows/ai-pr-risk-analysis.yml @MetaMask/qa .github/workflows/auto-label-not-ready-for-e2e.yml @MetaMask/qa @@ -353,6 +363,7 @@ tests/websocket/ @MetaMask/qa .github/workflows/run-performance-e2e.yml @MetaMask/qa .github/workflows/run-performance-e2e-experimental.yml @MetaMask/qa .github/workflows/run-performance-e2e-release.yml @MetaMask/qa +.github/workflows/run-system-tests.yml @MetaMask/qa .github/scripts/e2e-*.mjs @MetaMask/qa .github/scripts/collect-qa-stats.mjs @MetaMask/qa .github/scripts/generate-regression-slack-summary.mjs @MetaMask/qa diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 39f1b05384f..d54ce5cb39b 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -15,6 +15,11 @@ self-hosted-runner: - "ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg" - "ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-xl" - "low-priority" + # Namespace runner profile labels (INFRA-3592). Format: namespace-profile-. + - "namespace-profile-metamask-ci-linux" + - "namespace-profile-metamask-android-build" + - "namespace-profile-metamask-ios-build" + - "namespace-profile-metamask-ios-e2e" # Configuration variables in array of strings defined in your repository or # organization. `null` means disabling configuration variables check. diff --git a/.github/actions/ci-status-gate/action.yml b/.github/actions/ci-status-gate/action.yml new file mode 100644 index 00000000000..5c5f1434875 --- /dev/null +++ b/.github/actions/ci-status-gate/action.yml @@ -0,0 +1,158 @@ +name: CI Status Gate +description: Evaluate required CI job results and fail on unexpected skips or failed jobs. + +inputs: + needs-json: + description: JSON representation of the calling job's needs context. + required: true + requirement-context-json: + description: JSON representation of get-requirements outputs. + required: true + e2e-job-regex: + description: Regex matching E2E build/test jobs whose skipped result is allowed. Failed or cancelled E2E jobs still fail. + required: false + default: '^e2e-' + event-name: + description: GitHub event name for the current workflow run. + required: true + is-fork: + description: Whether the current pull request originates from a fork. When true, skipped jobs are treated as allowed skips. + required: false + default: 'false' + +runs: + using: composite + steps: + - name: Evaluate CI status + shell: bash + env: + NEEDS_JSON: ${{ inputs.needs-json }} + REQUIREMENT_CONTEXT_JSON: ${{ inputs.requirement-context-json }} + E2E_JOB_REGEX: ${{ inputs.e2e-job-regex }} + EVENT_NAME: ${{ inputs.event-name }} + IS_FORK: ${{ inputs.is-fork }} + run: | + set -euo pipefail + + get_requirement() { + local key="$1" + jq -nr --arg key "$key" 'env.REQUIREMENT_CONTEXT_JSON | fromjson | .[$key] // "false"' + } + + sanitize_markdown_cell() { + local value="$1" + value="${value//$'\n'/ }" + value="${value//|/\\|}" + printf '%s' "$value" + } + + add_summary_row() { + local job_name result decision reason + job_name="$(sanitize_markdown_cell "$1")" + result="$(sanitize_markdown_cell "$2")" + decision="$(sanitize_markdown_cell "$3")" + reason="$(sanitize_markdown_cell "$4")" + + printf '| `%s` | `%s` | %s | %s |\n' \ + "$job_name" "$result" "$decision" "$reason" >> "$summary_file" + } + + mark_failure() { + local message="$1" + failed="true" + echo "::error::$message" + } + + validate_json_type() { + local variable_name="$1" + local expected_type="$2" + + if ! jq -en --arg variable_name "$variable_name" --arg expected_type "$expected_type" \ + '(env[$variable_name] | fromjson | type) == $expected_type' >/dev/null 2>&1; then + echo "::error::$variable_name is not a valid JSON $expected_type" + exit 1 + fi + } + + require_requirement_key() { + local key="$1" + + if ! jq -en --arg key "$key" \ + 'env.REQUIREMENT_CONTEXT_JSON | fromjson | .[$key] != null' >/dev/null 2>&1; then + echo "::error::REQUIREMENT_CONTEXT_JSON is missing or null for required key: $key" + exit 1 + fi + } + + validate_json_type NEEDS_JSON object + validate_json_type REQUIREMENT_CONTEXT_JSON object + + for required_key in skip_everything block_merge_for_e2e_readiness; do + require_requirement_key "$required_key" + done + + skip_everything="$(get_requirement skip_everything)" + block_merge_for_e2e_readiness="$(get_requirement block_merge_for_e2e_readiness)" + + if [[ "$block_merge_for_e2e_readiness" == "true" ]]; then + echo "::error::The 'pr-not-ready-for-e2e' label is still applied. Remove it to trigger E2E tests before merging." + exit 1 + fi + + if [[ "$skip_everything" == "true" ]]; then + echo "skip_everything=true; treating all jobs as passed" + exit 0 + fi + + failed="false" + summary_file="$(mktemp)" + trap 'if [[ -n "${GITHUB_STEP_SUMMARY:-}" && -f "$summary_file" ]]; then cat "$summary_file" >> "$GITHUB_STEP_SUMMARY"; fi; rm -f "$summary_file"' EXIT + job_count=0 + + { + echo "### CI Status Gate" + echo + echo "| Job | Result | Decision | Reason |" + echo "| --- | --- | --- | --- |" + } >> "$summary_file" + + while IFS=$'\t' read -r job_name result; do + job_count=$((job_count + 1)) + + case "$result" in + success) + add_summary_row "$job_name" "$result" "pass" "job succeeded" + ;; + failure|cancelled) + mark_failure "$job_name finished with result: $result" + add_summary_row "$job_name" "$result" "fail" "job did not complete successfully" + ;; + skipped) + if [[ "$job_name" =~ $E2E_JOB_REGEX ]]; then + add_summary_row "$job_name" "$result" "pass" "skipped E2E jobs are allowed" + elif [[ "$EVENT_NAME" == "merge_group" ]]; then + add_summary_row "$job_name" "$result" "pass" "merge queue skip is allowed" + elif [[ "$IS_FORK" == "true" ]]; then + add_summary_row "$job_name" "$result" "pass" "fork-only skip is allowed" + else + mark_failure "$job_name was skipped unexpectedly" + add_summary_row "$job_name" "$result" "fail" "skip was not expected" + fi + ;; + *) + mark_failure "$job_name has unknown result: $result" + add_summary_row "$job_name" "$result" "fail" "job result is unknown" + ;; + esac + done < <(jq -nr 'env.NEEDS_JSON | fromjson | to_entries[] | [.key, (.value.result // "")] | @tsv') + + if [[ "$job_count" -eq 0 ]]; then + echo "::error::NEEDS_JSON does not contain any jobs" + exit 1 + fi + + if [[ "$failed" == "true" ]]; then + exit 1 + fi + + echo "All required jobs passed" diff --git a/.github/actions/setup-e2e-env/action.yml b/.github/actions/setup-e2e-env/action.yml index 227deaba598..e84b7cdda72 100644 --- a/.github/actions/setup-e2e-env/action.yml +++ b/.github/actions/setup-e2e-env/action.yml @@ -116,9 +116,10 @@ runs: if: ${{ inputs.platform == 'android' && inputs.setup-simulator == 'true' && runner.os == 'Linux' }} uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: - timeout_minutes: 3 + timeout_minutes: 5 max_attempts: 3 retry_wait_seconds: 30 + retry_on: error on_retry_command: sudo apt-get clean command: | set -euo pipefail @@ -281,7 +282,11 @@ runs: with: path: | node_modules + .yarn/install-state.gz key: ${{ inputs.cache-prefix }}-yarn-${{ inputs.platform }}-${{ runner.os }}-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ inputs.cache-prefix }}-yarn-${{ inputs.platform }}-${{ runner.os }}- + continue-on-error: true - name: Install JavaScript dependencies with retry id: yarn-install @@ -386,19 +391,19 @@ runs: ${{ runner.os }}-cocoapods-specs- continue-on-error: true - - name: Clear CocoaPods trunk to prevent stale specs - if: ${{ inputs.platform == 'ios' }} - run: pod repo remove trunk || true - shell: bash - - name: Install CocoaPods via bundler if: ${{ inputs.platform == 'ios'}} uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: timeout_minutes: 15 - max_attempts: 2 - retry_wait_seconds: 30 + max_attempts: 3 + retry_wait_seconds: 60 + on_retry_command: | + echo "::warning::CocoaPods install failed, retrying after trunk cleanup..." + pod repo remove trunk || true command: cd ios && bundle exec pod install --repo-update + env: + COCOAPODS_DISABLE_STATS: 'true' - name: Install applesimutils if: ${{ inputs.platform == 'ios' }} diff --git a/.github/guidelines/E2E_DECISION_TREE.md b/.github/guidelines/E2E_DECISION_TREE.md index 96441d8f46b..a9f6ce17e8d 100644 --- a/.github/guidelines/E2E_DECISION_TREE.md +++ b/.github/guidelines/E2E_DECISION_TREE.md @@ -10,7 +10,7 @@ flowchart TD GR -->|PR label: skip-e2e| HS[No E2E] GR -->|PR label: pr-not-ready-for-e2e| L2[No E2E] L2 -->|ignorable-only changes| NoBlock[No merge block] - L2 -->|non-ignorable changes| Skip2[Merge blocked] + L2 -->|non-ignorable changes| Skip2[⛔️ Merge blocked] GR -->|PR ignorable-only changes| Ignorable[No E2E] GR -->|PR has Android-only changes| Android[Android Build + Tests needed] GR -->|PR has iOS-only changes| iOS[iOS Build + Test needed] @@ -33,7 +33,7 @@ To save infra resources while waiting for static analysis findings and potential - E2E tests are skipped and merge is blocked while the label is present, **unless** all changes are ignorable-only. - If E2E tests are needed, they should pass to be able to merge. -## AI test selection +## Smart AI E2E test selection Runs only when all of the following are true: @@ -41,9 +41,22 @@ Runs only when all of the following are true: - No hard E2E skip signal (label `skip-e2e`) - No `skip-smart-e2e-selection` label +## (Exceptional) skip builds and all E2E tests + +- Label `skip-e2e` can be added to the PR to skip E2E tests (and builds) in case of infra issues. +- Using this label should be exceptional in case of CI friction and urgencies. Verify new changes and regressions manually before merging. + ## E2E flakiness detection in PRs Flakiness detection is applied to modified E2E test files in PRs: - Modified E2E test files run twice -- It applies to existing test files as well as new test files +- It applies to existing test files as well as new test files added in the PR +- It can be disabled by adding the label `skip-e2e-flakiness-detection`. Useful when making large refactors or when changes don't pose flakiness risk. + +## Release branches + +PRs to release branches (cherry-picked from main) are exempt from the following: + +- Label `pr-not-ready-for-e2e` is not applied +- Smart AI E2E selection is skipped - all E2E suites are run (if changes are not ignorable-only, e.g. only docs) diff --git a/.github/guidelines/LABELING_GUIDELINES.md b/.github/guidelines/LABELING_GUIDELINES.md index c93d7d4d976..4819b24e8f7 100644 --- a/.github/guidelines/LABELING_GUIDELINES.md +++ b/.github/guidelines/LABELING_GUIDELINES.md @@ -33,7 +33,7 @@ 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-e2e-flakiness-detection**: 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 diff --git a/.github/rules/filter-rules.yml b/.github/rules/filter-rules.yml index d1928e2df4f..b3e9d129805 100644 --- a/.github/rules/filter-rules.yml +++ b/.github/rules/filter-rules.yml @@ -24,6 +24,10 @@ low_level_test_files: &low_level_test_files - '**/*.stories.*' - '**/*.snap' +# LOCALE TRANSLATION FILES +locale_translation_files: &locale_translation_files + - 'locales/languages/**/*.json' + # CONFIG FILES config_files: &config_files - '.eslint*' @@ -47,6 +51,7 @@ e2e_ignorable: - *documentation_files - *asset_files - *low_level_test_files + - *locale_translation_files - *config_files - *ci_files @@ -74,6 +79,7 @@ android_or_ignorable: - *documentation_files - *asset_files - *low_level_test_files + - *locale_translation_files - *config_files - *ci_files @@ -82,6 +88,7 @@ ios_or_ignorable: - *documentation_files - *asset_files - *low_level_test_files + - *locale_translation_files - *config_files - *ci_files diff --git a/.github/scripts/e2e-split-tags-shards.mjs b/.github/scripts/e2e-split-tags-shards.mjs index 590fcb28af0..b178e0f5b28 100644 --- a/.github/scripts/e2e-split-tags-shards.mjs +++ b/.github/scripts/e2e-split-tags-shards.mjs @@ -108,9 +108,9 @@ async function shouldSkipFlakinessDetection() { ); const labels = data?.repository?.pullRequest?.labels?.nodes || []; - const labelFound = labels.some((l) => String(l?.name).toLowerCase() === 'skip-e2e-quality-gate'); + const labelFound = labels.some((l) => String(l?.name).toLowerCase() === 'skip-e2e-flakiness-detection'); if (labelFound) { - console.log('⏭️ Found "skip-e2e-quality-gate" label → SKIPPING flakiness detection'); + console.log('⏭️ Found "skip-e2e-flakiness-detection" label → SKIPPING flakiness detection'); } return labelFound; } catch (e) { diff --git a/.github/workflows/auto-label-not-ready-for-e2e.yml b/.github/workflows/auto-label-not-ready-for-e2e.yml index 58ea09f5dd3..fa74926389e 100644 --- a/.github/workflows/auto-label-not-ready-for-e2e.yml +++ b/.github/workflows/auto-label-not-ready-for-e2e.yml @@ -1,13 +1,12 @@ +# Automatically applies the 'pr-not-ready-for-e2e' label to newly opened PRs, +# but only if the PR is opened between 13:00 and 17:00 UTC (15:00–19:00 CEST). name: Auto-apply pr-not-ready-for-e2e label on: pull_request: types: [opened] - # temporary scoped to these branches to test the whole approach - branches: - - 'test-e2e-readiness-label-**' - # branches-ignore: - # - 'release/**' + branches-ignore: + - 'release/**' jobs: add-label: @@ -16,7 +15,18 @@ jobs: permissions: pull-requests: write steps: + - name: Check current UTC hour + id: time-check + run: | + HOUR=$(date -u +%H) + echo "Current UTC hour: $HOUR" + if [[ $HOUR -ge 13 && $HOUR -lt 17 ]]; then + echo "in_window=true" >> "$GITHUB_OUTPUT" + else + echo "in_window=false" >> "$GITHUB_OUTPUT" + fi - name: Add pr-not-ready-for-e2e label + if: steps.time-check.outputs.in_window == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} diff --git a/.github/workflows/auto-rc-ota-build-core.yml b/.github/workflows/auto-rc-ota-build-core.yml new file mode 100644 index 00000000000..c847996c55a --- /dev/null +++ b/.github/workflows/auto-rc-ota-build-core.yml @@ -0,0 +1,145 @@ +############################################################################################## +# +# Auto RC OTA / build core (reusable) +# +# Shared logic for the Auto RC flow (build-rc-auto.yml): detect an OTA_VERSION bump and either +# dispatch push-eas-update.yml, or fall through to build.yml. +# +# Runway's manual entry workflows no longer use this file — they call the dedicated OTA-only or +# build-only workflows (runway-ota-*.yml, runway-*-builds.yml) directly. Kept here to preserve +# automatic OTA-vs-build detection on every push to a release branch. +# +############################################################################################## +name: Auto RC OTA Build Core + +on: + workflow_call: + inputs: + platform: + description: 'Target platform passed to push-eas-update and build.yml (android or ios)' + required: true + type: string + source_branch: + description: >- + Optional branch, tag, or SHA (Build workflow source_branch). + Empty uses the branch selected in the caller workflow_dispatch "Use workflow from" UI. + required: false + type: string + default: '' + ota_channel: + description: 'push-eas-update channel input (e.g. rc, production)' + required: false + type: string + default: rc + build_name: + description: 'build.yml build_name (e.g. main-rc, main-prod)' + required: false + type: string + default: main-rc + create_production_ota_tag: + description: 'If true, create OTA release tag after production trigger-ota (callers: *production* only)' + required: false + type: boolean + default: false + environment: + description: 'Build environment / track passed to upload-to-testflight (e.g. rc, prod)' + required: false + type: string + default: 'rc' + skip_version_bump: + description: >- + If true, build.yml skips update-latest-build-version. Auto-RC callers set true since the + bump is performed once upstream. + required: false + type: boolean + default: false + outputs: + semantic_version: + description: 'package.json version at the built commit (empty when OTA path taken)' + value: ${{ jobs.trigger-build.outputs.semantic_version }} + ios_version_code: + description: 'iOS CURRENT_PROJECT_VERSION at the built commit (empty when OTA path taken)' + value: ${{ jobs.trigger-build.outputs.ios_version_code }} + android_version_code: + description: 'Android versionCode at the built commit (empty when OTA path taken)' + value: ${{ jobs.trigger-build.outputs.android_version_code }} + +permissions: + contents: write # required by build.yml (update-build-version job) + pull-requests: read + actions: write + id-token: write # required by build.yml + +jobs: + resolve-context: + name: Resolve OTA context + uses: ./.github/workflows/runway-ota-resolve-context.yml + with: + source_branch: ${{ inputs.source_branch }} + secrets: inherit + + validate-ota-pr: + name: Validate PR for OTA + needs: resolve-context + if: needs.resolve-context.outputs.ota_bump == 'true' + runs-on: ubuntu-latest + steps: + - name: Validate PR number + run: | + if [[ -z "${{ needs.resolve-context.outputs.pr_number }}" ]]; then + echo "::error::No PR found for this branch. OTA update requires a PR number." + echo "::error::If you ran the workflow manually (workflow_dispatch), select your release branch in the 'Use workflow from' dropdown (e.g. release/7.71.0), not main." + exit 1 + fi + echo "Using PR #${{ needs.resolve-context.outputs.pr_number }}" + + trigger-ota: + name: Trigger OTA update + needs: [resolve-context, validate-ota-pr] + if: needs.resolve-context.outputs.ota_bump == 'true' + uses: ./.github/workflows/push-eas-update.yml + with: + pr_number: ${{ needs.resolve-context.outputs.pr_number }} + base_branch: ${{ needs.resolve-context.outputs.base_ref }} + message: ${{ needs.resolve-context.outputs.ota_version }} + channel: ${{ inputs.ota_channel }} + platform: ${{ inputs.platform }} + secrets: inherit + + trigger-build: + name: Trigger build mobile app + needs: resolve-context + if: needs.resolve-context.outputs.ota_bump != 'true' + uses: ./.github/workflows/build.yml + with: + build_name: ${{ inputs.build_name }} + platform: ${{ inputs.platform }} + skip_version_bump: ${{ inputs.skip_version_bump }} + source_branch: ${{ inputs.source_branch || github.ref_name }} + upload_to_sentry: true + secrets: inherit + + create-ota-production-tag: + name: Create OTA production release tag + needs: [resolve-context, trigger-ota] + if: ${{ inputs.create_production_ota_tag == true }} + uses: ./.github/workflows/runway-create-ota-production-tag.yml + with: + tag_name: ${{ needs.resolve-context.outputs.ota_version }} + checkout_ref: ${{ inputs.source_branch || github.ref_name }} + secrets: inherit + + upload-ios-testflight: + name: Upload iOS to TestFlight + needs: [trigger-build] + if: ${{ inputs.platform == 'ios' }} + uses: ./.github/workflows/upload-to-testflight.yml + with: + environment: ${{ inputs.environment }} + source_branch: ${{ inputs.source_branch || github.ref_name }} + build_branch: ${{ inputs.source_branch || github.ref_name }} + build_name: ${{ inputs.build_name }} + build_commit_sha: ${{ needs.trigger-build.outputs.built_commit_sha }} + build_version: ${{ needs.trigger-build.outputs.semantic_version }} + build_number: ${{ needs.trigger-build.outputs.ios_version_code }} + secrets: inherit diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 85de7d34ce5..f4d636656fa 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -25,14 +25,19 @@ on: required: false default: 'qa' type: string + runner_provider: + description: Runner provider forwarded from the caller + required: false + type: string + default: current jobs: build-android-apks: name: Build Android E2E APKs - runs-on: ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg # Optimized for lg runner (48GB) with conservative memory settings + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-android-build' || 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' }} # Optimized for lg runner (48GB) with conservative memory settings timeout-minutes: 40 env: - GRADLE_USER_HOME: /home/admin/_work/.gradle + GRADLE_USER_HOME: ${{ inputs.runner_provider == 'namespace' && '/home/runner/_work/.gradle' || '/home/admin/_work/.gradle' }} CACHE_GENERATION: v1 # Increment this to bust the cache (v1, v2, v3, etc.) YARN_ENABLE_GLOBAL_CACHE: 'true' # Enable Yarn global cache for faster installs outputs: @@ -45,7 +50,20 @@ jobs: - name: Checkout repo uses: actions/checkout@v6 + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache + ${{ env.GRADLE_USER_HOME }}/caches + ${{ env.GRADLE_USER_HOME }}/wrapper + - name: Restore .metamask folder (Foundry download cache for install:foundryup) + if: ${{ inputs.runner_provider != 'namespace' }} uses: actions/cache@v4 with: path: .metamask @@ -100,6 +118,7 @@ jobs: fi - name: Restore APKs matching fingerprint from branch cache + if: ${{ inputs.runner_provider != 'namespace' }} id: apk-cache-restore # This action automatically updates the cache at the end of the workflow uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 @@ -116,7 +135,7 @@ jobs: key: android-apk-${{ github.ref_name }}-${{ inputs.build_type }}-${{ env.CACHE_GENERATION }}-${{ steps.generate-fingerprint.outputs.fingerprint }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - name: Restore APKs matching fingerprint from main cache - if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} + if: ${{ inputs.runner_provider != 'namespace' && steps.apk-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} id: apk-cache-restore-main # This will only restore the cache, not update it uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 @@ -136,7 +155,7 @@ jobs: id: gradle-cache-restore # This action automatically updates the cache at the end of the workflow uses: cirruslabs/cache@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 - if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' }} + if: ${{ inputs.runner_provider != 'namespace' && steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' }} env: GRADLE_CACHE_VERSION: 1 with: @@ -154,7 +173,7 @@ jobs: - name: Restore Gradle dependencies from main cache # This will only restore the cache, not update it uses: cirruslabs/cache/restore@bba69c6578b863ad0398ad40567bd2ef70290fe0 # v4 - if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' && steps.gradle-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} + if: ${{ inputs.runner_provider != 'namespace' && steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' && steps.gradle-cache-restore.outputs.cache-hit != 'true' && github.ref_name != 'main' }} env: GRADLE_CACHE_VERSION: 1 with: @@ -170,7 +189,7 @@ jobs: key: gradle-main-${{ env.GRADLE_CACHE_VERSION }}-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - name: Build Android E2E APKs - if: ${{ steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true' }} + if: ${{ inputs.runner_provider == 'namespace' || (steps.apk-cache-restore.outputs.cache-hit != 'true' && steps.apk-cache-restore-main.outputs.cache-hit != 'true') }} run: | echo "🏗 Building Android E2E APKs..." export NODE_OPTIONS="--max-old-space-size=4096" @@ -207,23 +226,13 @@ jobs: SEGMENT_REGULATIONS_ENDPOINT_FLASK: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_FLASK }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - FLASK_IOS_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_IOS_GOOGLE_CLIENT_ID_PROD }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - FLASK_IOS_GOOGLE_REDIRECT_URI_PROD: ${{ secrets.FLASK_IOS_GOOGLE_REDIRECT_URI_PROD }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - FLASK_ANDROID_APPLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_APPLE_CLIENT_ID_PROD }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} - FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} MM_PREDICT_GTM_MODAL_ENABLED: 'false' - name: Repack APK with JS updates using @expo/repack-app - if: ${{ steps.apk-cache-restore.outputs.cache-hit == 'true' || steps.apk-cache-restore-main.outputs.cache-hit == 'true' }} + if: ${{ inputs.runner_provider != 'namespace' && (steps.apk-cache-restore.outputs.cache-hit == 'true' || steps.apk-cache-restore-main.outputs.cache-hit == 'true') }} run: | echo "📦 Repacking APK with updated JavaScript bundle using @expo/repack-app..." # Use the optimized repack script which uses @expo/repack-app @@ -258,16 +267,6 @@ jobs: SEGMENT_REGULATIONS_ENDPOINT_FLASK: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_FLASK }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - FLASK_IOS_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_IOS_GOOGLE_CLIENT_ID_PROD }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - FLASK_IOS_GOOGLE_REDIRECT_URI_PROD: ${{ secrets.FLASK_IOS_GOOGLE_REDIRECT_URI_PROD }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - FLASK_ANDROID_APPLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_APPLE_CLIENT_ID_PROD }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_CLIENT_ID_PROD }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} - FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD: ${{ secrets.FLASK_ANDROID_GOOGLE_SERVER_CLIENT_ID_PROD }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index 8c233861ff7..c3fd9f4420a 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -17,6 +17,11 @@ on: required: false default: 'qa' type: string + runner_provider: + description: Runner provider forwarded from the caller + required: false + type: string + default: current permissions: contents: read @@ -25,7 +30,7 @@ permissions: jobs: build-ios-apps: name: Build iOS E2E Apps - runs-on: ghcr.io/cirruslabs/macos-runner:tahoe-xl + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ios-build' || 'ghcr.io/cirruslabs/macos-runner:tahoe-xl' }} outputs: artifacts-url: ${{ steps.set-artifacts-url.outputs.artifacts-url }} app-uploaded: ${{ steps.upload-app.outcome == 'success' }} @@ -60,11 +65,6 @@ jobs: SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} @@ -194,11 +194,6 @@ jobs: MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} @@ -232,11 +227,6 @@ jobs: SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} diff --git a/.github/workflows/build-rc-auto.yml b/.github/workflows/build-rc-auto.yml index 95a6dba90f1..4d79f0bb1c1 100644 --- a/.github/workflows/build-rc-auto.yml +++ b/.github/workflows/build-rc-auto.yml @@ -8,7 +8,7 @@ # Bitrise "Rolling builds" / "Abort running builds" for one branch + one workflow). # # Version bump runs once (update-latest-build-version.yml), then iOS and Android -# builds are triggered in parallel via runway-ota-build-core.yml (skip_version_bump). +# builds are triggered in parallel via auto-rc-ota-build-core.yml (skip_version_bump). # # The RC build comment includes an AI-generated test plan (inline with collapsible sections). # @@ -104,7 +104,7 @@ jobs: trigger-ios-rc-build: name: Trigger iOS RC Build - uses: ./.github/workflows/runway-ota-build-core.yml + uses: ./.github/workflows/auto-rc-ota-build-core.yml needs: - validate-and-find-pr - update_rc_build_version @@ -117,7 +117,7 @@ jobs: trigger-android-rc-build: name: Trigger Android RC Build - uses: ./.github/workflows/runway-ota-build-core.yml + uses: ./.github/workflows/auto-rc-ota-build-core.yml needs: - validate-and-find-pr - update_rc_build_version diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fdf959acbb5..b779012fce1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,6 +25,11 @@ on: required: false type: boolean default: false + runner_provider: + description: Runner provider forwarded from the caller + required: false + type: string + default: current outputs: build_name: description: 'build_name input passed to this workflow' @@ -63,12 +68,11 @@ on: - main-e2e - main-exp - main-dev + - main-dev-expo - flask-prod - flask-test - flask-e2e - flask-dev - - qa-prod - - qa-dev platform: required: true type: choice @@ -82,6 +86,14 @@ on: required: false type: boolean default: false + runner_provider: + description: Runner provider for this manual trial run + required: true + type: choice + options: + - current + - namespace + default: current permissions: contents: read @@ -105,7 +117,7 @@ jobs: prepare: needs: [update-build-version] if: ${{ always() && !failure() && !cancelled() }} - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} outputs: github_environment: ${{ steps.config.outputs.github_environment }} secrets_json: ${{ steps.config.outputs.secrets_json }} @@ -113,16 +125,17 @@ jobs: signing_aws_role: ${{ steps.config.outputs.signing_aws_role }} signing_aws_secret: ${{ steps.config.outputs.signing_aws_secret }} signing_android_keystore_path: ${{ steps.config.outputs.signing_android_keystore_path }} + script_name: ${{ steps.config.outputs.script_name }} checkout_ref_for_setup: ${{ !inputs.skip_version_bump && needs.update-build-version.outputs.commit-hash || (inputs.source_branch || github.ref_name) }} steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 ref: ${{ !inputs.skip_version_bump && needs.update-build-version.outputs.commit-hash || (inputs.source_branch || github.ref_name) }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' + cache: 'yarn' - run: yarn install --immutable - run: node scripts/validate-build-config.js @@ -142,6 +155,7 @@ jobs: fs.appendFileSync(process.env.GITHUB_OUTPUT, 'signing_aws_role=' + (signing ? signing.aws_role || '' : '') + '\n'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'signing_aws_secret=' + (signing ? signing.aws_secret || '' : '') + '\n'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'signing_android_keystore_path=' + (signing && signing.android_keystore_path ? signing.android_keystore_path : '') + '\n'); + fs.appendFileSync(process.env.GITHUB_OUTPUT, 'script_name=' + (build.script_name || '${{ inputs.build_name }}') + '\n'); " # Setup dependencies (no secrets) - uses reusable workflow on platform-specific runner @@ -163,6 +177,7 @@ jobs: upload-artifact: true artifact-name: node-modules-${{ inputs.build_name }}-${{ matrix.platform }} artifact-retention-days: 1 + runner_provider: ${{ inputs.runner_provider }} # Build build: @@ -173,7 +188,7 @@ jobs: matrix: platform: ${{ inputs.platform == 'both' && fromJSON('["android", "ios"]') || fromJSON(format('["{0}"]', inputs.platform)) }} # Android: Cirrus lg (large) runner for 8GB Gradle heap; iOS: Cirrus macOS Tahoe (has Xcode 26.x) - runs-on: ${{ matrix.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe-xl' || 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' }} + runs-on: ${{ inputs.runner_provider == 'namespace' && (matrix.platform == 'ios' && 'namespace-profile-metamask-ios-build' || 'namespace-profile-metamask-android-build') || (matrix.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe-xl' || 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg') }} environment: ${{ needs.prepare.outputs.github_environment }} steps: - name: Validate version-bump commit @@ -314,6 +329,16 @@ jobs: SECRETS_JSON: ${{ toJSON(secrets) }} run: node scripts/validate-secrets-from-config.js + - name: Restore CocoaPods specs cache (iOS) + if: matrix.platform == 'ios' + uses: actions/cache@v4 + with: + path: ~/.cocoapods/repos + key: ${{ runner.os }}-cocoapods-specs-${{ hashFiles('ios/Podfile.lock') }} + restore-keys: | + ${{ runner.os }}-cocoapods-specs- + continue-on-error: true + # iOS: Install Pods here so generated paths match this runner (setup-node-modules skips pod install with --no-install-pods). - name: Install CocoaPods dependencies (iOS) if: matrix.platform == 'ios' @@ -408,8 +433,8 @@ jobs: command: | export NODE_OPTIONS='--max-old-space-size=4096' export METRO_MAX_WORKERS='4' - BUILD_NAME="${{ inputs.build_name }}" - SCRIPT_NAME="build:${{ matrix.platform }}:${BUILD_NAME//-/:}" + SCRIPT_BASE="${{ needs.prepare.outputs.script_name }}" + SCRIPT_NAME="build:${{ matrix.platform }}:${SCRIPT_BASE//-/:}" yarn "$SCRIPT_NAME" # Rename build artifacts (ios_simulator_path / ios_ipa_path / ios_archive_path / android_*_path outputs) @@ -419,20 +444,14 @@ jobs: run: node scripts/rename-artifacts.js ${{ matrix.platform }} # Upload build artifacts (only if build succeeded) - # ios_simulator_path is set by scripts/rename-artifacts.js (staged under ios-simulator-upload/ - # in CI so upload-artifact uploads a .zip file, not a .app directory). - name: Upload iOS simulator app - if: success() && matrix.platform == 'ios' && env.IS_SIM_BUILD == 'true' + if: matrix.platform == 'ios' && env.IS_SIM_BUILD == 'true' uses: actions/upload-artifact@v4 with: name: ios-app-${{ inputs.build_name }} path: ${{ steps.rename.outputs.ios_simulator_path }} if-no-files-found: error - - name: Remove iOS simulator staging directory - if: success() && matrix.platform == 'ios' && env.IS_SIM_BUILD == 'true' - run: rm -rf ios-simulator-upload - - name: Upload iOS IPA if: matrix.platform == 'ios' && (env.IS_SIM_BUILD != 'true' || env.IS_DEVICE_BUILD == 'true') uses: actions/upload-artifact@v4 @@ -512,7 +531,7 @@ jobs: name: Emit build metadata needs: [prepare, build] if: ${{ !failure() && !cancelled() && needs.prepare.result == 'success' && needs.build.result == 'success' }} - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} outputs: checkout_ref: ${{ steps.meta.outputs.checkout_ref }} built_commit_sha: ${{ steps.meta.outputs.built_commit_sha }} @@ -522,7 +541,6 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 ref: ${{ needs.prepare.outputs.checkout_ref_for_setup }} - name: Setup Node.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47658969fb5..f6c370b2365 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,16 @@ on: # Run the full suite "overnight," once every hour from 2:00am UTC until 6:00am UTC. # This helps to identy the flaky and failed tests on main branch - cron: '0 2-6 * * *' + workflow_dispatch: + inputs: + runner_provider: + description: Runner provider for this manual trial run + required: true + type: choice + options: + - current + - namespace + default: current concurrency: group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }} @@ -25,7 +35,7 @@ jobs: check-diff: name: Check diff - runs-on: macos-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ios-build' || 'macos-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements @@ -76,16 +86,25 @@ jobs: dedupe: name: Dedupe - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements steps: - uses: actions/checkout@v6 + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - cache: yarn + cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }} - name: Install Yarn dependencies with retry uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: @@ -112,16 +131,25 @@ jobs: git-safe-dependencies: name: Run `@lavamoat/git-safe-dependencies` - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements steps: - uses: actions/checkout@v6 + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - cache: yarn + cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }} - name: Install Yarn dependencies with retry uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: @@ -141,7 +169,7 @@ jobs: scripts: name: Run `${{ matrix.scripts }}` - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements @@ -158,10 +186,19 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 2 + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - cache: yarn + cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }} - name: Install Yarn dependencies with retry uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: @@ -184,7 +221,7 @@ jobs: js-bundle-size-check: name: JS bundle size check - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements @@ -193,10 +230,19 @@ jobs: statuses: write steps: - uses: actions/checkout@v6 + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - cache: yarn + cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }} - name: Install Yarn dependencies with retry uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: @@ -394,7 +440,7 @@ jobs: ship-js-bundle-size-check: name: Ship JS bundle size check - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} needs: [js-bundle-size-check] if: ${{ github.ref == 'refs/heads/main' }} steps: @@ -432,7 +478,7 @@ jobs: check-workflows: name: Check workflows - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements @@ -448,7 +494,7 @@ jobs: unit-tests: name: Unit tests (${{ matrix.shard }}) - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements @@ -457,10 +503,19 @@ jobs: shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] steps: - uses: actions/checkout@v6 + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - cache: yarn + cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }} - name: Install Yarn dependencies with retry uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: @@ -476,7 +531,7 @@ jobs: # in sync with the length of matrix.shard - run: yarn test:unit --shard=${{ matrix.shard }}/10 --forceExit --silent --coverageReporters=json --json --outputFile=tests/results/unit-test-results-${{ matrix.shard }}.json env: - NODE_OPTIONS: --max_old_space_size=20480 + NODE_OPTIONS: ${{ inputs.runner_provider == 'namespace' && '--max_old_space_size=12288' || '--max_old_space_size=20480' }} - name: Rename coverage report and extract test count for this shard shell: bash run: | @@ -503,16 +558,35 @@ jobs: # We need to merge both unit and component view tests into a single coverage report so the PR coverage # threshold calculation is accurate. merge-unit-and-component-view-tests: - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} needs: [unit-tests, component-view-tests] if: ${{ !cancelled() && github.event_name != 'merge_group' }} steps: - uses: actions/checkout@v6 + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache + - name: Restore node_modules cache + if: ${{ inputs.runner_provider != 'namespace' }} + id: cache-node-modules + uses: actions/cache@v4 + with: + path: | + node_modules + .yarn/install-state.gz + key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }} - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - cache: yarn + cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }} - name: Install Yarn dependencies with retry + if: ${{ inputs.runner_provider == 'namespace' || steps.cache-node-modules.outputs.cache-hit != 'true' }} uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: timeout_minutes: 10 @@ -611,7 +685,7 @@ jobs: component-view-tests: name: Component view tests - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements @@ -620,11 +694,30 @@ jobs: shard: [1, 2] steps: - uses: actions/checkout@v6 + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache + - name: Restore node_modules cache + if: ${{ inputs.runner_provider != 'namespace' }} + id: cache-node-modules + uses: actions/cache@v4 + with: + path: | + node_modules + .yarn/install-state.gz + key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock') }} - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - cache: yarn + cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }} - name: Install Yarn dependencies with retry + if: ${{ inputs.runner_provider == 'namespace' || steps.cache-node-modules.outputs.cache-hit != 'true' }} uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: timeout_minutes: 10 @@ -641,7 +734,7 @@ jobs: --json \ --outputFile=tests/results/cv-test-results-${{ matrix.shard }}.json env: - NODE_OPTIONS: --max-old-space-size=20480 + NODE_OPTIONS: ${{ inputs.runner_provider == 'namespace' && '--max-old-space-size=12288' || '--max-old-space-size=20480' }} - name: Rename coverage report and extract test count for this shard shell: bash run: | @@ -658,7 +751,7 @@ jobs: smart-e2e-selection: name: 'Smart E2E Selection' - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.run_smart_e2e_selection == 'true' }} needs: - get_requirements @@ -709,6 +802,7 @@ jobs: build_type: 'main' metamask_environment: 'e2e' keystore_target: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit e2e-smoke-tests-android: @@ -726,6 +820,7 @@ jobs: (fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) || '["ALL"]' }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit build-ios-apps: @@ -741,11 +836,13 @@ jobs: id-token: write needs: [get_requirements, smart-e2e-selection] uses: ./.github/workflows/build-ios-e2e.yml + with: + runner_provider: ${{ inputs.runner_provider }} secrets: inherit ios-tests-ready: name: 'iOS Tests Ready' - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ !cancelled() && needs.build-ios-apps.result == 'success' }} needs: [build-ios-apps] steps: @@ -767,6 +864,7 @@ jobs: (fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) || '["ALL"]' }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit # Fixture validation — ensures committed E2E fixtures match the live app state schema @@ -786,11 +884,12 @@ jobs: total_splits: 1 build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit report-fixture-validation: name: 'Report Fixture Validation' - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ !cancelled() && needs.validate-e2e-fixtures.result != 'skipped' }} needs: [validate-e2e-fixtures] permissions: @@ -817,13 +916,22 @@ jobs: sonar-cloud: name: SonarCloud analysis - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} needs: merge-unit-and-component-view-tests if: ${{ !cancelled() && github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork }} steps: - uses: actions/checkout@v6 with: fetch-depth: 0 # SonarCloud needs a full checkout to perform necessary analysis + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' @@ -862,7 +970,7 @@ jobs: sonar-cloud-quality-gate-status: name: SonarCloud quality gate status - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} needs: sonar-cloud if: ${{ !cancelled() && github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork }} steps: @@ -917,114 +1025,45 @@ jobs: fi fi - all-jobs-pass: - name: All jobs pass - runs-on: ubuntu-latest - if: ${{ !cancelled() }} - needs: - [ - check-diff, - dedupe, - scripts, - unit-tests, - component-view-tests, - check-workflows, - js-bundle-size-check, - sonar-cloud-quality-gate-status, - ] - outputs: - ALL_JOBS_PASSED: ${{ steps.jobs-passed-status.outputs.ALL_JOBS_PASSED }} - steps: - - name: Set jobs passed status - id: jobs-passed-status - env: - NEEDS_CONTEXT: ${{ toJSON(needs) }} - EVENT_NAME: ${{ github.event_name }} - IS_FORK: ${{ github.event.pull_request.head.repo.fork }} - run: | - # Check results of all required jobs dynamically - # On merge_group events, "skipped" is acceptable (some jobs intentionally skip) - # On fork PRs, "skipped" is acceptable (secret-dependent jobs are intentionally skipped) - # On other events (push to main), all jobs must succeed - - FAILED="false" - - while read -r job_name result; do - if [[ "$result" == "failure" ]] || [[ "$result" == "cancelled" ]]; then - echo "::error::Job '$job_name' failed with result: $result" - FAILED="true" - elif [[ "$result" == "skipped" ]]; then - if [[ "$EVENT_NAME" == "merge_group" ]] || [[ "$IS_FORK" == "true" ]]; then - echo "Job '$job_name' was skipped (OK for merge_group events and fork PRs)" - else - echo "::error::Job '$job_name' was unexpectedly skipped on $EVENT_NAME event" - FAILED="true" - fi - else - echo "Job '$job_name' passed" - fi - done < <(echo "$NEEDS_CONTEXT" | jq -r 'to_entries[] | "\(.key) \(.value.result)"') - - if [[ "$FAILED" == "true" ]]; then - echo "Some required jobs failed" - exit 1 - fi - - echo "ALL_JOBS_PASSED=true" >> "$GITHUB_OUTPUT" - check-all-jobs-pass: name: Check all jobs pass - if: ${{ !cancelled() }} - runs-on: ubuntu-latest + # Run the aggregate gate even when optional dependencies are skipped. + # The composite action decides which skipped jobs are acceptable. + if: ${{ always() && !cancelled() }} + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} needs: - get_requirements - - all-jobs-pass + - check-diff + - dedupe + - scripts + - unit-tests + - component-view-tests + - check-workflows + - js-bundle-size-check + - sonar-cloud-quality-gate-status + - build-android-apks + - build-ios-apps - e2e-smoke-tests-android - e2e-smoke-tests-ios - env: - SKIPPED: ${{ needs.get_requirements.outputs.skip_everything == 'true' }} steps: - - name: Block merge while pr-not-ready-for-e2e label is applied - if: ${{ needs.get_requirements.outputs.block_merge_for_e2e_readiness == 'true' }} - run: | - echo "::error::The 'pr-not-ready-for-e2e' label is still applied. Remove it to trigger E2E tests before merging." - exit 1 - - run: | - # If the merge queue was skipped, consider all jobs as passed - if [[ "$SKIPPED" == "true" ]]; then - echo "Merge queue skipped, considering all jobs as passed" - exit 0 - fi - - # Check if all non-E2E jobs passed - if [[ "${{ needs.all-jobs-pass.outputs.ALL_JOBS_PASSED }}" != "true" ]]; then - echo "Non-E2E jobs failed" - exit 1 - fi - - # Check E2E jobs only if they should have run - if [[ "${{ needs.get_requirements.outputs.skip_e2e }}" != "true" ]]; then - # Accept both 'success' and 'skipped' as valid results - # 'skipped' occurs during merge_group events or when jobs are intentionally skipped - # Only fail on 'failure' or 'cancelled' - ANDROID_RESULT="${{ needs.e2e-smoke-tests-android.result }}" - if [[ "$ANDROID_RESULT" == "failure" ]] || [[ "$ANDROID_RESULT" == "cancelled" ]]; then - echo "Android E2E tests failed (result: $ANDROID_RESULT)" - exit 1 - fi - - IOS_RESULT="${{ needs.e2e-smoke-tests-ios.result }}" - if [[ "$IOS_RESULT" == "failure" ]] || [[ "$IOS_RESULT" == "cancelled" ]]; then - echo "iOS E2E tests failed (result: $IOS_RESULT)" - exit 1 - fi - fi + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + sparse-checkout: | + .github/actions/ci-status-gate - echo "All required jobs passed" + - name: Evaluate CI status + uses: ./.github/actions/ci-status-gate + with: + needs-json: ${{ toJSON(needs) }} + requirement-context-json: ${{ toJSON(needs.get_requirements.outputs) }} + e2e-job-regex: '^(build-android-apks|build-ios-apps|e2e-smoke-tests-android|e2e-smoke-tests-ios)$' + event-name: ${{ github.event_name }} + is-fork: ${{ github.event.pull_request.head.repo.fork == true }} log-merge-group-failure: name: Log merge group failure - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} # Only run this job if the merge group event fails, skip on forks if: ${{ github.event_name == 'merge_group' && failure() }} needs: diff --git a/.github/workflows/crowdin-rc-download-translations.yml b/.github/workflows/crowdin-rc-download-translations.yml new file mode 100644 index 00000000000..cef063ccb79 --- /dev/null +++ b/.github/workflows/crowdin-rc-download-translations.yml @@ -0,0 +1,88 @@ +name: Crowdin Download Approved RC Translations + +on: + workflow_dispatch: + +jobs: + find-rc-branches: + name: Find release candidate branches + if: ${{ github.ref_name == 'main' }} + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + branches: ${{ steps.find-branches.outputs.branches }} + steps: + - name: Find release candidate branches + id: find-branches + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + base: 'stable', + state: 'open', + }); + + const releaseBranchRegex = /^release\/[0-9]+\.[0-9]+\.[0-9]+$/; + + const branches = prs + .map((pr) => pr.head.ref) + .filter((branchName) => releaseBranchRegex.test(branchName)); + + core.info(`Found release branches: ${branches.join(',')}`); + core.setOutput('branches', JSON.stringify(branches)); + + + download-translations: + name: Download translations + # Use `!cancelled()` to ensure this runs even when `find-rc-branches` is skipped + if: ${{ !cancelled() }} + needs: [find-rc-branches] + runs-on: ubuntu-latest + timeout-minutes: 3 + + strategy: + matrix: + # If run on `main`, we download translations for all RCs + # If run on a non-default branch, we just download translations for that individual branch + branch: >- + ${{ + github.ref_name == 'main' && + fromJson(needs.find-rc-branches.outputs.branches) || + fromJson(format('[{0}]', github.ref_name)) + }} + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + ref: ${{ matrix.branch }} + # Use PAT to ensure that the commit later can trigger status check workflows + token: ${{ secrets.METAMASKBOT_CROWDIN_TOKEN }} + + - name: crowdin download approved translations + uses: crowdin/github-action@a3160b9e5a9e00739392c23da5e580c6cabe526d + with: + upload_sources: false + upload_translations: false # disabled to prevent translations overwriting Blends translations + download_translations: true # created separate action to pull down completed translations + export_only_approved: true + + crowdin_branch_name: ${{ matrix.branch }} + pull_request_base_branch_name: ${{ matrix.branch }} + localization_branch_name: l10n_crowdin_translations_${{ matrix.branch }} + pull_request_title: 'chore: New Crowdin Translations for ${{ matrix.branch }}' + skip_ref_checkout: true + # This will be in dry-run mode until after we've tested this on `main` + dryrun_action: true + + github_user_name: metamaskbot + github_user_email: metamaskbot@users.noreply.github.com + env: + GITHUB_TOKEN: ${{ secrets.METAMASKBOT_CROWDIN_TOKEN }} + GITHUB_ACTOR: metamaskbot + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.github/workflows/crowdin-rc-upload-sources.yml b/.github/workflows/crowdin-rc-upload-sources.yml new file mode 100644 index 00000000000..837d91dd863 --- /dev/null +++ b/.github/workflows/crowdin-rc-upload-sources.yml @@ -0,0 +1,33 @@ +name: Crowdin Upload RC Sources + +on: + workflow_dispatch: + +jobs: + upload-sources: + runs-on: ubuntu-latest + timeout-minutes: 3 + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + # Use PAT to ensure that the commit later can trigger status check workflows + token: ${{ secrets.METAMASKBOT_CROWDIN_TOKEN }} + + - name: crowdin upload sources + uses: crowdin/github-action@a3160b9e5a9e00739392c23da5e580c6cabe526d + with: + crowdin_branch_name: ${{ github.ref_name }} + upload_sources: true + upload_translations: false # disabled to prevent translations overwriting Blends translations + download_translations: false # created separate action to pull down completed translations + # This will be in dry-run mode until after we've tested this on `main` + dryrun_action: true + github_user_name: metamaskbot + github_user_email: metamaskbot@users.noreply.github.com + env: + GITHUB_TOKEN: ${{ secrets.METAMASKBOT_CROWDIN_TOKEN }} + GITHUB_ACTOR: metamaskbot + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.github/workflows/expo-dev-build.yml b/.github/workflows/expo-dev-build.yml index 0c818c4a479..1c078efa825 100644 --- a/.github/workflows/expo-dev-build.yml +++ b/.github/workflows/expo-dev-build.yml @@ -2,11 +2,12 @@ # # Expo Dev Build — replaces the Bitrise expo_dev_pipeline. # -# Triggered on every push to main. Builds the main-dev configuration (Debug, simulator) +# Triggered on every push to main. Builds the main-dev-expo configuration (Debug) # for both iOS and Android using the reusable build.yml workflow. # -# No version bump or TestFlight upload — this is a dev/simulator build only. -# Artifacts (iOS .app zip + Android APK) are uploaded as GitHub Actions artifacts. +# Produces simulator .app + device IPA (iOS) and APK + test APK (Android). +# No version bump or TestFlight upload. +# Artifacts are uploaded as GitHub Actions artifacts. # # [skip ci] commits (e.g. version bumps) are automatically skipped by GitHub Actions. # @@ -18,6 +19,15 @@ on: branches: - main workflow_dispatch: + inputs: + runner_provider: + description: Runner provider for this manual trial run + required: true + type: choice + options: + - current + - namespace + default: current permissions: contents: write @@ -25,10 +35,11 @@ permissions: jobs: build-dev: - name: Expo dev build (main-dev) + name: Expo dev build (main-dev-expo) uses: ./.github/workflows/build.yml with: - build_name: main-dev + build_name: main-dev-expo platform: both skip_version_bump: true + runner_provider: ${{ inputs.runner_provider }} secrets: inherit diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 2204203dbf8..26b1fbac786 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -81,6 +81,92 @@ jobs: source_branch: ${{ needs.android-rc-branch.outputs.build_branch }} secrets: inherit + # ── Sentry Size Analysis: upload RC builds ──────────────────────────── + # Runs in parallel with cleanup/downstream jobs so it never blocks the + # critical path. Uploads nightly RC artifacts so Sentry can track binary + # size over time, surface actionable insights, and auto-compare nightlies. + size-analysis-ios: + name: Sentry Size Analysis (iOS) + needs: [ios-rc] + if: success() + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 2 + + - name: Resolve base SHA + id: base + run: echo "sha=$(git rev-parse ${{ github.sha }}~1)" >> "$GITHUB_OUTPUT" + + - name: Install sentry-cli + run: curl -sL https://sentry.io/get-cli/ | bash + + - name: Download iOS xcarchive + uses: actions/download-artifact@v4 + with: + name: ios-xcarchive-main-rc + path: ./build-artifacts/ + + - name: Unzip xcarchive + run: | + cd ./build-artifacts + unzip -q ./*.xcarchive.zip + rm -f ./*.xcarchive.zip + + - name: Upload to Sentry Size Analysis + env: + SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} + run: | + sentry-cli build upload ./build-artifacts/*.xcarchive \ + --org metamask \ + --project metamask-mobile \ + --build-configuration Release-iOS \ + --head-sha "${{ github.sha }}" \ + --base-sha "${{ steps.base.outputs.sha }}" \ + --head-ref main \ + --vcs-provider github \ + --head-repo-name MetaMask/metamask-mobile + + size-analysis-android: + name: Sentry Size Analysis (Android) + needs: [android-rc] + if: success() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 2 + + - name: Resolve base SHA + id: base + run: echo "sha=$(git rev-parse ${{ github.sha }}~1)" >> "$GITHUB_OUTPUT" + + - name: Install sentry-cli + run: curl -sL https://sentry.io/get-cli/ | bash + + - name: Download Android AAB + uses: actions/download-artifact@v4 + with: + name: android-aab-main-rc + path: ./build-artifacts/ + + - name: Upload to Sentry Size Analysis + env: + SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} + run: | + sentry-cli build upload ./build-artifacts/*.aab \ + --org metamask \ + --project metamask-mobile \ + --build-configuration Release-Android \ + --head-sha "${{ github.sha }}" \ + --base-sha "${{ steps.base.outputs.sha }}" \ + --head-ref main \ + --vcs-provider github \ + --head-repo-name MetaMask/metamask-mobile + # ── Cleanup Android ephemeral branches ───────────────────────────────── # iOS ephemeral branches are cleaned up by build-and-upload-to-testflight.yml. cleanup: diff --git a/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml b/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml index 2986f7fae76..24cea0920f3 100644 --- a/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml +++ b/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml @@ -14,7 +14,7 @@ jobs: if: >- github.event.label.name == 'skip-smart-e2e-selection' || github.event.label.name == 'skip-e2e' || - github.event.label.name == 'skip-e2e-quality-gate' || + github.event.label.name == 'skip-e2e-flakiness-detection' || github.event.label.name == 'pr-not-ready-for-e2e' runs-on: ubuntu-latest permissions: @@ -92,5 +92,8 @@ jobs: run: | RUN_ID="${{ steps.find.outputs.run_id }}" echo "Re-running workflow $RUN_ID..." - gh run rerun "$RUN_ID" --repo "$REPO" - echo "CI workflow re-triggered successfully" + if gh run rerun "$RUN_ID" --repo "$REPO"; then + echo "CI workflow re-triggered successfully" + else + echo "Rerun not possible (run may not be in a retriable state)" + fi diff --git a/.github/workflows/run-e2e-api-specs.yml b/.github/workflows/run-e2e-api-specs.yml index 94c5edfbb81..f90fa6ad24b 100644 --- a/.github/workflows/run-e2e-api-specs.yml +++ b/.github/workflows/run-e2e-api-specs.yml @@ -23,8 +23,6 @@ jobs: SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SOLANA_E2E_TEST_SRP: ${{ secrets.MM_SOLANA_E2E_TEST_SRP }} diff --git a/.github/workflows/run-e2e-regression-tests-android.yml b/.github/workflows/run-e2e-regression-tests-android.yml index fb0a2a7b7b9..a39a83f7a68 100644 --- a/.github/workflows/run-e2e-regression-tests-android.yml +++ b/.github/workflows/run-e2e-regression-tests-android.yml @@ -7,6 +7,14 @@ on: description: 'Send Slack notification even when all tests pass' type: boolean default: false + runner_provider: + description: Runner provider for this manual trial run + required: true + type: choice + options: + - current + - namespace + default: current permissions: contents: read @@ -25,6 +33,7 @@ jobs: build_type: 'main' metamask_environment: 'e2e' keystore_target: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-confirmations-android: @@ -41,6 +50,7 @@ jobs: test_suite_tag: 'RegressionConfirmations' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-trade-android: @@ -57,6 +67,7 @@ jobs: test_suite_tag: 'RegressionTrade' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-wallet-platform-android: @@ -73,6 +84,7 @@ jobs: test_suite_tag: 'RegressionWalletPlatform' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-accounts-android: @@ -89,6 +101,7 @@ jobs: test_suite_tag: 'RegressionAccounts' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-network-abstraction-android: @@ -105,6 +118,7 @@ jobs: test_suite_tag: 'RegressionNetworkAbstractions' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-network-expansion-android: @@ -121,6 +135,7 @@ jobs: test_suite_tag: 'RegressionNetworkExpansion' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-assets-android: @@ -137,6 +152,7 @@ jobs: test_suite_tag: 'RegressionAssets' split_number: ${{ matrix.split }} total_splits: 2 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-ux-android: @@ -153,11 +169,12 @@ jobs: test_suite_tag: 'RegressionWalletUX' split_number: ${{ matrix.split }} total_splits: 1 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit report-android-regression-tests: name: Report Android Regression Tests - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: always() needs: - build-android-apks diff --git a/.github/workflows/run-e2e-regression-tests-ios.yml b/.github/workflows/run-e2e-regression-tests-ios.yml index 29343a469b7..295069cc952 100644 --- a/.github/workflows/run-e2e-regression-tests-ios.yml +++ b/.github/workflows/run-e2e-regression-tests-ios.yml @@ -9,6 +9,14 @@ on: description: 'Send Slack notification even when all tests pass' type: boolean default: false + runner_provider: + description: Runner provider for this manual trial run + required: true + type: choice + options: + - current + - namespace + default: current permissions: contents: read @@ -23,6 +31,8 @@ jobs: contents: read id-token: write uses: ./.github/workflows/build-ios-e2e.yml + with: + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-confirmations-ios: @@ -39,6 +49,7 @@ jobs: test_suite_tag: 'RegressionConfirmations' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-trade-ios: @@ -55,6 +66,7 @@ jobs: test_suite_tag: 'RegressionTrade' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-wallet-platform-ios: @@ -71,6 +83,7 @@ jobs: test_suite_tag: 'RegressionWalletPlatform' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-accounts-ios: @@ -87,6 +100,7 @@ jobs: test_suite_tag: 'RegressionAccounts' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-network-abstraction-ios: @@ -103,6 +117,7 @@ jobs: test_suite_tag: 'RegressionNetworkAbstractions' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-network-expansion-ios: @@ -119,6 +134,7 @@ jobs: test_suite_tag: 'RegressionNetworkExpansion' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-assets-ios: @@ -135,6 +151,7 @@ jobs: test_suite_tag: 'RegressionAssets' split_number: ${{ matrix.split }} total_splits: 2 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-ux-ios: @@ -151,11 +168,12 @@ jobs: test_suite_tag: 'RegressionWalletUX' split_number: ${{ matrix.split }} total_splits: 1 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit report-ios-regression-tests: name: Report iOS Regression Tests - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: always() needs: - regression-confirmations-ios diff --git a/.github/workflows/run-e2e-smoke-tests-android.yml b/.github/workflows/run-e2e-smoke-tests-android.yml index a8dc3575fbc..f40ccf3d1e9 100644 --- a/.github/workflows/run-e2e-smoke-tests-android.yml +++ b/.github/workflows/run-e2e-smoke-tests-android.yml @@ -13,6 +13,11 @@ on: required: false type: string default: '' + runner_provider: + description: Runner provider forwarded from the caller + required: false + type: string + default: current permissions: contents: read @@ -33,6 +38,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 2 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit stake-android-smoke: @@ -49,6 +55,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit perps-android-smoke: @@ -65,6 +72,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit wallet-platform-android-smoke: @@ -81,6 +89,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 3 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit identity-android-smoke: @@ -97,6 +106,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 2 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit accounts-android-smoke: @@ -113,6 +123,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit network-abstraction-android-smoke: @@ -129,6 +140,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 2 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit network-expansion-android-smoke: @@ -145,6 +157,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 2 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit confirmations-android-smoke: @@ -161,6 +174,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 4 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit prediction-market-android-smoke: @@ -177,6 +191,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit money-android-smoke: @@ -193,6 +208,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit multichain-api-android-smoke: @@ -209,6 +225,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit seedless-onboarding-android-smoke: @@ -225,6 +242,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit browser-android-smoke: @@ -241,6 +259,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit snaps-android-smoke: @@ -257,11 +276,12 @@ jobs: split_number: ${{ matrix.split }} total_splits: 4 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit report-android-smoke-tests: name: Report Android Smoke Tests - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ !cancelled() && inputs.selected_tags != '[]' && inputs.selected_tags != '["FlaskBuildTests"]' }} needs: - swap-android-smoke diff --git a/.github/workflows/run-e2e-smoke-tests-ios.yml b/.github/workflows/run-e2e-smoke-tests-ios.yml index 05b82e8bf05..19fb3ee5d7c 100644 --- a/.github/workflows/run-e2e-smoke-tests-ios.yml +++ b/.github/workflows/run-e2e-smoke-tests-ios.yml @@ -13,6 +13,11 @@ on: required: false type: string default: '' + runner_provider: + description: Runner provider forwarded from the caller + required: false + type: string + default: current permissions: contents: read @@ -35,6 +40,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit swap-ios-smoke: @@ -53,6 +59,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit stake-ios-smoke: @@ -71,6 +78,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit perps-ios-smoke: @@ -89,6 +97,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit wallet-platform-ios-smoke: @@ -107,6 +116,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit identity-ios-smoke: @@ -125,6 +135,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit accounts-ios-smoke: @@ -143,6 +154,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit network-abstraction-ios-smoke: @@ -161,6 +173,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit network-expansion-ios-smoke: @@ -179,6 +192,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit prediction-market-ios-smoke: @@ -197,6 +211,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit money-ios-smoke: @@ -215,6 +230,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit multichain-api-ios-smoke: @@ -233,6 +249,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit seedless-onboarding-ios-smoke: @@ -251,6 +268,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit browser-ios-smoke: @@ -269,6 +287,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit snaps-ios-smoke: @@ -287,11 +306,12 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit report-ios-smoke-tests: name: Report iOS Smoke Tests - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ !cancelled() && inputs.selected_tags != '[]' && inputs.selected_tags != '["FlaskBuildTests"]' }} needs: - confirmations-ios-smoke diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml index cf7e218be2a..efea199b9dd 100644 --- a/.github/workflows/run-e2e-workflow.yml +++ b/.github/workflows/run-e2e-workflow.yml @@ -53,11 +53,16 @@ on: required: false type: string default: 'main-' + runner_provider: + description: Runner provider forwarded from the caller + required: false + type: string + default: current jobs: test-e2e-mobile: name: ${{ inputs.test-suite-name }} - runs-on: ${{ inputs.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe' || 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' }} + runs-on: ${{ inputs.runner_provider == 'namespace' && (inputs.platform == 'ios' && 'namespace-profile-metamask-ios-e2e' || 'namespace-profile-metamask-android-build') || (inputs.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe' || 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg') }} outputs: apk-target-path: ${{ steps.determine-target-paths.outputs.apk-target-path }} test-apk-target-path: ${{ steps.determine-target-paths.outputs.test-apk-target-path }} @@ -84,11 +89,6 @@ jobs: SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/run-performance-e2e.yml b/.github/workflows/run-performance-e2e.yml index a8aef5ffc6b..114bff17c87 100644 --- a/.github/workflows/run-performance-e2e.yml +++ b/.github/workflows/run-performance-e2e.yml @@ -142,6 +142,7 @@ jobs: needs: [determine-branch-name] outputs: android_matrix: ${{ steps.read-matrix.outputs.android_matrix }} + android_mm_connect_matrix: ${{ steps.read-matrix.outputs.android_mm_connect_matrix }} ios_matrix: ${{ steps.read-matrix.outputs.ios_matrix }} steps: - name: Checkout code @@ -165,18 +166,23 @@ jobs: fi ANDROID_MATRIX=$(jq ".android_devices | $FILTER" "$FILE") + ANDROID_MM_CONNECT_MATRIX=$(jq '[.android_devices[] | select(.name | contains("Samsung"))]' "$FILE") IOS_MATRIX=$(jq ".ios_devices | $FILTER" "$FILE") { echo "android_matrix<> "$GITHUB_OUTPUT" echo "Selected: $(echo "$ANDROID_MATRIX" | jq length) Android, $(echo "$IOS_MATRIX" | jq length) iOS" + echo "Selected for Android MM-Connect: $(echo "$ANDROID_MM_CONNECT_MATRIX" | jq length)" set-build-names: name: Set Unified BrowserStack Build Names @@ -333,7 +339,7 @@ jobs: name: Fetch RN Playground APK and Upload to BrowserStack runs-on: ubuntu-latest needs: [wait-for-onboarding-completion] - if: always() && !cancelled() + if: always() && !cancelled() && (inputs.build_variant || 'rc') == 'rc' outputs: browserstack-playground-url: ${{ steps.upload-playground.outputs.browserstack-url }} steps: @@ -376,13 +382,13 @@ jobs: set-build-names, determine-branch-name, ] - if: always() && !cancelled() && (needs.trigger-android-dual-versions.result == 'skipped' || needs.trigger-android-dual-versions.result == 'success') && (inputs.browserstack_app_url_android_imported_wallet != '' || needs.trigger-android-dual-versions.outputs.with-srp-browserstack-url != '') + if: always() && !cancelled() && (inputs.build_variant || 'rc') == 'rc' && (needs.trigger-android-dual-versions.result == 'skipped' || needs.trigger-android-dual-versions.result == 'success') && (inputs.browserstack_app_url_android_imported_wallet != '' || needs.trigger-android-dual-versions.outputs.with-srp-browserstack-url != '') with: platform: android build_type: mm-connect sentry_target: ${{ inputs.sentry_target || 'test' }} build_variant: ${{ inputs.build_variant || 'rc' }} - device_matrix: ${{ needs.read-device-matrix.outputs.android_matrix }} + device_matrix: ${{ needs.read-device-matrix.outputs.android_mm_connect_matrix }} browserstack_app_url: ${{ needs.trigger-android-dual-versions.outputs.with-srp-browserstack-url || inputs.browserstack_app_url_android_imported_wallet }} app_version: ${{ needs.trigger-android-dual-versions.outputs.with-srp-version || 'Manual-Input' }} branch_name: ${{ needs.determine-branch-name.outputs.branch_name }} diff --git a/.github/workflows/run-system-tests.yml b/.github/workflows/run-system-tests.yml new file mode 100644 index 00000000000..9e037e8ac72 --- /dev/null +++ b/.github/workflows/run-system-tests.yml @@ -0,0 +1,638 @@ +# Nightly System Tests +# +# Runs the 18 performance test specs as functional system tests (no quality gates, +# no Sentry, no performance metrics). Validates that core user flows (login, +# onboarding, swaps, etc.) work on real devices via BrowserStack. +# +# Triggers: +# - Daily at 5 AM UTC (1 hour after nightly build starts) +# - Manually via workflow_dispatch (optionally with pre-built BrowserStack URLs) + +name: Nightly System Tests + +on: + schedule: + - cron: '0 5 * * *' # 5 AM UTC daily + workflow_dispatch: + inputs: + browserstack_app_url_android: + description: 'BrowserStack Android App URL for login tests — with-SRP build (bs://...)' + required: false + type: string + browserstack_app_url_ios: + description: 'BrowserStack iOS App URL for login tests — with-SRP build (bs://...)' + required: false + type: string + browserstack_clean_app_url_android: + description: 'BrowserStack Android Clean App URL for onboarding tests (bs://...)' + required: false + type: string + browserstack_clean_app_url_ios: + description: 'BrowserStack iOS Clean App URL for onboarding tests (bs://...)' + required: false + type: string + +permissions: + contents: write + id-token: write + +concurrency: + group: system-tests-${{ github.ref }} + cancel-in-progress: true +jobs: + build-with-srp: + name: Build with-SRP (login tests) + if: >- + github.event_name != 'workflow_dispatch' + || (!inputs.browserstack_app_url_android + && !inputs.browserstack_app_url_ios + && !inputs.browserstack_clean_app_url_android + && !inputs.browserstack_clean_app_url_ios) + uses: ./.github/workflows/build.yml + with: + build_name: main-rc-with-srp + platform: both + skip_version_bump: true + source_branch: ${{ github.ref_name }} + secrets: inherit + + build-clean: + name: Build clean RC (onboarding tests) + if: >- + github.event_name != 'workflow_dispatch' + || (!inputs.browserstack_app_url_android + && !inputs.browserstack_app_url_ios + && !inputs.browserstack_clean_app_url_android + && !inputs.browserstack_clean_app_url_ios) + uses: ./.github/workflows/build.yml + with: + build_name: main-rc + platform: both + skip_version_bump: true + source_branch: ${{ github.ref_name }} + secrets: inherit + + upload-to-browserstack: + name: Upload builds to BrowserStack + needs: [build-with-srp, build-clean] + if: always() && !cancelled() + runs-on: ubuntu-latest + outputs: + android-login-url: ${{ steps.resolve.outputs.android-login-url }} + ios-login-url: ${{ steps.resolve.outputs.ios-login-url }} + android-onboarding-url: ${{ steps.resolve.outputs.android-onboarding-url }} + ios-onboarding-url: ${{ steps.resolve.outputs.ios-onboarding-url }} + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + steps: + - name: Download Android APK (with-SRP) + if: needs.build-with-srp.result == 'success' + uses: actions/download-artifact@v8 + with: + name: android-apk-main-rc-with-srp + path: artifacts/android-with-srp + + - name: Download iOS IPA (with-SRP) + if: needs.build-with-srp.result == 'success' + uses: actions/download-artifact@v8 + with: + name: ios-ipa-main-rc-with-srp + path: artifacts/ios-with-srp + + - name: Download Android APK (clean) + if: needs.build-clean.result == 'success' + uses: actions/download-artifact@v8 + with: + name: android-apk-main-rc + path: artifacts/android-clean + + - name: Download iOS IPA (clean) + if: needs.build-clean.result == 'success' + uses: actions/download-artifact@v8 + with: + name: ios-ipa-main-rc + path: artifacts/ios-clean + + - name: Upload artifacts to BrowserStack and resolve URLs + id: resolve + run: | + # Helper: resolve a single URL from manual input or build artifact. + # Outputs the URL to GITHUB_OUTPUT, or empty string if unavailable. + # Upload failures are logged but do not abort other resolutions. + resolve_url() { + local output_key="$1" + local manual_url="$2" + local artifact_dir="$3" + local extension="$4" + local custom_id="$5" + + if [[ -n "$manual_url" ]]; then + echo "${output_key}=${manual_url}" >> "$GITHUB_OUTPUT" + echo "Using manual URL for ${output_key}: ${manual_url}" + return 0 + fi + + if [[ ! -d "$artifact_dir" ]]; then + echo "No build artifact for ${output_key} and no manual URL — test will be skipped" + echo "${output_key}=" >> "$GITHUB_OUTPUT" + return 0 + fi + + local file + file=$(find "$artifact_dir" -name "*.${extension}" | head -1) + if [[ -z "$file" ]]; then + echo "::warning::No .${extension} found in ${artifact_dir} — ${output_key} will be empty" + echo "${output_key}=" >> "$GITHUB_OUTPUT" + return 0 + fi + + echo "Uploading ${file} to BrowserStack..." + local response + response=$(curl -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + -X POST "https://api-cloud.browserstack.com/app-automate/upload" \ + -F "file=@${file}" \ + -F "custom_id=${custom_id}") + + local app_url + app_url=$(echo "$response" | jq -r '.app_url') + + if [[ -z "$app_url" || "$app_url" == "null" ]]; then + echo "::error::BrowserStack upload failed for ${output_key} (${file}): ${response}" + echo "${output_key}=" >> "$GITHUB_OUTPUT" + return 0 + fi + + echo "${output_key}=${app_url}" >> "$GITHUB_OUTPUT" + echo "${output_key}: ${app_url}" + } + + resolve_url "android-login-url" \ + "${{ inputs.browserstack_app_url_android }}" \ + "artifacts/android-with-srp" "apk" \ + "system-test-android-login-${{ github.run_id }}" + + resolve_url "ios-login-url" \ + "${{ inputs.browserstack_app_url_ios }}" \ + "artifacts/ios-with-srp" "ipa" \ + "system-test-ios-login-${{ github.run_id }}" + + resolve_url "android-onboarding-url" \ + "${{ inputs.browserstack_clean_app_url_android }}" \ + "artifacts/android-clean" "apk" \ + "system-test-android-onboarding-${{ github.run_id }}" + + resolve_url "ios-onboarding-url" \ + "${{ inputs.browserstack_clean_app_url_ios }}" \ + "artifacts/ios-clean" "ipa" \ + "system-test-ios-onboarding-${{ github.run_id }}" + + echo "URL resolution complete" + + run-android-login-tests: + name: Android Login Tests + needs: [upload-to-browserstack] + if: >- + always() && !cancelled() + && needs.upload-to-browserstack.outputs.android-login-url != '' + runs-on: ubuntu-latest + env: + BROWSERSTACK_ANDROID_APP_URL: ${{ needs.upload-to-browserstack.outputs.android-login-url }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Restore node_modules cache + uses: actions/cache@v6 + with: + path: | + node_modules + .yarn/cache + .yarn/install-state.gz + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - name: Install dependencies + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn --immutable + + - name: Restore .metamask folder + id: restore-metamask + uses: actions/cache@v6 + with: + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} + + - name: Install Foundry if cache missed + if: steps.restore-metamask.outputs.cache-hit != 'true' + run: yarn install:foundryup + + - name: Setup project + run: yarn setup:github-ci + + - name: BrowserStack Env Setup + uses: browserstack/github-actions/setup-env@4478e16186f38e5be07721931642e65a028713c3 + with: + username: ${{ secrets.BROWSERSTACK_USERNAME }} + access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + project-name: ${{ github.repository }} + + - name: Compute BrowserStack Local Identifier + id: bs-local-id + run: echo "value=${{ github.run_id }}-android-login" >> "$GITHUB_OUTPUT" + + - name: Setup BrowserStack Local + uses: browserstack/github-actions/setup-local@4478e16186f38e5be07721931642e65a028713c3 + with: + local-testing: start + local-identifier: ${{ steps.bs-local-id.outputs.value }} + local-args: '--force-local --verbose' + + - name: Wait for BrowserStack Local + run: sleep 10 + + - name: Set test environment + run: | + { + echo "BROWSERSTACK_LOCAL=true" + echo "BROWSERSTACK_LOCAL_IDENTIFIER=${{ steps.bs-local-id.outputs.value }}" + echo "BROWSERSTACK_BUILD_NAME=system-test-android-login-${{ github.run_id }}" + echo "MM_TEST_ACCOUNT_SRP=${{ secrets.MM_TEST_ACCOUNT_SRP }}" + echo "TEST_SRP_1=${{ secrets.TEST_SRP_1 }}" + echo "TEST_SRP_2=${{ secrets.TEST_SRP_2 }}" + echo "TEST_SRP_3=${{ secrets.TEST_SRP_3 }}" + echo "E2E_PASSWORD=${{ secrets.E2E_PASSWORD }}" + } >> "$GITHUB_ENV" + + - name: Run Android login system tests + run: yarn run-system-tests:android-login + + - name: Upload test results + uses: actions/upload-artifact@v8 + if: always() + with: + name: system-test-report-android-login + path: tests/test-reports/system-test-report/ + if-no-files-found: ignore + retention-days: 7 + + run-android-onboarding-tests: + name: Android Onboarding Tests + needs: [upload-to-browserstack] + if: >- + always() && !cancelled() + && needs.upload-to-browserstack.outputs.android-onboarding-url != '' + runs-on: ubuntu-latest + env: + BROWSERSTACK_ANDROID_CLEAN_APP_URL: ${{ needs.upload-to-browserstack.outputs.android-onboarding-url }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Restore node_modules cache + uses: actions/cache@v6 + with: + path: | + node_modules + .yarn/cache + .yarn/install-state.gz + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - name: Install dependencies + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn --immutable + + - name: Restore .metamask folder + id: restore-metamask + uses: actions/cache@v6 + with: + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} + + - name: Install Foundry if cache missed + if: steps.restore-metamask.outputs.cache-hit != 'true' + run: yarn install:foundryup + + - name: Setup project + run: yarn setup:github-ci + + - name: BrowserStack Env Setup + uses: browserstack/github-actions/setup-env@4478e16186f38e5be07721931642e65a028713c3 + with: + username: ${{ secrets.BROWSERSTACK_USERNAME }} + access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + project-name: ${{ github.repository }} + + - name: Compute BrowserStack Local Identifier + id: bs-local-id + run: echo "value=${{ github.run_id }}-android-onboarding" >> "$GITHUB_OUTPUT" + + - name: Setup BrowserStack Local + uses: browserstack/github-actions/setup-local@4478e16186f38e5be07721931642e65a028713c3 + with: + local-testing: start + local-identifier: ${{ steps.bs-local-id.outputs.value }} + local-args: '--force-local --verbose' + + - name: Wait for BrowserStack Local + run: sleep 10 + + - name: Set test environment + run: | + { + echo "BROWSERSTACK_LOCAL=true" + echo "BROWSERSTACK_LOCAL_IDENTIFIER=${{ steps.bs-local-id.outputs.value }}" + echo "BROWSERSTACK_BUILD_NAME=system-test-android-onboarding-${{ github.run_id }}" + echo "MM_TEST_ACCOUNT_SRP=${{ secrets.MM_TEST_ACCOUNT_SRP }}" + echo "TEST_SRP_1=${{ secrets.TEST_SRP_1 }}" + echo "TEST_SRP_2=${{ secrets.TEST_SRP_2 }}" + echo "TEST_SRP_3=${{ secrets.TEST_SRP_3 }}" + echo "E2E_PASSWORD=${{ secrets.E2E_PASSWORD }}" + } >> "$GITHUB_ENV" + + - name: Run Android onboarding system tests + run: yarn run-system-tests:android-onboarding + + - name: Upload test results + uses: actions/upload-artifact@v8 + if: always() + with: + name: system-test-report-android-onboarding + path: tests/test-reports/system-test-report/ + if-no-files-found: ignore + retention-days: 7 + + run-ios-login-tests: + name: iOS Login Tests + needs: [upload-to-browserstack] + if: >- + always() && !cancelled() + && needs.upload-to-browserstack.outputs.ios-login-url != '' + runs-on: ubuntu-latest + env: + BROWSERSTACK_IOS_APP_URL: ${{ needs.upload-to-browserstack.outputs.ios-login-url }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Restore node_modules cache + uses: actions/cache@v6 + with: + path: | + node_modules + .yarn/cache + .yarn/install-state.gz + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - name: Install dependencies + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn --immutable + + - name: Restore .metamask folder + id: restore-metamask + uses: actions/cache@v6 + with: + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} + + - name: Install Foundry if cache missed + if: steps.restore-metamask.outputs.cache-hit != 'true' + run: yarn install:foundryup + + - name: Setup project + run: yarn setup:github-ci + + - name: BrowserStack Env Setup + uses: browserstack/github-actions/setup-env@4478e16186f38e5be07721931642e65a028713c3 + with: + username: ${{ secrets.BROWSERSTACK_USERNAME }} + access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + project-name: ${{ github.repository }} + + - name: Compute BrowserStack Local Identifier + id: bs-local-id + run: echo "value=${{ github.run_id }}-ios-login" >> "$GITHUB_OUTPUT" + + - name: Setup BrowserStack Local + uses: browserstack/github-actions/setup-local@4478e16186f38e5be07721931642e65a028713c3 + with: + local-testing: start + local-identifier: ${{ steps.bs-local-id.outputs.value }} + local-args: '--force-local --verbose' + + - name: Wait for BrowserStack Local + run: sleep 10 + + - name: Set test environment + run: | + { + echo "BROWSERSTACK_LOCAL=true" + echo "BROWSERSTACK_LOCAL_IDENTIFIER=${{ steps.bs-local-id.outputs.value }}" + echo "BROWSERSTACK_BUILD_NAME=system-test-ios-login-${{ github.run_id }}" + echo "MM_TEST_ACCOUNT_SRP=${{ secrets.MM_TEST_ACCOUNT_SRP }}" + echo "TEST_SRP_1=${{ secrets.TEST_SRP_1 }}" + echo "TEST_SRP_2=${{ secrets.TEST_SRP_2 }}" + echo "TEST_SRP_3=${{ secrets.TEST_SRP_3 }}" + echo "E2E_PASSWORD=${{ secrets.E2E_PASSWORD }}" + } >> "$GITHUB_ENV" + + - name: Run iOS login system tests + run: yarn run-system-tests:ios-login + + - name: Upload test results + uses: actions/upload-artifact@v8 + if: always() + with: + name: system-test-report-ios-login + path: tests/test-reports/system-test-report/ + if-no-files-found: ignore + retention-days: 7 + + run-ios-onboarding-tests: + name: iOS Onboarding Tests + needs: [upload-to-browserstack] + if: >- + always() && !cancelled() + && needs.upload-to-browserstack.outputs.ios-onboarding-url != '' + runs-on: ubuntu-latest + env: + BROWSERSTACK_IOS_CLEAN_APP_URL: ${{ needs.upload-to-browserstack.outputs.ios-onboarding-url }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Restore node_modules cache + uses: actions/cache@v6 + with: + path: | + node_modules + .yarn/cache + .yarn/install-state.gz + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - name: Install dependencies + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn --immutable + + - name: Restore .metamask folder + id: restore-metamask + uses: actions/cache@v6 + with: + path: .metamask + key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} + + - name: Install Foundry if cache missed + if: steps.restore-metamask.outputs.cache-hit != 'true' + run: yarn install:foundryup + + - name: Setup project + run: yarn setup:github-ci + + - name: BrowserStack Env Setup + uses: browserstack/github-actions/setup-env@4478e16186f38e5be07721931642e65a028713c3 + with: + username: ${{ secrets.BROWSERSTACK_USERNAME }} + access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + project-name: ${{ github.repository }} + + - name: Compute BrowserStack Local Identifier + id: bs-local-id + run: echo "value=${{ github.run_id }}-ios-onboarding" >> "$GITHUB_OUTPUT" + + - name: Setup BrowserStack Local + uses: browserstack/github-actions/setup-local@4478e16186f38e5be07721931642e65a028713c3 + with: + local-testing: start + local-identifier: ${{ steps.bs-local-id.outputs.value }} + local-args: '--force-local --verbose' + + - name: Wait for BrowserStack Local + run: sleep 10 + + - name: Set test environment + run: | + { + echo "BROWSERSTACK_LOCAL=true" + echo "BROWSERSTACK_LOCAL_IDENTIFIER=${{ steps.bs-local-id.outputs.value }}" + echo "BROWSERSTACK_BUILD_NAME=system-test-ios-onboarding-${{ github.run_id }}" + echo "MM_TEST_ACCOUNT_SRP=${{ secrets.MM_TEST_ACCOUNT_SRP }}" + echo "TEST_SRP_1=${{ secrets.TEST_SRP_1 }}" + echo "TEST_SRP_2=${{ secrets.TEST_SRP_2 }}" + echo "TEST_SRP_3=${{ secrets.TEST_SRP_3 }}" + echo "E2E_PASSWORD=${{ secrets.E2E_PASSWORD }}" + } >> "$GITHUB_ENV" + + - name: Run iOS onboarding system tests + run: yarn run-system-tests:ios-onboarding + + - name: Upload test results + uses: actions/upload-artifact@v8 + if: always() + with: + name: system-test-report-ios-onboarding + path: tests/test-reports/system-test-report/ + if-no-files-found: ignore + retention-days: 7 + + report: + name: System Test Summary + needs: + - run-android-login-tests + - run-android-onboarding-tests + - run-ios-login-tests + - run-ios-onboarding-tests + if: always() + runs-on: ubuntu-latest + steps: + - name: Download all test reports + uses: actions/download-artifact@v8 + with: + pattern: system-test-report-* + path: all-reports + merge-multiple: false + + - name: Generate summary + run: | + STATUS_ANDROID_LOGIN="${{ needs.run-android-login-tests.result }}" + STATUS_ANDROID_ONBOARDING="${{ needs.run-android-onboarding-tests.result }}" + STATUS_IOS_LOGIN="${{ needs.run-ios-login-tests.result }}" + STATUS_IOS_ONBOARDING="${{ needs.run-ios-onboarding-tests.result }}" + + format_status() { + case "$1" in + success) echo "passed" ;; + failure) echo "FAILED" ;; + skipped) echo "skipped" ;; + cancelled) echo "cancelled" ;; + *) echo "$1" ;; + esac + } + + { + echo "## Nightly System Test Results" + echo "" + echo "| Project | Status |" + echo "|---------|--------|" + echo "| Android Login | $(format_status "$STATUS_ANDROID_LOGIN") |" + echo "| Android Onboarding | $(format_status "$STATUS_ANDROID_ONBOARDING") |" + echo "| iOS Login | $(format_status "$STATUS_IOS_LOGIN") |" + echo "| iOS Onboarding | $(format_status "$STATUS_IOS_ONBOARDING") |" + echo "" + echo "**Trigger:** ${{ github.event_name }}" + echo "**Branch:** ${{ github.ref_name }}" + echo "**Run:** [#${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" + } >> "$GITHUB_STEP_SUMMARY" + + # Fail the report job if any test job failed + if [[ "$STATUS_ANDROID_LOGIN" == "failure" || "$STATUS_ANDROID_ONBOARDING" == "failure" || "$STATUS_IOS_LOGIN" == "failure" || "$STATUS_IOS_ONBOARDING" == "failure" ]]; then + echo "" + echo "::error::One or more system test projects failed" + exit 1 + fi diff --git a/.github/workflows/runway-android-production-workflow.yml b/.github/workflows/runway-android-production-workflow.yml deleted file mode 100644 index fdac5745de2..00000000000 --- a/.github/workflows/runway-android-production-workflow.yml +++ /dev/null @@ -1,41 +0,0 @@ -############################################################################################## -# -# Runway Android Production Workflow -# -# Triggered from Runway to either: -# - Push an OTA update to the production channel (when OTA_VERSION is bumped), or -# - Build the production mobile app (when there is no OTA version bump). -# -# When triggering workflow_dispatch, select the correct branch (e.g. main or release). -# Version bump: skipped — run Runway iOS Production first on the same branch for the bump. -# -############################################################################################## -name: Runway Android Production - -on: - workflow_dispatch: - inputs: - source_branch: - description: >- - Optional branch, tag, or SHA (Build workflow source_branch). - Empty uses the branch selected in the "Use workflow from" UI. - required: false - type: string - -permissions: - contents: write # required by build.yml (update-build-version job) - pull-requests: read - actions: write - id-token: write # required by build.yml - -jobs: - runway-production: - uses: ./.github/workflows/runway-ota-build-core.yml - with: - platform: android - source_branch: ${{ inputs.source_branch }} - ota_channel: production - build_name: main-prod - create_production_ota_tag: true - skip_version_bump: true - secrets: inherit diff --git a/.github/workflows/runway-android-rc-workflow.yml b/.github/workflows/runway-android-rc-workflow.yml deleted file mode 100644 index b30426089e1..00000000000 --- a/.github/workflows/runway-android-rc-workflow.yml +++ /dev/null @@ -1,50 +0,0 @@ -############################################################################################## -# -# Runway Android RC Workflow -# -# Triggered from Runway to either: -# - Push an OTA update (when OTA_VERSION in app/constants/ota.ts is bumped), or -# - Build the mobile app (when there is no OTA version bump). -# -# When triggering workflow_dispatch, select the release branch (e.g. release/7.71.0). -# Version bump: skipped here — run Runway iOS RC first on the same branch so it performs the bump. -# -############################################################################################## -name: Runway Android RC - -on: - workflow_dispatch: - inputs: - source_branch: - description: >- - Optional branch, tag, or SHA (Build workflow source_branch). - Empty uses the branch selected in the "Use workflow from" UI. - required: false - type: string - -permissions: - contents: write # required by build.yml (update-build-version job) - pull-requests: read - actions: write - id-token: write # required by build.yml - -jobs: - runway-rc: - uses: ./.github/workflows/runway-ota-build-core.yml - with: - platform: android - source_branch: ${{ inputs.source_branch }} - skip_version_bump: true - secrets: inherit - - slack-notification: - name: Slack RC Notification - needs: runway-rc - if: success() - uses: ./.github/workflows/slack-rc-notification.yml - with: - source_branch: ${{ inputs.source_branch || github.ref_name }} - semver: ${{ needs.runway-rc.outputs.semantic_version }} - ios_build_number: ${{ needs.runway-rc.outputs.ios_version_code }} - android_build_number: ${{ needs.runway-rc.outputs.android_version_code }} - secrets: inherit diff --git a/.github/workflows/runway-ios-production-workflow.yml b/.github/workflows/runway-ios-production-workflow.yml deleted file mode 100644 index 118b768fe0d..00000000000 --- a/.github/workflows/runway-ios-production-workflow.yml +++ /dev/null @@ -1,41 +0,0 @@ -############################################################################################## -# -# Runway iOS Production Workflow -# -# Triggered from Runway to either: -# - Push an OTA update to the production channel (when OTA_VERSION is bumped), or -# - Build the production app and upload the IPA to TestFlight (when there is no OTA bump). -# -# When triggering workflow_dispatch, select the correct branch (e.g. main or release). -# Version bump: this workflow bumps; run Android production after on the same branch. -# -############################################################################################## -name: Runway iOS Production - -on: - workflow_dispatch: - inputs: - source_branch: - description: >- - Optional branch, tag, or SHA (Build workflow source_branch). - Empty uses the branch selected in the "Use workflow from" UI. - required: false - type: string - -permissions: - contents: write # required by build.yml (update-build-version job) - pull-requests: read - actions: write - id-token: write # required by build.yml - -jobs: - runway-production: - uses: ./.github/workflows/runway-ota-build-core.yml - with: - platform: ios - source_branch: ${{ inputs.source_branch }} - ota_channel: production - build_name: main-prod - create_production_ota_tag: true - environment: prod - secrets: inherit diff --git a/.github/workflows/runway-ios-rc-workflow.yml b/.github/workflows/runway-ios-rc-workflow.yml deleted file mode 100644 index 6f3dfab2e1d..00000000000 --- a/.github/workflows/runway-ios-rc-workflow.yml +++ /dev/null @@ -1,49 +0,0 @@ -############################################################################################## -# -# Runway iOS RC Workflow -# -# Triggered from Runway to either: -# - Push an OTA update (when OTA_VERSION in app/constants/ota.ts is bumped), or -# - Build the mobile app and upload the IPA to TestFlight (when there is no OTA version bump). -# -# When triggering workflow_dispatch, select the release branch (e.g. release/7.71.0). -# Version bump: this workflow bumps the repo build number; run Android RC after so it can skip bump. -# -############################################################################################## -name: Runway iOS RC - -on: - workflow_dispatch: - inputs: - source_branch: - description: >- - Optional branch, tag, or SHA (Build workflow source_branch). - Empty uses the branch selected in the "Use workflow from" UI. - required: false - type: string - -permissions: - contents: write # required by build.yml (update-build-version job) - pull-requests: read - actions: write - id-token: write # required by build.yml - -jobs: - runway-rc: - uses: ./.github/workflows/runway-ota-build-core.yml - with: - platform: ios - source_branch: ${{ inputs.source_branch }} - secrets: inherit - - slack-notification: - name: Slack RC Notification - needs: runway-rc - if: success() - uses: ./.github/workflows/slack-rc-notification.yml - with: - source_branch: ${{ inputs.source_branch || github.ref_name }} - semver: ${{ needs.runway-rc.outputs.semantic_version }} - ios_build_number: ${{ needs.runway-rc.outputs.ios_version_code }} - android_build_number: ${{ needs.runway-rc.outputs.android_version_code }} - secrets: inherit diff --git a/.github/workflows/runway-ota-build-core.yml b/.github/workflows/runway-ota-build-core.yml deleted file mode 100644 index 43d23d6a92c..00000000000 --- a/.github/workflows/runway-ota-build-core.yml +++ /dev/null @@ -1,225 +0,0 @@ -############################################################################################## -# -# Runway OTA / build core (reusable) -# -# Shared logic for Runway: OTA bump detection, push-eas-update dispatch, or build.yml. -# Callers: iOS Runway workflows bump once (skip_version_bump false); Android pass skip_version_bump true. -# -############################################################################################## -name: Runway OTA Build Core - -on: - workflow_call: - inputs: - platform: - description: 'Target platform passed to push-eas-update and build.yml (android or ios)' - required: true - type: string - source_branch: - description: >- - Optional branch, tag, or SHA (Build workflow source_branch). - Empty uses the branch selected in the caller workflow_dispatch "Use workflow from" UI. - required: false - type: string - default: '' - ota_channel: - description: 'push-eas-update channel input (e.g. rc, production)' - required: false - type: string - default: rc - build_name: - description: 'build.yml build_name (e.g. main-rc, main-prod)' - required: false - type: string - default: main-rc - create_production_ota_tag: - description: 'If true, create OTA release tag after production trigger-ota (callers: *production* only)' - required: false - type: boolean - default: false - environment: - description: 'Build environment / track passed to upload-to-testflight (e.g. rc, prod)' - required: false - type: string - default: 'rc' - skip_version_bump: - description: >- - If true, build.yml skips update-latest-build-version. Android Runway entry workflows set - true (iOS bumps first); iOS uses default false. - required: false - type: boolean - default: false - outputs: - semantic_version: - description: 'package.json version at the built commit (empty when OTA path taken)' - value: ${{ jobs.trigger-build.outputs.semantic_version }} - ios_version_code: - description: 'iOS CURRENT_PROJECT_VERSION at the built commit (empty when OTA path taken)' - value: ${{ jobs.trigger-build.outputs.ios_version_code }} - android_version_code: - description: 'Android versionCode at the built commit (empty when OTA path taken)' - value: ${{ jobs.trigger-build.outputs.android_version_code }} - -permissions: - contents: write # required by build.yml (update-build-version job) - pull-requests: read - actions: write - id-token: write # required by build.yml - -jobs: - decide: - name: Check OTA version and resolve inputs - runs-on: ubuntu-latest - outputs: - ota_bump: ${{ steps.decide.outputs.ota_bump }} - base_ref: ${{ steps.decide.outputs.base_ref }} - ota_version: ${{ steps.decide.outputs.ota_version }} - pr_number: ${{ steps.resolve-pr.outputs.pr_number }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ inputs.source_branch || github.ref }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - - - name: Resolve PR number for current branch - id: resolve-pr - run: | - BRANCH="${{ inputs.source_branch || github.ref_name }}" - # Strip refs/heads/ if present - BRANCH="${BRANCH#refs/heads/}" - echo "Resolving PR for branch: $BRANCH (repo: $GITHUB_REPOSITORY)" - - # Try same-repo head first, then owner:branch (required by API when listing pulls) - # jq '.[0].number' on an empty array outputs the literal string "null", so normalise to empty - PR_NUMBER=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BRANCH" --json number --jq '.[0].number // empty' 2>/dev/null || echo "") - if [[ -z "$PR_NUMBER" ]]; then - PR_NUMBER=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$GITHUB_REPOSITORY_OWNER:$BRANCH" --json number --jq '.[0].number // empty' 2>/dev/null || echo "") - fi - - echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" - echo "Branch: $BRANCH, PR number: ${PR_NUMBER:-none}" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Decide OTA vs build - id: decide - run: | - set -e - # Version from package.json (e.g. 7.70.0) → base ref for OTA workflow is always v{VERSION} - VERSION=$(node -p "require('./package.json').version") - RELEASE_TAG="v${VERSION}" - echo "base_ref=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" - - # Parse OTA_VERSION from the export line (do not use a fixed line number — comment block length changes). - extract_ota() { grep -E '^export const OTA_VERSION' "$1" | sed -n "s/^export const OTA_VERSION: string = '\\([^']*\\)'.*/\\1/p"; } - extract_ota_from_git_show() { grep -E '^export const OTA_VERSION' | sed -n "s/^export const OTA_VERSION: string = '\\([^']*\\)'.*/\\1/p"; } - - # OTA_VERSION from current ref - CURRENT_OTA=$(extract_ota app/constants/ota.ts) - echo "ota_version=${CURRENT_OTA}" >> "$GITHUB_OUTPUT" - - # Early exit 1: sentinel means no OTA has been configured for this release - if [[ "$CURRENT_OTA" == "vX.XX.X" ]]; then - echo "ota_bump=false" >> "$GITHUB_OUTPUT" - echo "OTA_VERSION is sentinel ($CURRENT_OTA) → will trigger build" - exit 0 - fi - - # Early exit 2: if a tag for this OTA_VERSION already exists, the OTA was - # already shipped (e.g. merged from a prior release branch) — treat as stale. - if git rev-parse "refs/tags/${CURRENT_OTA}" >/dev/null 2>&1; then - echo "ota_bump=false" >> "$GITHUB_OUTPUT" - echo "OTA tag ${CURRENT_OTA} already exists (already shipped) → stale, will trigger build" - exit 0 - fi - - # Ref to compare against for detecting bump: use release tag if it exists, else main - if git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then - COMPARE_REF="$RELEASE_TAG" - BASE_OTA=$(git show "${COMPARE_REF}:app/constants/ota.ts" 2>/dev/null | extract_ota_from_git_show || echo "") - else - COMPARE_REF="main" - BASE_OTA=$(git show "origin/main:app/constants/ota.ts" 2>/dev/null | extract_ota_from_git_show || echo "") - echo "Release tag ${RELEASE_TAG} not found; comparing OTA_VERSION to ${COMPARE_REF} to detect bump" - fi - - if [[ -n "$BASE_OTA" && "$CURRENT_OTA" != "$BASE_OTA" ]]; then - echo "ota_bump=true" >> "$GITHUB_OUTPUT" - echo "OTA_VERSION changed: $BASE_OTA -> $CURRENT_OTA → will trigger OTA update" - else - echo "ota_bump=false" >> "$GITHUB_OUTPUT" - echo "No OTA version bump (base: $BASE_OTA, current: $CURRENT_OTA) → will trigger build" - fi - - # Reusable workflows must be job-level `uses:` (not a step). Steps only support actions (action.yml). - validate-ota-pr: - name: Validate PR for OTA - needs: decide - if: needs.decide.outputs.ota_bump == 'true' - runs-on: ubuntu-latest - steps: - - name: Validate PR number - run: | - if [[ -z "${{ needs.decide.outputs.pr_number }}" ]]; then - echo "::error::No PR found for this branch. OTA update requires a PR number." - echo "::error::If you ran the workflow manually (workflow_dispatch), select your release branch in the 'Use workflow from' dropdown (e.g. release/7.71.0), not main." - exit 1 - fi - echo "Using PR #${{ needs.decide.outputs.pr_number }}" - - trigger-ota: - name: Trigger OTA update - needs: [decide, validate-ota-pr] - if: needs.decide.outputs.ota_bump == 'true' - uses: ./.github/workflows/push-eas-update.yml - with: - pr_number: ${{ needs.decide.outputs.pr_number }} - base_branch: ${{ needs.decide.outputs.base_ref }} - message: ${{ needs.decide.outputs.ota_version }} - channel: ${{ inputs.ota_channel }} - platform: ${{ inputs.platform }} - secrets: inherit - - trigger-build: - name: Trigger build mobile app - needs: decide - if: needs.decide.outputs.ota_bump != 'true' - uses: ./.github/workflows/build.yml - with: - build_name: ${{ inputs.build_name }} - platform: ${{ inputs.platform }} - skip_version_bump: ${{ inputs.skip_version_bump }} - source_branch: ${{ inputs.source_branch || github.ref_name }} - upload_to_sentry: true - secrets: inherit - - create-ota-production-tag: - name: Create OTA production release tag - needs: [decide, trigger-ota] - if: ${{ inputs.create_production_ota_tag == true }} - uses: ./.github/workflows/runway-create-ota-production-tag.yml - with: - tag_name: ${{ needs.decide.outputs.ota_version }} - checkout_ref: ${{ inputs.source_branch || github.ref_name }} - secrets: inherit - - upload-ios-testflight: - name: Upload iOS to TestFlight - needs: [decide, trigger-build] - if: ${{ inputs.platform == 'ios' }} - uses: ./.github/workflows/upload-to-testflight.yml - with: - environment: ${{ inputs.environment }} - source_branch: ${{ inputs.source_branch || github.ref_name }} - build_branch: ${{ inputs.source_branch || github.ref_name }} - build_name: ${{ inputs.build_name }} - build_commit_sha: ${{ needs.trigger-build.outputs.built_commit_sha }} - build_version: ${{ needs.trigger-build.outputs.semantic_version }} - build_number: ${{ needs.trigger-build.outputs.ios_version_code }} - secrets: inherit diff --git a/.github/workflows/setup-node-modules.yml b/.github/workflows/setup-node-modules.yml index f87f6145ee4..7773f5cf12e 100644 --- a/.github/workflows/setup-node-modules.yml +++ b/.github/workflows/setup-node-modules.yml @@ -58,6 +58,11 @@ on: required: false type: number default: 1 + runner_provider: + description: Runner provider forwarded from the caller + required: false + type: string + default: current outputs: artifact-name: description: 'The actual artifact name used' @@ -71,7 +76,7 @@ jobs: setup: name: Setup Node Modules ${{ inputs.platform && format('({0})', inputs.platform) || '' }} # Platform-specific runner to match consumer (build needs same OS for native deps/symlinks) - runs-on: ${{ inputs.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe-xl' || (inputs.platform == 'android' && 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' || 'ubuntu-latest') }} + runs-on: ${{ inputs.runner_provider == 'namespace' && (inputs.platform == 'ios' && 'namespace-profile-metamask-ios-build' || (inputs.platform == 'android' && 'namespace-profile-metamask-android-build' || 'namespace-profile-metamask-ci-linux')) || (inputs.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe-xl' || (inputs.platform == 'android' && 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' || 'ubuntu-latest')) }} permissions: contents: read id-token: write @@ -85,12 +90,22 @@ jobs: fetch-depth: ${{ inputs.fetch-depth }} submodules: ${{ inputs.checkout-submodules && 'recursive' || false }} + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache + # iOS: Only Node + Ruby needed (setup runs gem/bundle install, no pods/Xcode). Full iOS env is in build job. - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' - cache: ${{ inputs.platform == 'android' && 'yarn' || (inputs.platform == 'ios' && 'yarn') || '' }} + cache: ${{ inputs.runner_provider == 'namespace' && '' || (inputs.platform == 'android' && 'yarn' || (inputs.platform == 'ios' && 'yarn') || '') }} - name: Setup Ruby (iOS) if: inputs.platform == 'ios' @@ -126,14 +141,14 @@ jobs: - name: Restore .metamask folder id: restore-metamask - if: inputs.platform != '' + if: ${{ inputs.runner_provider != 'namespace' && inputs.platform != '' }} uses: actions/cache@v4 with: path: .metamask key: .metamask-${{ hashFiles('package.json', 'yarn.lock') }} - name: Install Foundry if cache missed - if: inputs.platform != '' && steps.restore-metamask.outputs.cache-hit != 'true' + if: ${{ inputs.platform != '' && (inputs.runner_provider == 'namespace' || steps.restore-metamask.outputs.cache-hit != 'true') }} run: yarn install:foundryup - name: Setup project diff --git a/.github/workflows/update-e2e-fixtures.yml b/.github/workflows/update-e2e-fixtures.yml index 63f5db43701..a9c652ff3d6 100644 --- a/.github/workflows/update-e2e-fixtures.yml +++ b/.github/workflows/update-e2e-fixtures.yml @@ -199,11 +199,6 @@ jobs: SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} - MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} - MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} - MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} - MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} @@ -255,7 +250,7 @@ jobs: run: | IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' \ yarn detox test -c ios.sim.main.ci --headless \ - tests/regression/fixtures/fixture-validation.spec.ts + tests/smoke/fixtures/fixture-validation.spec.ts env: PREBUILT_IOS_APP_PATH: artifacts/main-qa-MetaMask.app diff --git a/.js.env.example b/.js.env.example index d5c27ab408c..67940dfec48 100644 --- a/.js.env.example +++ b/.js.env.example @@ -100,9 +100,6 @@ export MM_SAMPLE_FEATURE_COUNTER_ENABLED="true" # The endpoint used to submit errors and tracing data to Sentry for dev environment. # export MM_SENTRY_DSN_DEV= -# Permissions Settings feature flag specific to UI changes -export MM_PERMISSIONS_SETTINGS_V1_ENABLED="" - # Earn Variables ## Stablecoin Lending export MM_STABLECOIN_LENDING_UI_ENABLED="true" @@ -114,7 +111,6 @@ export MM_POOLED_STAKING_ENABLED="true" export MM_POOLED_STAKING_SERVICE_INTERRUPTION_BANNER_ENABLED="true" # mUSD export MM_MUSD_CONVERSION_FLOW_ENABLED="false" -export MM_MUSD_QUICK_CONVERT_ENABLED="false" # See app/components/UI/Earn/docs/wildcard-token-list.md for more information. export MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST='' export MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='' @@ -158,20 +154,6 @@ export E2E_MOCK_OAUTH='false' export E2E_BYOA_AUTH_SECRET='' export E2E_MOCK_OAUTH_EMAIL='' -# env for seedless onboarding main-dev -export ANDROID_APPLE_CLIENT_ID='io.metamask.appleloginclient.dev' -export ANDROID_GOOGLE_CLIENT_ID='8615965109465-i8oeh9kuvl1n6lk1ffkobpvth27bmi41.apps.googleusercontent.com' -export ANDROID_GOOGLE_SERVER_CLIENT_ID='615965109465-i8oeh9kuvl1n6lk1ffkobpvth27bmi41.apps.googleusercontent.com' -export IOS_GOOGLE_CLIENT_ID='615965109465-h6tp2h3crls6hbggispcgovbvk4vabu3.apps.googleusercontent.com' -export IOS_GOOGLE_REDIRECT_URI='com.googleusercontent.apps.615965109465-h6tp2h3crls6hbggispcgovbvk4vabu3:/oauth2redirect/google' - -# env for seedless onboarding flask-dev -#export ANDROID_APPLE_CLIENT_ID="io.metamask.appleloginclient.flask.dev" -#export ANDROID_GOOGLE_CLIENT_ID="615965109465-ab20kuqbls6fj5s50fvmvbnket8nv1sh.apps.googleusercontent.com" -#export ANDROID_GOOGLE_SERVER_CLIENT_ID="615965109465-ab20kuqbls6fj5s50fvmvbnket8nv1sh.apps.googleusercontent.com" -#export IOS_GOOGLE_CLIENT_ID="615965109465-89b2lmqgm5ka8j8t403qhooouv57id9b.apps.googleusercontent.com" -#export IOS_GOOGLE_REDIRECT_URI="com.googleusercontent.apps.615965109465-89b2lmqgm5ka8j8t403qhooouv57id9b:/oauth2redirect/google" - # Enable send re-designs locally export MM_SEND_REDESIGN_ENABLED="true" diff --git a/.yarn/patches/@metamask-assets-controllers-npm-104.2.0-c375051533.patch b/.yarn/patches/@metamask-assets-controllers-npm-104.2.0-c375051533.patch deleted file mode 100644 index 7929d27693d..00000000000 --- a/.yarn/patches/@metamask-assets-controllers-npm-104.2.0-c375051533.patch +++ /dev/null @@ -1,52 +0,0 @@ -diff --git a/dist/MultichainAssetsController/MultichainAssetsController.cjs b/dist/MultichainAssetsController/MultichainAssetsController.cjs -index 0f74ce9c3200043c7c8fd121218e41790c55cc0e..ee449c44281ae3dd37e73ea0e9cc0b8ee18dea0e 100644 ---- a/dist/MultichainAssetsController/MultichainAssetsController.cjs -+++ b/dist/MultichainAssetsController/MultichainAssetsController.cjs -@@ -493,7 +493,7 @@ async function _MultichainAssetsController_getAssetsMetadataFrom(assets, snapId) - }); - }, _MultichainAssetsController_filterBlockaidSpamTokensOnAdd = - /** -- * Fail-closed Blockaid filter for newly detected `token:` assets (native/other namespaces unchanged). -+ * Fail-open Blockaid filter for newly detected `token:` assets (native/other namespaces unchanged). - * - * @param assets - CAIP assets to filter. - * @returns Filtered list, original order preserved. -@@ -503,33 +503,25 @@ async function _MultichainAssetsController_filterBlockaidSpamTokensOnAdd(assets) - if (Object.keys(tokensByChain).length === 0) { - return [...assets]; - } -- const keptTokenAssets = new Set(); -+ const rejectedAssets = new Set(); - for (const [chainName, tokenEntries] of Object.entries(tokensByChain)) { - const batchOutcomes = await __classPrivateFieldGet(this, _MultichainAssetsController_instances, "m", _MultichainAssetsController_runBatchedBulkTokenScans).call(this, chainName, tokenEntries); - for (const outcome of batchOutcomes) { - if (outcome.status === 'rejected') { -+ // Fail-open: if API fails, allow all tokens in this batch through - continue; - } - for (const entry of outcome.entries) { - const scanned = outcome.response[entry.address]; -+ // Reject only if we have a definitive malicious result - if (scanned?.result_type && - scanned.result_type !== phishing_controller_1.TokenScanResultType.Malicious) { -- keptTokenAssets.add(entry.asset); -+ rejectedAssets.add(entry.asset); - } - } - } - } -- return assets.filter((asset) => { -- try { -- if ((0, utils_1.parseCaipAssetType)(asset).assetNamespace === 'token') { -- return keptTokenAssets.has(asset); -- } -- } -- catch { -- return false; -- } -- return true; -- }); -+ return assets.filter((asset) => !rejectedAssets.has(asset)); - }, _MultichainAssetsController_findMaliciousTokensAmong = - /** - * SPL `token:` assets in state that Blockaid marks malicious (failed batches skipped). diff --git a/CHANGELOG.md b/CHANGELOG.md index 99671269b08..d50c5227be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.77.0] + +### Added + +- Added the Rewards Perps Trading Campaign with a details page, stats page, leaderboard, prize pool, tour, and opt-in flow. (#29323) +- Added a Money balance card to the wallet home screen showing the user's Money Account balance, vault APY, and a quick action to add funds. (#29724) +- Added an explore page v2 behind a feature flag. (#29473) +- Added an A/B test for the hub page discovery tabs. (#29193) +- Added Send flow warnings for first-time recipient interaction. (#28650) +- Added FIFA World Cup support to live sports predictions. (#29740) +- Added a rewards toast to enable notifications. (#29531) +- Added back access to SDK connections management. (#29685) +- Added a compact spending limit indicator on Card Home for limited Solana card funding tokens, showing remaining allowance when the original cap is not available. (#29662) +- Added a Swap/Bridge warning when the native balance would go below a minimum threshold. (#29712) +- Added transactions in token details for gas-fee-sponsored transactions. (#28977) +- Added sort icons on the filter bar in the trending list. (#29809) +- Added custom sorting in Perps from the Explore screen. (#29929) +- Disabled tx management on the Tempo testnet (Moderato). (#29425) + +### Changed + +- Removed the sites arrow column from the sites tab. (#29861) +- Updated the Card feature to display "mUSD back" instead of "cashback" in all user-facing text. (#29683) +- Improved the Money home experience behind the feature flag by wiring real navigation (sheets, Card flows, potential earnings, and conversion), replacing the Activity tab when the flag is enabled, and showing projected earnings using live APY and balance data. (#29454) +- Improved retry behavior when QR hardware wallet signing scans fail. (#29737) +- Improved QR hardware wallet scan error messages and retry handling. (#29388) +- Removed deprecated quick-convert feature code paths. (#29351) +- Updated the font for the token details label. (#29710) +- Aligned the data fetch on stocks. (#29795) + +### Fixed + +- Fixed a bug where the "Your balance" heading on the Money Hub stayed pinned to the top of the screen instead of scrolling with the content. (#29863) +- Fixed open order trigger/limit prices showing only 2 decimals instead of market-appropriate precision. (#29799) +- Fixed NFT details not showing the collection name. (#29551) +- Fixed token list items to use a fallback icon when token image URLs are missing. (#29827) +- Fixed Turkish strings that showed broken i18n placeholders when a percent sign appeared before an interpolated value. (#29779) +- Fixed the DeFi empty-state Explore button to open the in-app Explore sites screen instead of an external portfolio page. (#29552) +- Fixed Activity showing incorrect Sent/Received labels and "Not available" amounts after switching from a non-EVM network filter back to all popular networks. (#29794) +- Fixed a regression where Earn redirects to the activity view for successful pooled-staking or lending deposits left users stranded without a way to exit. (#29763) +- Fixed live prediction position values so they stay updated across the home screen and market details views. (#29527) +- Fixed Sei Mainnet to use Seiscan (`https://seiscan.io`) in place of the deprecated Seitrace explorer; existing installs are migrated via migration 134. (#29221) +- Fixed a regression that prevented flipping a Perps position. (#29691) +- Fixed the Activity Perps tab not reappearing after switching the transaction network filter back to popular networks from Solana, without forcing Ethereum as the selected chain. (#29676) +- Fixed token details opening Etherscan instead of the correct block explorer for tokens on custom or non-PopularList networks when another EVM network was selected. (#29686) +- Fixed an iOS bug where the back button in the in-app webview (e.g. "View on block explorer") was rendered behind the status bar and could not be tapped. (#29693) +- Fixed QR code scanned recipient addresses not being forwarded through the send flow. (#29668) +- Fixed a crash in Perps when a Bitcoin, Solana, or Tron account was selected. (#29420) +- Fixed a subscription leak in `BackgroundBridge` where disconnected SDK and WalletConnect sessions could continue to receive network, account, permission, and lock/unlock notifications. (#29040) +- Fixed advanced chart timestamps to display in the user's local timezone instead of UTC. (#29654) +- Fixed EVM activity to align with the active account group and network filter. (#29994) +- Fixed the trending label display. (#30031) +- Fixed titles on Explore search. (#30026) +- Fixed a graceful fallback for assets missing images. (#30156) +- Skipped Blockaid validation for gas-included swaps. (#30150) +- Hid the gas sponsorship banner for hardware wallets. (#30091) +- Skipped `useInsufficientNativeReserveError` for non-EVM accounts to prevent spurious native-reserve errors. (#30048) +- Cleared the gas sponsorship flag for hardware wallet transactions. (#30023) + ## [7.76.3] ### Added @@ -11435,7 +11494,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#957](https://github.com/MetaMask/metamask-mobile/pull/957): fix timeouts (#957) - [#954](https://github.com/MetaMask/metamask-mobile/pull/954): Bugfix: onboarding navigation (#954) -[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.76.3...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-mobile/compare/v7.77.0...HEAD +[7.77.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.76.3...v7.77.0 [7.76.3]: https://github.com/MetaMask/metamask-mobile/compare/v7.76.0...v7.76.3 [7.76.0]: https://github.com/MetaMask/metamask-mobile/compare/v7.75.1...v7.76.0 [7.75.1]: https://github.com/MetaMask/metamask-mobile/compare/v7.75.0...v7.75.1 diff --git a/README.md b/README.md index 8d63bfd09c8..902fd35aa0a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ To learn how to contribute to the MetaMask codebase, visit our [Contributor Docs ## Documentation - [Architecture](./docs/readme/architecture.md) +- [BigInt number migration](./docs/bigint-migration-guide.md) (deprecated `app/util/number/index.js` burndown and ESLint allowlist) - [Expo Development Environment Setup](./docs/readme/expo-environment.md) - [Native Development Environment Setup](./docs/readme/environment.md) - [Build Troubleshooting](./docs/readme/troubleshooting.md) diff --git a/android/app/build.gradle b/android/app/build.gradle index e2a750ef915..66077b09617 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,8 +187,8 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.76.0" - versionCode 4866 + versionName "7.77.0" + versionCode 5017 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/app/actions/money/index.test.ts b/app/actions/money/index.test.ts new file mode 100644 index 00000000000..c5051da621d --- /dev/null +++ b/app/actions/money/index.test.ts @@ -0,0 +1,161 @@ +import { + __resetUpgradesInFlightForTesting, + upgradeMoneyAccount, +} from './index'; +import Engine from '../../core/Engine'; +import Logger from '../../util/Logger'; +import { selectPrimaryMoneyAccount } from '../../selectors/moneyAccountController'; +import type { RootState } from '../../reducers'; + +jest.mock('../../core/Engine', () => ({ + __esModule: true, + default: { + context: { + MoneyAccountUpgradeController: { + upgradeAccount: jest.fn(), + }, + }, + }, +})); + +jest.mock('../../util/Logger', () => ({ + __esModule: true, + default: { + log: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('../../selectors/moneyAccountController', () => ({ + selectPrimaryMoneyAccount: jest.fn(), +})); + +jest.mock( + '../../core/Engine/controllers/money-account-upgrade-controller-init', + () => ({ + whenMoneyAccountUpgradeReady: jest.fn(() => Promise.resolve()), + }), +); + +const mockUpgradeAccount = Engine.context.MoneyAccountUpgradeController + .upgradeAccount as jest.Mock; +const mockSelectPrimaryMoneyAccount = + selectPrimaryMoneyAccount as unknown as jest.Mock; +const mockLogError = Logger.error as jest.Mock; +const mockLogLog = Logger.log as jest.Mock; + +const ADDRESS = '0x1111111111111111111111111111111111111111' as const; +const OTHER_ADDRESS = '0x2222222222222222222222222222222222222222' as const; + +const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); + +describe('upgradeMoneyAccount', () => { + let dispatch: jest.Mock; + let getState: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + __resetUpgradesInFlightForTesting(); + dispatch = jest.fn(); + getState = jest.fn().mockReturnValue({} as RootState); + mockUpgradeAccount.mockResolvedValue(undefined); + }); + + it('calls MoneyAccountUpgradeController.upgradeAccount with the primary money account address', async () => { + mockSelectPrimaryMoneyAccount.mockReturnValue({ address: ADDRESS }); + + upgradeMoneyAccount()(dispatch, getState, undefined); + await flushPromises(); + + expect(mockUpgradeAccount).toHaveBeenCalledWith(ADDRESS); + }); + + it('skips the call and logs when there is no primary money account', () => { + mockSelectPrimaryMoneyAccount.mockReturnValue(undefined); + + upgradeMoneyAccount()(dispatch, getState, undefined); + + expect(mockUpgradeAccount).not.toHaveBeenCalled(); + expect(mockLogLog).toHaveBeenCalled(); + }); + + it('skips the call when the primary money account address is not a strict hex string', () => { + mockSelectPrimaryMoneyAccount.mockReturnValue({ address: 'not-hex' }); + + upgradeMoneyAccount()(dispatch, getState, undefined); + + expect(mockUpgradeAccount).not.toHaveBeenCalled(); + expect(mockLogLog).toHaveBeenCalledWith( + expect.stringContaining('upgradeMoneyAccount'), + expect.stringContaining('no valid primary money account'), + expect.objectContaining({ address: 'not-hex' }), + ); + }); + + it('catches rejected upgradeAccount promises and logs', async () => { + mockSelectPrimaryMoneyAccount.mockReturnValue({ address: ADDRESS }); + const error = new Error('boom'); + mockUpgradeAccount.mockRejectedValueOnce(error); + + upgradeMoneyAccount()(dispatch, getState, undefined); + await flushPromises(); + + expect(mockLogError).toHaveBeenCalledWith(error, expect.any(String)); + }); + + it('wraps non-Error rejections', async () => { + mockSelectPrimaryMoneyAccount.mockReturnValue({ address: ADDRESS }); + mockUpgradeAccount.mockRejectedValueOnce('string rejection'); + + upgradeMoneyAccount()(dispatch, getState, undefined); + await flushPromises(); + + expect(mockLogError).toHaveBeenCalledWith( + expect.any(Error), + expect.any(String), + ); + }); + + it('deduplicates concurrent upgrades for the same address', async () => { + mockSelectPrimaryMoneyAccount.mockReturnValue({ address: ADDRESS }); + mockUpgradeAccount.mockReturnValueOnce(new Promise(() => undefined)); + + upgradeMoneyAccount()(dispatch, getState, undefined); + upgradeMoneyAccount()(dispatch, getState, undefined); + await flushPromises(); + + expect(mockUpgradeAccount).toHaveBeenCalledTimes(1); + expect(mockLogLog).toHaveBeenCalledWith( + expect.stringContaining('upgradeMoneyAccount'), + 'upgrade already in flight; skipping', + { address: ADDRESS }, + ); + }); + + it('allows a subsequent upgrade after a previous one resolves', async () => { + mockSelectPrimaryMoneyAccount.mockReturnValue({ address: ADDRESS }); + + upgradeMoneyAccount()(dispatch, getState, undefined); + await flushPromises(); + upgradeMoneyAccount()(dispatch, getState, undefined); + await flushPromises(); + + expect(mockUpgradeAccount).toHaveBeenCalledTimes(2); + }); + + it('does not deduplicate upgrades for different addresses', async () => { + mockUpgradeAccount.mockReturnValue(new Promise(() => undefined)); + + mockSelectPrimaryMoneyAccount.mockReturnValueOnce({ address: ADDRESS }); + upgradeMoneyAccount()(dispatch, getState, undefined); + mockSelectPrimaryMoneyAccount.mockReturnValueOnce({ + address: OTHER_ADDRESS, + }); + upgradeMoneyAccount()(dispatch, getState, undefined); + await flushPromises(); + + expect(mockUpgradeAccount).toHaveBeenCalledTimes(2); + expect(mockUpgradeAccount).toHaveBeenNthCalledWith(1, ADDRESS); + expect(mockUpgradeAccount).toHaveBeenNthCalledWith(2, OTHER_ADDRESS); + }); +}); diff --git a/app/actions/money/index.ts b/app/actions/money/index.ts new file mode 100644 index 00000000000..cc73292a345 --- /dev/null +++ b/app/actions/money/index.ts @@ -0,0 +1,50 @@ +import type { AnyAction } from 'redux'; +import type { ThunkAction } from 'redux-thunk'; +import { type Hex, isStrictHexString } from '@metamask/utils'; +import type { RootState } from '../../reducers'; +import { selectPrimaryMoneyAccount } from '../../selectors/moneyAccountController'; +import Engine from '../../core/Engine'; +import Logger from '../../util/Logger'; +import { whenMoneyAccountUpgradeReady } from '../../core/Engine/controllers/money-account-upgrade-controller-init'; + +const LOG_PREFIX = '[upgradeMoneyAccount]'; + +const upgradesInFlight = new Set(); + +/** @internal For test use only. */ +export const __resetUpgradesInFlightForTesting = () => { + upgradesInFlight.clear(); +}; + +export const upgradeMoneyAccount = + (): ThunkAction => + (_dispatch, getState) => { + const address = selectPrimaryMoneyAccount(getState())?.address; + if (!address || !isStrictHexString(address)) { + Logger.log(LOG_PREFIX, 'no valid primary money account; skipping', { + address, + }); + return; + } + + if (upgradesInFlight.has(address)) { + Logger.log(LOG_PREFIX, 'upgrade already in flight; skipping', { + address, + }); + return; + } + + upgradesInFlight.add(address); + whenMoneyAccountUpgradeReady() + .then(() => + Engine.context.MoneyAccountUpgradeController.upgradeAccount(address), + ) + .catch((error: unknown) => { + const wrapped = + error instanceof Error ? error : new Error(String(error)); + Logger.error(wrapped, `${LOG_PREFIX} failed`); + }) + .finally(() => { + upgradesInFlight.delete(address); + }); + }; diff --git a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.constants.ts b/app/component-library/components-temp/Contracts/ContractBox/ContractBox.constants.ts deleted file mode 100644 index 2aadf982816..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.constants.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable no-console */ -import { ImageSourcePropType } from 'react-native'; - -const imageSource = - 'https://assets.coingecko.com/coins/images/279/small/ethereum.png?1595348880'; - -export const CONTRACT_PET_NAME = 'DAI'; -export const CONTRACT_BOX_TEST_ID = 'contract-box'; -export const CONTRACT_LOCAL_IMAGE: ImageSourcePropType = { - uri: imageSource, -}; - -export const CONTRACT_COPY_ADDRESS = () => { - console.log('copy address'); -}; - -export const CONTRACT_EXPORT_ADDRESS = () => { - console.log('export address'); -}; - -export const CONTRACT_ON_PRESS = () => { - console.log('contract pressed'); -}; - -export const HAS_BLOCK_EXPLORER = true; -export const TOKEN_SYMBOL = 'D'; diff --git a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.styles.ts b/app/component-library/components-temp/Contracts/ContractBox/ContractBox.styles.ts deleted file mode 100644 index 46ff512773f..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.styles.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Third party dependencies. -import { StyleSheet } from 'react-native'; - -/** - * Style sheet for Account Balance component. - * - * @returns StyleSheet object. - */ -const styleSheet = StyleSheet.create({ - container: { - flexDirection: 'row', - justifyContent: 'space-between', - }, -}); - -export default styleSheet; diff --git a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.test.tsx b/app/component-library/components-temp/Contracts/ContractBox/ContractBox.test.tsx deleted file mode 100644 index d4acbe3aea9..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react-native'; -import TEST_ADDRESS from '../../../../constants/address'; -import ContractBox from './ContractBox'; -import { - CONTRACT_BOX_TEST_ID, - CONTRACT_PET_NAME, - CONTRACT_LOCAL_IMAGE, - CONTRACT_COPY_ADDRESS, - CONTRACT_EXPORT_ADDRESS, - CONTRACT_ON_PRESS, -} from './ContractBox.constants'; -import renderWithProvider from '../../../../util/test/renderWithProvider'; - -describe('ContractBox', () => { - it('should render ContractBox', () => { - renderWithProvider( - , - { - state: { - engine: { - backgroundState: { - PreferencesController: { isIpfsGatewayEnabled: true }, - }, - }, - }, - }, - ); - expect(screen.getAllByTestId(CONTRACT_BOX_TEST_ID).length).toBeGreaterThan( - 0, - ); - }); -}); diff --git a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.tsx b/app/component-library/components-temp/Contracts/ContractBox/ContractBox.tsx deleted file mode 100644 index f06f4e6a176..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import Card from '../../../components/Cards/Card'; -import ContractBoxBase from '../ContractBoxBase'; -import styles from './ContractBox.styles'; -import { View } from 'react-native'; -import { ContractBoxProps } from './ContractBox.types'; -import { CONTRACT_BOX_TEST_ID } from './ContractBox.constants'; - -const ContractBox = ({ - contractAddress, - contractPetName, - contractLocalImage, - onExportAddress, - onCopyAddress, - onContractPress, - hasBlockExplorer, -}: ContractBoxProps) => ( - - - - - -); - -export default ContractBox; diff --git a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.types.ts b/app/component-library/components-temp/Contracts/ContractBox/ContractBox.types.ts deleted file mode 100644 index c7e39064c39..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/ContractBox.types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { ContractBoxBaseProps } from '../ContractBoxBase/ContractBoxBase.types'; - -export type ContractBoxProps = ContractBoxBaseProps; diff --git a/app/component-library/components-temp/Contracts/ContractBox/index.ts b/app/component-library/components-temp/Contracts/ContractBox/index.ts deleted file mode 100644 index bd87bb9fbfb..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ContractBox'; diff --git a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.constants.ts b/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.constants.ts deleted file mode 100644 index 23a62de58a3..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const EXPORT_ICON_TEST_ID = 'export-icon'; -export const COPY_ICON_TEST_ID = 'copy-icon'; -export const CONTRACT_BOX_TEST_ID = 'contract-box'; -export const CONTRACT_BOX_NO_PET_NAME_TEST_ID = 'contract-box-no-pet-name'; diff --git a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.styles.ts b/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.styles.ts deleted file mode 100644 index 8d164eb6a7a..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.styles.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Third party dependencies. -import { StyleSheet } from 'react-native'; -import { Theme } from '../../../../util/theme/models'; -/** - * Style sheet for Account Balance component. - * - * @returns StyleSheet object. - */ -const styleSheet = (params: { theme: Theme }) => { - const { theme } = params; - return StyleSheet.create({ - container: { - flexDirection: 'row', - justifyContent: 'space-between', - flex: 1, - }, - rowContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - imageContainer: { - marginRight: 16, - }, - icon: { - paddingHorizontal: 6, - }, - iconContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - header: { - color: theme.colors.info.default, - }, - }); -}; - -export default styleSheet; diff --git a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.test.tsx b/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.test.tsx deleted file mode 100644 index 58e1e9532d3..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react-native'; -import ContractBoxBase from './ContractBoxBase'; -import TEST_ADDRESS from '../../../../constants/address'; -import { - CONTRACT_PET_NAME, - CONTRACT_LOCAL_IMAGE, - CONTRACT_COPY_ADDRESS, - CONTRACT_ON_PRESS, -} from '../ContractBox/ContractBox.constants'; -import { CONTRACT_BOX_NO_PET_NAME_TEST_ID } from './ContractBoxBase.constants'; -import { ContractBoxBaseProps } from './ContractBoxBase.types'; -import renderWithProvider from '../../../../util/test/renderWithProvider'; - -describe('Component ContractBoxBase', () => { - let props: ContractBoxBaseProps; - - beforeEach(() => { - props = { - contractAddress: TEST_ADDRESS, - contractPetName: CONTRACT_PET_NAME, - contractLocalImage: CONTRACT_LOCAL_IMAGE, - onCopyAddress: CONTRACT_COPY_ADDRESS, - onContractPress: CONTRACT_ON_PRESS, - }; - }); - - const renderComponent = () => - renderWithProvider(, { - state: { - engine: { - backgroundState: { - PreferencesController: { isIpfsGatewayEnabled: true }, - }, - }, - }, - }); - - it('should render correctly', () => { - const { toJSON } = renderComponent(); - expect(toJSON()).toBeDefined(); - }); - - it('renders the no-pet-name element when contractPetName is undefined', () => { - props.contractPetName = undefined; - renderComponent(); - expect(screen.getByTestId(CONTRACT_BOX_NO_PET_NAME_TEST_ID)).toBeTruthy(); - }); -}); diff --git a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.tsx b/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.tsx deleted file mode 100644 index 5ba78777e38..00000000000 --- a/app/component-library/components-temp/Contracts/ContractBoxBase/ContractBoxBase.tsx +++ /dev/null @@ -1,99 +0,0 @@ -// Third party depencies -import React from 'react'; -import { View, Pressable } from 'react-native'; - -// External dependencies. -import Avatar, { - AvatarSize, - AvatarVariant, -} from '../../../components/Avatars/Avatar'; -import Text, { TextVariant } from '../../../components/Texts/Text'; -import { formatAddress } from '../../../../util/address'; -import Icon, { IconName, IconSize } from '../../../components/Icons/Icon'; -import { useStyles } from '../../../hooks'; -import Button, { ButtonVariants } from '../../../components/Buttons/Button'; -import Identicon from '../../../../components/UI/Identicon'; - -// Internal dependencies. -import { ContractBoxBaseProps, IconViewProps } from './ContractBoxBase.types'; -import styleSheet from './ContractBoxBase.styles'; -import { - EXPORT_ICON_TEST_ID, - COPY_ICON_TEST_ID, - CONTRACT_BOX_TEST_ID, - CONTRACT_BOX_NO_PET_NAME_TEST_ID, -} from './ContractBoxBase.constants'; - -const ContractBoxBase = ({ - contractAddress, - contractLocalImage, - contractPetName, - onCopyAddress, - onExportAddress, - onContractPress, - hasBlockExplorer, -}: ContractBoxBaseProps) => { - const formattedAddress = formatAddress(contractAddress, 'short'); - const { - styles, - theme: { colors }, - } = useStyles(styleSheet, {}); - - const renderIconView = ({ onPress, name, size, testID }: IconViewProps) => ( - - - - ); - - return ( - - - - {contractLocalImage ? ( - - ) : ( - - )} - - {contractPetName ? ( - - - {contractPetName} - - {formattedAddress} - - ) : ( - - + + ); }; diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.styles.ts b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.styles.ts deleted file mode 100644 index 37a85b329cd..00000000000 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.styles.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { StyleSheet } from 'react-native'; -import type { Theme } from '../../../../../util/theme/models'; - -const styleSheet = ({ theme }: { theme: Theme }) => - StyleSheet.create({ - container: { - flex: 1, - padding: 16, - backgroundColor: theme.colors.background.default, - }, - listContainer: { - flex: 1, - }, - headerTextContainer: { - gap: 8, - paddingBottom: 16, - }, - balanceCardHeader: { - paddingBottom: 8, - }, - emptyContainer: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - listHeaderContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - termsApply: { - textDecorationLine: 'underline', - }, - balanceCardContainer: { - paddingVertical: 12, - }, - }); - -export default styleSheet; diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx deleted file mode 100644 index cfe0b54a992..00000000000 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/MusdQuickConvertView.test.tsx +++ /dev/null @@ -1,692 +0,0 @@ -import React from 'react'; -import { fireEvent, act } from '@testing-library/react-native'; -import { TransactionStatus } from '@metamask/transaction-controller'; -import { Hex } from '@metamask/utils'; -import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import initialRootState from '../../../../../util/test/initial-root-state'; -import MusdQuickConvertView, { MusdQuickConvertViewTestIds } from './index'; -import { AssetType } from '../../../../Views/confirmations/types/token'; -import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens'; -import { useMusdConversion } from '../../hooks/useMusdConversion'; -import { selectMusdQuickConvertEnabledFlag } from '../../selectors/featureFlags'; -import { - createTokenChainKey, - selectHasInFlightMusdConversion, - selectHasUnapprovedMusdConversion, - selectMusdConversionStatuses, -} from '../../selectors/musdConversionStatus'; -import { MUSD_CONVERSION_APY } from '../../constants/musd'; -import AppConstants from '../../../../../core/AppConstants'; -import { Linking } from 'react-native'; -import { useNavigation, useFocusEffect } from '@react-navigation/native'; -import { MetaMetricsEvents } from '../../../../../core/Analytics'; -import { MUSD_EVENTS_CONSTANTS } from '../../constants/events'; -import { strings } from '../../../../../../locales/i18n'; -import { MUSD_CONVERSION_NAVIGATION_OVERRIDE } from '../../types/musd.types'; -import { ConvertTokenRowTestIds } from '../../components/Musd/ConvertTokenRow'; -import { useMusdBalance } from '../../hooks/useMusdBalance'; -import { IconName } from '@metamask/design-system-react-native'; - -const mockTrackEvent = jest.fn(); -const mockCreateEventBuilder = jest.fn(); -const mockAddProperties = jest.fn(); -const mockBuild = jest.fn(); - -jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ - useAnalytics: () => ({ - trackEvent: mockTrackEvent, - createEventBuilder: mockCreateEventBuilder, - }), -})); - -jest.mock('@react-navigation/native', () => { - const actual = jest.requireActual('@react-navigation/native'); - return { - ...actual, - useNavigation: jest.fn(), - useFocusEffect: jest.fn(), - }; -}); - -jest.mock('../../hooks/useMusdConversionTokens'); -jest.mock('../../hooks/useMusdConversion'); -jest.mock('../../hooks/useMusdBalance'); -jest.mock('../../selectors/featureFlags', () => ({ - ...jest.requireActual('../../selectors/featureFlags'), - selectMusdQuickConvertEnabledFlag: jest.fn(), -})); -jest.mock('../../selectors/musdConversionStatus', () => ({ - ...jest.requireActual('../../selectors/musdConversionStatus'), - selectHasInFlightMusdConversion: jest.fn(), - selectHasUnapprovedMusdConversion: jest.fn(), - selectMusdConversionStatuses: jest.fn(), -})); -const mockGetStakingNavbar = jest.fn(() => ({})); -jest.mock('../../../Navbar', () => ({ - getStakingNavbar: ( - title: string, - nav: unknown, - colors: unknown, - options?: { hasCancelButton?: boolean }, - ) => mockGetStakingNavbar(title, nav, colors, options), -})); -jest.mock('../../../../hooks/useStyles', () => ({ - useStyles: jest.fn(() => ({ - styles: { - container: {}, - headerContainer: {}, - headerTextContainer: {}, - listContainer: {}, - emptyContainer: {}, - listHeaderContainer: {}, - termsApply: {}, - balanceCardHeader: {}, - }, - theme: { colors: {} }, - })), -})); -jest.mock('../../utils/network', () => ({ - getNetworkName: jest.fn(() => 'Ethereum'), -})); -jest.mock('react-native/Libraries/Linking/Linking', () => ({ - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - openURL: jest.fn(), - canOpenURL: jest.fn(), - getInitialURL: jest.fn(), -})); - -const mockSelectMusdQuickConvertEnabledFlag = - selectMusdQuickConvertEnabledFlag as jest.MockedFunction< - typeof selectMusdQuickConvertEnabledFlag - >; -const mockUseMusdConversionTokens = - useMusdConversionTokens as jest.MockedFunction< - typeof useMusdConversionTokens - >; -const mockUseMusdConversion = useMusdConversion as jest.MockedFunction< - typeof useMusdConversion ->; -const mockUseMusdBalance = useMusdBalance as jest.MockedFunction< - typeof useMusdBalance ->; -const mockSelectHasUnapprovedMusdConversion = - selectHasUnapprovedMusdConversion as jest.MockedFunction< - typeof selectHasUnapprovedMusdConversion - >; -const mockSelectHasInFlightMusdConversion = - selectHasInFlightMusdConversion as jest.MockedFunction< - typeof selectHasInFlightMusdConversion - >; -const mockSelectMusdConversionStatuses = - selectMusdConversionStatuses as jest.MockedFunction< - typeof selectMusdConversionStatuses - >; -const mockUseNavigation = useNavigation as jest.MockedFunction< - typeof useNavigation ->; -const mockUseFocusEffect = useFocusEffect as jest.MockedFunction< - typeof useFocusEffect ->; - -const createMockToken = (overrides: Partial = {}): AssetType => ({ - address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Hex, - chainId: '0x1' as Hex, - symbol: 'USDC', - decimals: 6, - rawBalance: '0x5f5e100' as Hex, - name: 'USD Coin', - aggregators: [], - image: 'https://example.com/usdc.png', - balance: '100', - logo: 'https://example.com/usdc.png', - isETH: false, - fiat: { balance: 100 }, - ...overrides, -}); - -describe('MusdQuickConvertView', () => { - const mockInitiateMaxConversion = jest.fn(); - const mockInitiateCustomConversion = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - - mockBuild.mockReturnValue({ name: 'mock-built-event' }); - mockAddProperties.mockImplementation(() => ({ build: mockBuild })); - mockCreateEventBuilder.mockImplementation(() => ({ - addProperties: mockAddProperties, - build: mockBuild, - })); - - mockUseNavigation.mockReturnValue({ - navigate: jest.fn(), - goBack: jest.fn(), - reset: jest.fn(), - setParams: jest.fn(), - dispatch: jest.fn(), - isFocused: jest.fn().mockReturnValue(true), - canGoBack: jest.fn().mockReturnValue(true), - getParent: jest.fn(), - getId: jest.fn(), - getState: jest.fn(), - setOptions: jest.fn(), - addListener: jest.fn(), - removeListener: jest.fn(), - } as unknown as ReturnType); - mockUseFocusEffect.mockImplementation((callback) => { - callback(); - }); - mockSelectMusdQuickConvertEnabledFlag.mockReturnValue(true); - mockUseMusdConversionTokens.mockReturnValue({ - tokens: [], - filterAllowedTokens: jest.fn(), - isConversionToken: jest.fn(), - isMusdSupportedOnChain: jest.fn(), - hasConvertibleTokensByChainId: jest.fn(), - }); - mockUseMusdConversion.mockReturnValue({ - initiateMaxConversion: mockInitiateMaxConversion, - initiateCustomConversion: mockInitiateCustomConversion, - clearError: jest.fn(), - error: null, - hasSeenConversionEducationScreen: true, - }); - mockUseMusdBalance.mockReturnValue({ - hasMusdBalanceOnAnyChain: false, - hasMusdBalanceOnChain: jest.fn(), - tokenBalanceByChain: {}, - fiatBalanceByChain: {}, - fiatBalanceFormattedByChain: {}, - tokenBalanceAggregated: '0', - fiatBalanceAggregated: undefined, - fiatBalanceAggregatedFormatted: '$0.00', - }); - mockSelectHasInFlightMusdConversion.mockReturnValue(false); - mockSelectHasUnapprovedMusdConversion.mockReturnValue(false); - mockSelectMusdConversionStatuses.mockReturnValue({}); - }); - - describe('navigation setup', () => { - it('calls setOptions with getStakingNavbar when screen gains focus', () => { - const mockSetOptions = jest.fn(); - mockUseNavigation.mockReturnValue({ - navigate: jest.fn(), - goBack: jest.fn(), - reset: jest.fn(), - setParams: jest.fn(), - dispatch: jest.fn(), - isFocused: jest.fn().mockReturnValue(true), - canGoBack: jest.fn().mockReturnValue(true), - getParent: jest.fn(), - getId: jest.fn(), - getState: jest.fn(), - setOptions: mockSetOptions, - addListener: jest.fn(), - removeListener: jest.fn(), - } as unknown as ReturnType); - - renderWithProvider(, { - state: initialRootState, - }); - - expect(mockGetStakingNavbar).toHaveBeenCalledWith( - '', - expect.anything(), - expect.anything(), - { hasCancelButton: false }, - ); - expect(mockSetOptions).toHaveBeenCalled(); - }); - }); - - describe('feature flag branching', () => { - it('returns null when quick convert feature flag is disabled', () => { - mockSelectMusdQuickConvertEnabledFlag.mockReturnValue(false); - - const { queryByTestId } = renderWithProvider(, { - state: initialRootState, - }); - - expect( - queryByTestId(MusdQuickConvertViewTestIds.CONTAINER), - ).not.toBeOnTheScreen(); - }); - - it('renders container when quick convert feature flag is enabled', () => { - mockSelectMusdQuickConvertEnabledFlag.mockReturnValue(true); - - const { getByTestId } = renderWithProvider(, { - state: initialRootState, - }); - - expect( - getByTestId(MusdQuickConvertViewTestIds.CONTAINER), - ).toBeOnTheScreen(); - }); - }); - - describe('empty state', () => { - it('displays empty state when no tokens have balance', () => { - mockUseMusdConversionTokens.mockReturnValue({ - tokens: [], - filterAllowedTokens: jest.fn(), - isConversionToken: jest.fn(), - isMusdSupportedOnChain: jest.fn(), - hasConvertibleTokensByChainId: jest.fn(), - }); - - const { getByTestId, getByText } = renderWithProvider( - , - { state: initialRootState }, - ); - - expect( - getByTestId(MusdQuickConvertViewTestIds.EMPTY_STATE), - ).toBeOnTheScreen(); - expect( - getByText(strings('earn.musd_conversion.no_tokens_to_convert')), - ).toBeOnTheScreen(); - }); - - it('displays empty state when tokens have zero rawBalance', () => { - mockUseMusdConversionTokens.mockReturnValue({ - tokens: [ - createMockToken({ rawBalance: '0x0' as Hex }), - createMockToken({ rawBalance: undefined }), - ], - filterAllowedTokens: jest.fn(), - isConversionToken: jest.fn(), - isMusdSupportedOnChain: jest.fn(), - hasConvertibleTokensByChainId: jest.fn(), - }); - - const { getByTestId } = renderWithProvider(, { - state: initialRootState, - }); - - expect( - getByTestId(MusdQuickConvertViewTestIds.EMPTY_STATE), - ).toBeOnTheScreen(); - }); - }); - - describe('token list with balance', () => { - it('renders token rows when tokens have balance', () => { - const token = createMockToken(); - mockUseMusdConversionTokens.mockReturnValue({ - tokens: [token], - filterAllowedTokens: jest.fn(), - isConversionToken: jest.fn(), - isMusdSupportedOnChain: jest.fn(), - hasConvertibleTokensByChainId: jest.fn(), - }); - - const { getByTestId, getAllByTestId, getByText } = renderWithProvider( - , - { state: initialRootState }, - ); - - expect( - getByTestId(MusdQuickConvertViewTestIds.TOKEN_LIST), - ).toBeOnTheScreen(); - expect(getAllByTestId(ConvertTokenRowTestIds.CONTAINER).length).toBe(1); - expect(getByText(strings('earn.your_stablecoins'))).toBeOnTheScreen(); - }); - - it('does not render Your mUSD section when user has no balance on any chain', () => { - const { getByTestId, getByText, queryByText } = renderWithProvider( - , - { state: initialRootState }, - ); - - expect(getByTestId(MusdQuickConvertViewTestIds.HEADER)).toBeOnTheScreen(); - expect( - getByText( - strings('earn.musd_conversion.quick_convert.title', { - percentage: MUSD_CONVERSION_APY, - }), - ), - ).toBeOnTheScreen(); - expect( - queryByText(strings('earn.musd_conversion.your_musd')), - ).not.toBeOnTheScreen(); - }); - - it('renders Your mUSD section when user has balance on any chain', () => { - mockUseMusdBalance.mockReturnValue({ - hasMusdBalanceOnAnyChain: true, - hasMusdBalanceOnChain: jest.fn(), - tokenBalanceByChain: { '0x1': '100.00' }, - fiatBalanceByChain: { '0x1': '100.00' }, - fiatBalanceFormattedByChain: { '0x1': '$100.00' }, - tokenBalanceAggregated: '100.00', - fiatBalanceAggregated: '100.00', - fiatBalanceAggregatedFormatted: '$100.00', - }); - - const { getByText } = renderWithProvider(, { - state: initialRootState, - }); - - expect( - getByText(strings('earn.musd_conversion.your_musd')), - ).toBeOnTheScreen(); - }); - - it('falls back to token balance when fiat balance is unavailable', () => { - mockUseMusdBalance.mockReturnValue({ - hasMusdBalanceOnAnyChain: true, - hasMusdBalanceOnChain: jest.fn(), - tokenBalanceByChain: { '0x1': '123.45' }, - fiatBalanceByChain: {}, - fiatBalanceFormattedByChain: {}, - tokenBalanceAggregated: '123.45', - fiatBalanceAggregated: undefined, - fiatBalanceAggregatedFormatted: '$0.00', - }); - - const { getByText } = renderWithProvider(, { - state: initialRootState, - }); - - expect(getByText('123.45')).toBeOnTheScreen(); - }); - }); - - describe('Max flow', () => { - it('calls initiateMaxConversion when Max button is pressed', async () => { - const token = createMockToken(); - mockUseMusdConversionTokens.mockReturnValue({ - tokens: [token], - filterAllowedTokens: jest.fn(), - isConversionToken: jest.fn(), - isMusdSupportedOnChain: jest.fn(), - hasConvertibleTokensByChainId: jest.fn(), - }); - mockInitiateMaxConversion.mockResolvedValue({ - transactionId: 'tx-max-123', - }); - - const { getAllByTestId } = renderWithProvider(, { - state: initialRootState, - }); - - const maxButton = getAllByTestId(ConvertTokenRowTestIds.MAX_BUTTON)[0]; - - await act(async () => { - fireEvent.press(maxButton); - }); - - expect(mockInitiateMaxConversion).toHaveBeenCalledWith(token); - }); - }); - - describe('Edit flow', () => { - it('calls initiateCustomConversion when Edit button is pressed', async () => { - const token = createMockToken(); - mockUseMusdConversionTokens.mockReturnValue({ - tokens: [token], - filterAllowedTokens: jest.fn(), - isConversionToken: jest.fn(), - isMusdSupportedOnChain: jest.fn(), - hasConvertibleTokensByChainId: jest.fn(), - }); - mockInitiateCustomConversion.mockResolvedValue('tx-edit-789'); - - const { getAllByTestId } = renderWithProvider(, { - state: initialRootState, - }); - - const editButton = getAllByTestId(ConvertTokenRowTestIds.EDIT_BUTTON)[0]; - - await act(async () => { - fireEvent.press(editButton); - }); - - expect(mockInitiateCustomConversion).toHaveBeenCalledWith({ - preferredPaymentToken: { - address: token.address, - chainId: token.chainId, - }, - navigationOverride: MUSD_CONVERSION_NAVIGATION_OVERRIDE.CUSTOM, - }); - }); - }); - - describe('conversion pending state', () => { - it('hides Max and Edit buttons when conversion is pending for token', () => { - const token = createMockToken(); - const tokenChainKey = createTokenChainKey( - token.address as string, - token.chainId as string, - ); - mockUseMusdConversionTokens.mockReturnValue({ - tokens: [token], - filterAllowedTokens: jest.fn(), - isConversionToken: jest.fn(), - isMusdSupportedOnChain: jest.fn(), - hasConvertibleTokensByChainId: jest.fn(), - }); - mockSelectMusdConversionStatuses.mockReturnValue({ - [tokenChainKey]: { - txId: 'tx-pending-1', - status: TransactionStatus.submitted, - isPending: true, - isConfirmed: false, - isFailed: false, - }, - }); - - const { queryAllByTestId } = renderWithProvider( - , - { - state: initialRootState, - }, - ); - - expect(queryAllByTestId(ConvertTokenRowTestIds.MAX_BUTTON).length).toBe( - 0, - ); - expect(queryAllByTestId(ConvertTokenRowTestIds.EDIT_BUTTON).length).toBe( - 0, - ); - }); - }); - - describe('unapproved conversion', () => { - it('does not call initiateMaxConversion or initiateCustomConversion when Max or Edit is pressed and hasUnapprovedMusdConversion is true', async () => { - const token = createMockToken(); - mockUseMusdConversionTokens.mockReturnValue({ - tokens: [token], - filterAllowedTokens: jest.fn(), - isConversionToken: jest.fn(), - isMusdSupportedOnChain: jest.fn(), - hasConvertibleTokensByChainId: jest.fn(), - }); - mockSelectHasUnapprovedMusdConversion.mockReturnValue(true); - - const { getAllByTestId } = renderWithProvider(, { - state: initialRootState, - }); - - const maxButton = getAllByTestId(ConvertTokenRowTestIds.MAX_BUTTON)[0]; - const editButton = getAllByTestId(ConvertTokenRowTestIds.EDIT_BUTTON)[0]; - - await act(async () => { - fireEvent.press(maxButton); - }); - expect(mockInitiateMaxConversion).not.toHaveBeenCalled(); - - await act(async () => { - fireEvent.press(editButton); - }); - expect(mockInitiateCustomConversion).not.toHaveBeenCalled(); - }); - }); - - describe('terms of use', () => { - it('opens terms URL when terms apply text is pressed', () => { - const { getByText } = renderWithProvider(, { - state: initialRootState, - }); - - const termsApplyText = getByText( - strings('earn.musd_conversion.education.terms_apply'), - ); - - act(() => { - fireEvent.press(termsApplyText); - }); - - expect(Linking.openURL).toHaveBeenCalledWith( - AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, - ); - }); - }); - - describe('MetaMetrics', () => { - it('tracks MUSD_BONUS_TERMS_OF_USE_PRESSED event when terms apply text is pressed', () => { - const { getByText } = renderWithProvider(, { - state: initialRootState, - }); - - mockTrackEvent.mockClear(); - mockCreateEventBuilder.mockClear(); - mockAddProperties.mockClear(); - mockBuild.mockClear(); - - const termsApplyText = getByText( - strings('earn.musd_conversion.education.terms_apply'), - ); - - act(() => { - fireEvent.press(termsApplyText); - }); - - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED, - ); - expect(mockAddProperties).toHaveBeenCalledWith({ - location: - MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN, - url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, - }); - expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); - }); - - it('tracks MUSD_QUICK_CONVERT_SCREEN_VIEWED event on mount', () => { - renderWithProvider(, { - state: initialRootState, - }); - - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.MUSD_QUICK_CONVERT_SCREEN_VIEWED, - ); - expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); - }); - - it('does not track MUSD_QUICK_CONVERT_SCREEN_VIEWED when feature flag is disabled', () => { - mockSelectMusdQuickConvertEnabledFlag.mockReturnValue(false); - - renderWithProvider(, { - state: initialRootState, - }); - - expect(mockCreateEventBuilder).not.toHaveBeenCalledWith( - MetaMetricsEvents.MUSD_QUICK_CONVERT_SCREEN_VIEWED, - ); - }); - - it('tracks MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED event when Max button is pressed', async () => { - const token = createMockToken(); - mockUseMusdConversionTokens.mockReturnValue({ - tokens: [token], - filterAllowedTokens: jest.fn(), - isConversionToken: jest.fn(), - isMusdSupportedOnChain: jest.fn(), - hasConvertibleTokensByChainId: jest.fn(), - }); - mockInitiateMaxConversion.mockResolvedValue({ - transactionId: 'tx-max-123', - }); - - const { getAllByTestId } = renderWithProvider(, { - state: initialRootState, - }); - - mockTrackEvent.mockClear(); - mockCreateEventBuilder.mockClear(); - mockAddProperties.mockClear(); - mockBuild.mockClear(); - - const maxButton = getAllByTestId(ConvertTokenRowTestIds.MAX_BUTTON)[0]; - - await act(async () => { - fireEvent.press(maxButton); - }); - - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED, - ); - expect(mockAddProperties).toHaveBeenCalledWith({ - location: - MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN, - button_type: 'text_button', - button_action: 'max', - button_text: strings('earn.musd_conversion.max'), - redirects_to: - MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS - .QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN, - asset_symbol: token.symbol, - network_chain_id: token.chainId, - network_name: 'Ethereum', - }); - expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); - }); - - it('tracks MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED event when Edit button is pressed', async () => { - const token = createMockToken(); - mockUseMusdConversionTokens.mockReturnValue({ - tokens: [token], - filterAllowedTokens: jest.fn(), - isConversionToken: jest.fn(), - isMusdSupportedOnChain: jest.fn(), - hasConvertibleTokensByChainId: jest.fn(), - }); - mockInitiateCustomConversion.mockResolvedValue('tx-edit-789'); - - const { getAllByTestId } = renderWithProvider(, { - state: initialRootState, - }); - - mockTrackEvent.mockClear(); - mockCreateEventBuilder.mockClear(); - mockAddProperties.mockClear(); - mockBuild.mockClear(); - - const editButton = getAllByTestId(ConvertTokenRowTestIds.EDIT_BUTTON)[0]; - - await act(async () => { - fireEvent.press(editButton); - }); - - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED, - ); - expect(mockAddProperties).toHaveBeenCalledWith({ - location: - MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN, - button_type: 'icon_button', - icon: IconName.Edit, - button_action: 'custom', - redirects_to: - MUSD_EVENTS_CONSTANTS.EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, - asset_symbol: token.symbol, - network_chain_id: token.chainId, - network_name: 'Ethereum', - }); - expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); - }); - }); -}); diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.styles.ts b/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.styles.ts deleted file mode 100644 index 269a3932009..00000000000 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.styles.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { Theme } from '../../../../../../../util/theme/models'; - -const styleSheet = (_params: { theme: Theme }) => - StyleSheet.create({ - container: { - width: '100%', - justifyContent: 'space-between', - flexDirection: 'row', - }, - left: { - flexDirection: 'row', - gap: 16, - }, - right: { - alignSelf: 'center', - }, - tokenIconContainer: { - width: 32, - height: 32, - position: 'relative', - }, - tokenIcon: { - width: 32, - height: 32, - }, - }); - -export default styleSheet; diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.test.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.test.tsx deleted file mode 100644 index 45d598b8906..00000000000 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import { CHAIN_IDS } from '@metamask/transaction-controller'; - -jest.mock('../../../../../../../util/networks', () => ({ - getNetworkImageSource: jest.fn(() => ({ uri: 'mock-network-image' })), -})); - -import renderWithProvider from '../../../../../../../util/test/renderWithProvider'; -import MusdBalanceCard, { MusdBalanceCardTestIds } from './MusdBalanceCard'; -import { MUSD_TOKEN } from '../../../../constants/musd'; -import initialRootState from '../../../../../../../util/test/initial-root-state'; - -describe('MusdBalanceCard', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('rendering', () => { - it('renders card container', () => { - const { getByTestId } = renderWithProvider( - , - { - state: initialRootState, - }, - ); - - expect(getByTestId(MusdBalanceCardTestIds.CONTAINER)).toBeOnTheScreen(); - expect(getByTestId(MusdBalanceCardTestIds.TOKEN_ICON)).toBeOnTheScreen(); - }); - - it('displays balance from props', () => { - const formattedBalance = '$1,234.56'; - const { getByText } = renderWithProvider( - , - { - state: initialRootState, - }, - ); - - expect(getByText(formattedBalance)).toBeOnTheScreen(); - }); - - it('displays mUSD symbol', () => { - const { getByText } = renderWithProvider( - , - { - state: initialRootState, - }, - ); - - expect(getByText(MUSD_TOKEN.symbol)).toBeOnTheScreen(); - }); - - it('displays percentage bonus text from localization', () => { - const { getByText } = renderWithProvider( - , - { - state: initialRootState, - }, - ); - - expect(getByText('3% bonus')).toBeOnTheScreen(); - }); - }); -}); diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.tsx deleted file mode 100644 index a0876b88b3e..00000000000 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/MusdBalanceCard.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; -import { View, Image } from 'react-native'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../../../component-library/components/Texts/Text'; -import Badge, { - BadgeVariant, -} from '../../../../../../../component-library/components/Badges/Badge'; -import BadgeWrapper, { - BadgePosition, -} from '../../../../../../../component-library/components/Badges/BadgeWrapper'; -import { AvatarSize } from '../../../../../../../component-library/components/Avatars/Avatar'; -import { useStyles } from '../../../../../../hooks/useStyles'; -import { strings } from '../../../../../../../../locales/i18n'; -import { MUSD_CONVERSION_APY, MUSD_TOKEN } from '../../../../constants/musd'; -import { getNetworkImageSource } from '../../../../../../../util/networks'; -import styleSheet from './MusdBalanceCard.styles'; -import { Hex } from '@metamask/utils'; - -/** - * Test IDs for the MusdBalanceCard component. - */ -export const MusdBalanceCardTestIds = { - CONTAINER: 'musd-balance-card', - TOKEN_ICON: 'musd-token-icon', -} as const; - -interface MusdBalanceCardProps { - chainId: Hex; - balance: string; -} - -const MusdBalanceCard = ({ chainId, balance }: MusdBalanceCardProps) => { - const { styles } = useStyles(styleSheet, {}); - - return ( - - - - - } - > - - - - - {balance} - - {MUSD_TOKEN.symbol} - - - - - - - {strings('earn.musd_conversion.percentage_bonus', { - percentage: MUSD_CONVERSION_APY, - })} - - - - ); -}; - -export default MusdBalanceCard; diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/index.ts b/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/index.ts deleted file mode 100644 index fbcb42a56ca..00000000000 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/components/MusdBalanceCard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './MusdBalanceCard'; diff --git a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx b/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx deleted file mode 100644 index a6221510000..00000000000 --- a/app/components/UI/Earn/Views/MusdQuickConvertView/index.tsx +++ /dev/null @@ -1,385 +0,0 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; -import { View, SectionList, Linking } from 'react-native'; -import { useSelector } from 'react-redux'; -import { useNavigation, useFocusEffect } from '@react-navigation/native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { Hex } from '@metamask/utils'; -import Text, { - TextColor, - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import { useStyles } from '../../../../hooks/useStyles'; -import { strings } from '../../../../../../locales/i18n'; -import { getStakingNavbar } from '../../../Navbar'; -import { AssetType } from '../../../../Views/confirmations/types/token'; -import { useMusdConversionTokens } from '../../hooks/useMusdConversionTokens'; -import { useMusdConversion } from '../../hooks/useMusdConversion'; -import { selectMusdQuickConvertEnabledFlag } from '../../selectors/featureFlags'; -import { - createTokenChainKey, - selectHasInFlightMusdConversion, - selectHasUnapprovedMusdConversion, - selectMusdConversionStatuses, -} from '../../selectors/musdConversionStatus'; -import ConvertTokenRow from '../../components/Musd/ConvertTokenRow'; -import styleSheet from './MusdQuickConvertView.styles'; -import Tag from '../../../../../component-library/components/Tags/Tag'; -import { TagProps } from '../../../../../component-library/components/Tags/Tag/Tag.types'; -import { MUSD_CONVERSION_APY } from '../../constants/musd'; -import AppConstants from '../../../../../core/AppConstants'; -import MusdBalanceCard from './components/MusdBalanceCard'; -import { MUSD_CONVERSION_NAVIGATION_OVERRIDE } from '../../types/musd.types'; -import Logger from '../../../../../util/Logger'; -import { useMusdBalance } from '../../hooks/useMusdBalance'; -import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; -import { MetaMetricsEvents } from '../../../../../core/Analytics'; -import { MUSD_EVENTS_CONSTANTS } from '../../constants/events'; -import { getNetworkName } from '../../utils/network'; -import { IconName } from '@metamask/design-system-react-native'; - -const { EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS; - -export const MusdQuickConvertViewTestIds = { - CONTAINER: 'musd-quick-convert-view-container', - TOKEN_LIST: 'musd-quick-convert-view-token-list', - EMPTY_STATE: 'musd-quick-convert-view-empty-state', - LOADING: 'musd-quick-convert-view-loading', - HEADER: 'musd-quick-convert-view-header', -} as const; - -interface SectionHeaderProps { - title: string; - tag?: TagProps; -} - -const SectionHeader = ({ title, tag }: SectionHeaderProps) => { - const { styles } = useStyles(styleSheet, {}); - - return ( - - {title} - {tag && } - - ); -}; - -interface TokenSection { - title: string; - tag?: TagProps; - data: AssetType[]; -} - -/** - * Quick Convert Token List screen. - * - * Displays all convertible tokens the user holds with Max and Edit buttons. - * - Max: Opens a bottom sheet for quick full-balance conversion - * - Edit: Navigates to the existing custom amount confirmation screen - */ -const MusdQuickConvertView = () => { - const { styles, theme } = useStyles(styleSheet, {}); - const { colors } = theme; - const navigation = useNavigation(); - const { initiateCustomConversion, initiateMaxConversion } = - useMusdConversion(); - - const { trackEvent, createEventBuilder } = useAnalytics(); - - // Feature flags - const isQuickConvertEnabled = useSelector(selectMusdQuickConvertEnabledFlag); - - // Get convertible tokens - const { tokens: conversionTokens } = useMusdConversionTokens(); - - const hasUnapprovedMusdConversion = useSelector( - selectHasUnapprovedMusdConversion, - ); - const hasInFlightMusdConversion = useSelector( - selectHasInFlightMusdConversion, - ); - - const conversionStatusesByTokenChainKey = useSelector( - selectMusdConversionStatuses, - ); - - // Set up navigation header - useFocusEffect( - useCallback(() => { - navigation.setOptions( - getStakingNavbar('', navigation, colors, { - hasCancelButton: false, - }), - ); - }, [navigation, colors]), - ); - - useEffect(() => { - if (!isQuickConvertEnabled) return; - - trackEvent( - createEventBuilder( - MetaMetricsEvents.MUSD_QUICK_CONVERT_SCREEN_VIEWED, - ).build(), - ); - }, [createEventBuilder, isQuickConvertEnabled, trackEvent]); - - // navigate to max conversion bottom sheet - const handleMaxPress = useCallback( - async (token: AssetType) => { - trackEvent( - createEventBuilder( - MetaMetricsEvents.MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED, - ) - .addProperties({ - location: EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN, - button_type: 'text_button', - button_action: 'max', - button_text: strings('earn.musd_conversion.max'), - redirects_to: - EVENT_LOCATIONS.QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN, - asset_symbol: token.symbol, - network_chain_id: token.chainId, - network_name: token.chainId - ? getNetworkName(token.chainId as Hex) - : 'unknown', - }) - .build(), - ); - - try { - await initiateMaxConversion(token); - } catch { - Logger.error(new Error('Failed to initiate max conversion'), { - tags: { - feature: 'musd_conversion', - action: 'initiate_max_conversion', - }, - context: { - name: 'MusdQuickConvertView.handleMaxPress', - data: { - token, - }, - }, - }); - } - }, - [createEventBuilder, initiateMaxConversion, trackEvent], - ); - - // navigate to existing confirmation screen - const handleEditPress = useCallback( - async (token: AssetType) => { - trackEvent( - createEventBuilder( - MetaMetricsEvents.MUSD_QUICK_CONVERT_TOKEN_ROW_BUTTON_CLICKED, - ) - .addProperties({ - location: EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN, - button_type: 'icon_button', - icon: IconName.Edit, - button_action: 'custom', - redirects_to: EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, - asset_symbol: token.symbol, - network_chain_id: token.chainId, - network_name: token.chainId - ? getNetworkName(token.chainId as Hex) - : 'unknown', - }) - .build(), - ); - - try { - await initiateCustomConversion({ - preferredPaymentToken: { - address: token.address as Hex, - chainId: token.chainId as Hex, - }, - navigationOverride: MUSD_CONVERSION_NAVIGATION_OVERRIDE.CUSTOM, - }); - } catch { - Logger.error(new Error('Failed to initiate custom conversion'), { - tags: { - feature: 'musd_conversion', - action: 'initiate_custom_conversion', - }, - context: { - name: 'MusdQuickConvertView.handleEditPress', - data: { - token, - }, - }, - }); - } - }, - [createEventBuilder, initiateCustomConversion, trackEvent], - ); - - const tokensWithBalance = useMemo( - () => - conversionTokens.filter( - (token) => token.rawBalance && token.rawBalance !== '0x0', - ), - [conversionTokens], - ); - - // Keep this as a SectionList even while we only have one section today. - // In the near future we'll add additional sections (e.g. non-stablecoins) below. - const tokenSections = useMemo(() => { - if (tokensWithBalance.length === 0) { - return []; - } - - return [ - { - title: strings('earn.your_stablecoins'), - data: tokensWithBalance, - }, - ]; - }, [tokensWithBalance]); - - // Render individual token row - const renderTokenItem = useCallback( - ({ item }: { item: AssetType }) => { - const tokenAddress = item.address; - const tokenChainId = item.chainId; - - const tokenChainKey = - tokenAddress && tokenChainId - ? createTokenChainKey(tokenAddress, tokenChainId) - : undefined; - - const txStatusInfo = tokenChainKey - ? conversionStatusesByTokenChainKey[tokenChainKey] - : undefined; - - return ( - - ); - }, - [ - conversionStatusesByTokenChainKey, - handleEditPress, - handleMaxPress, - hasInFlightMusdConversion, - hasUnapprovedMusdConversion, - ], - ); - - const { - fiatBalanceFormattedByChain, - tokenBalanceByChain, - hasMusdBalanceOnAnyChain, - } = useMusdBalance(); - - const renderSectionHeader = useCallback( - ({ section }: { section: TokenSection }) => ( - - ), - [], - ); - - // Ideally users can't get to the quick convert view if they don't have any tokens to convert. - const renderEmptyState = () => ( - - - {strings('earn.musd_conversion.no_tokens_to_convert')} - - - ); - - const handleTermsOfUsePressed = useCallback(() => { - trackEvent( - createEventBuilder(MetaMetricsEvents.MUSD_BONUS_TERMS_OF_USE_PRESSED) - .addProperties({ - location: EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN, - url: AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE, - }) - .build(), - ); - Linking.openURL(AppConstants.URLS.MUSD_CONVERSION_BONUS_TERMS_OF_USE); - }, [createEventBuilder, trackEvent]); - - // If feature flags are not enabled, don't render - if (!isQuickConvertEnabled) { - return null; - } - - return ( - - {/* Header section */} - - - - {strings('earn.musd_conversion.quick_convert.title', { - percentage: MUSD_CONVERSION_APY, - })} - - - {strings('earn.musd_conversion.quick_convert.subtitle', { - percentage: MUSD_CONVERSION_APY, - })}{' '} - - {strings('earn.musd_conversion.education.terms_apply')} - - - - {hasMusdBalanceOnAnyChain && ( - - - {strings('earn.musd_conversion.your_musd')} - - {Object.keys(tokenBalanceByChain).map((chainId) => ( - - - - ))} - - )} - - - {/* Token list */} - - style={styles.listContainer} - sections={tokenSections} - renderItem={renderTokenItem} - keyExtractor={(item) => `${item.address}-${item.chainId}`} - renderSectionHeader={renderSectionHeader} - ListEmptyComponent={renderEmptyState} - showsVerticalScrollIndicator={false} - testID={MusdQuickConvertViewTestIds.TOKEN_LIST} - stickySectionHeadersEnabled={false} - /> - - ); -}; - -export default MusdQuickConvertView; diff --git a/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx index 9917f800bce..6f2633ce026 100644 --- a/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx +++ b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.test.tsx @@ -197,7 +197,7 @@ describe('AssetOverviewClaimBonus', () => { ).not.toBeDisabled(); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$30.00'); + ).toHaveTextContent('$30.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.LIFETIME_VALUE), ).toHaveTextContent('+$221.59'); @@ -239,7 +239,7 @@ describe('AssetOverviewClaimBonus', () => { ).toBeDisabled(); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$15.00'); + ).toHaveTextContent('$15.00'); }); }); @@ -279,7 +279,7 @@ describe('AssetOverviewClaimBonus', () => { ).not.toBeDisabled(); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$0.00'); + ).toHaveTextContent('$0.00'); }); }); @@ -318,7 +318,7 @@ describe('AssetOverviewClaimBonus', () => { ).toBeDisabled(); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$0.00'); + ).toHaveTextContent('$0.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.LIFETIME_VALUE), ).toHaveTextContent('$0.00'); @@ -560,7 +560,7 @@ describe('AssetOverviewClaimBonus', () => { // (700 + 300) * 3% = 30.00 expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$30.00'); + ).toHaveTextContent('$30.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON), ).toHaveTextContent('Claim $5.00 bonus'); @@ -586,7 +586,7 @@ describe('AssetOverviewClaimBonus', () => { // 500 * 3% = 15.00, "Accruing next bonus" because balance > 0 & no claim expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$15.00'); + ).toHaveTextContent('$15.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON), ).toHaveTextContent('Accruing next bonus'); @@ -614,7 +614,7 @@ describe('AssetOverviewClaimBonus', () => { // on Linea and always returned undefined, dropping Linea balances. expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$6.00'); + ).toHaveTextContent('$6.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON), ).toHaveTextContent('Accruing next bonus'); @@ -642,7 +642,7 @@ describe('AssetOverviewClaimBonus', () => { expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$0.00'); + ).toHaveTextContent('$0.00'); expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.CLAIM_BUTTON), ).toHaveTextContent('No accruing bonus'); @@ -683,7 +683,7 @@ describe('AssetOverviewClaimBonus', () => { expect( getByTestId(ASSET_OVERVIEW_CLAIM_BONUS_TEST_IDS.ANNUAL_BONUS_VALUE), - ).toHaveTextContent('+$4.50'); + ).toHaveTextContent('$4.50'); }); it('looks up mUSD on each chain using checksummed addresses', () => { diff --git a/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx index 67903b1e659..be7bddd408a 100644 --- a/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx +++ b/app/components/UI/Earn/components/AssetOverviewClaimBonus/AssetOverviewClaimBonus.tsx @@ -129,8 +129,8 @@ const AssetOverviewClaimBonus: React.FC = ({ [balance], ); const formattedAnnualBonus = hasBalance - ? `+$${estimatedAnnualBonus.toFixed(2)}` - : '+$0.00'; + ? `$${estimatedAnnualBonus.toFixed(2)}` + : '$0.00'; // Lifetime bonus: white $0.00 until first claim, then green +$X. const hasLifetimeBonus = Number(lifetimeBonusClaimed) > 0; @@ -356,7 +356,7 @@ const AssetOverviewClaimBonus: React.FC = ({ {/* CTA Button */} + + + ); +}; + +export default MoneyBalanceCard; diff --git a/app/components/UI/Money/components/MoneyBalanceCard/index.ts b/app/components/UI/Money/components/MoneyBalanceCard/index.ts new file mode 100644 index 00000000000..f9961f83ad5 --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceCard/index.ts @@ -0,0 +1,2 @@ +export { default } from './MoneyBalanceCard'; +export { MoneyBalanceCardTestIds } from './MoneyBalanceCard.testIds'; diff --git a/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.styles.ts b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.styles.ts new file mode 100644 index 00000000000..83a15787e29 --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.styles.ts @@ -0,0 +1,14 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => + StyleSheet.create({ + content: { + paddingHorizontal: 16, + paddingBottom: 16, + gap: 16, + backgroundColor: params.theme.colors.background.default, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.test.tsx b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.test.tsx new file mode 100644 index 00000000000..1ce76bb0d7a --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import MoneyBalanceInfoSheet from './MoneyBalanceInfoSheet'; +import { MoneyBalanceInfoSheetTestIds } from './MoneyBalanceInfoSheet.testIds'; +import { strings } from '../../../../../../locales/i18n'; + +const mockOnCloseBottomSheet = jest.fn((cb?: () => void) => cb?.()); +const mockGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + goBack: mockGoBack, + }), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const { View, Text: RNText, Pressable } = jest.requireActual('react-native'); + + const MockBottomSheet = ReactActual.forwardRef( + ( + { + children, + testID, + goBack, + }: { + children: React.ReactNode; + testID?: string; + goBack?: () => void; + }, + ref: React.Ref<{ onCloseBottomSheet: (cb?: () => void) => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: mockOnCloseBottomSheet, + onOpenBottomSheet: jest.fn(), + })); + return ReactActual.createElement( + View, + { testID }, + ReactActual.createElement( + Pressable, + { + testID: 'bottom-sheet-go-back', + onPress: goBack, + }, + ReactActual.createElement(RNText, {}, 'go-back'), + ), + children, + ); + }, + ); + + const MockBottomSheetHeader = ({ + children, + onClose, + }: { + children: React.ReactNode; + onClose?: () => void; + }) => + ReactActual.createElement( + View, + { testID: 'bottom-sheet-header' }, + ReactActual.createElement( + Pressable, + { testID: 'bottom-sheet-close-button', onPress: onClose }, + ReactActual.createElement(RNText, {}, 'close'), + ), + children, + ); + + return { + ...actual, + BottomSheet: MockBottomSheet, + BottomSheetHeader: MockBottomSheetHeader, + }; +}); + +describe('MoneyBalanceInfoSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the container', () => { + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyBalanceInfoSheetTestIds.CONTAINER), + ).toBeOnTheScreen(); + }); + + it('renders the sheet title', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceInfoSheetTestIds.TITLE)).toHaveTextContent( + strings('money.balance_card.info_sheet_title'), + ); + }); + + it('renders the body copy', () => { + const { getByTestId } = renderWithProvider(); + + expect(getByTestId(MoneyBalanceInfoSheetTestIds.BODY)).toHaveTextContent( + strings('money.balance_card.info_sheet_body'), + ); + }); + + it('closes the sheet when the close button is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId('bottom-sheet-close-button')); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); + + it('navigates back when the BottomSheet goBack handler is invoked', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId('bottom-sheet-go-back')); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.testIds.ts b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.testIds.ts new file mode 100644 index 00000000000..11f47f9297d --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.testIds.ts @@ -0,0 +1,5 @@ +export const MoneyBalanceInfoSheetTestIds = { + CONTAINER: 'money-balance-info-sheet-container', + TITLE: 'money-balance-info-sheet-title', + BODY: 'money-balance-info-sheet-body', +} as const; diff --git a/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.tsx b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.tsx new file mode 100644 index 00000000000..3b3ec1a9841 --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceInfoSheet/MoneyBalanceInfoSheet.tsx @@ -0,0 +1,56 @@ +import React, { useCallback, useRef } from 'react'; +import { View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { + BottomSheet, + BottomSheetHeader, + type BottomSheetRef, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './MoneyBalanceInfoSheet.styles'; +import { MoneyBalanceInfoSheetTestIds } from './MoneyBalanceInfoSheet.testIds'; + +const MoneyBalanceInfoSheet = () => { + const sheetRef = useRef(null); + const navigation = useNavigation(); + const { styles } = useStyles(styleSheet, {}); + + const handleGoBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleClose = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + return ( + + + + {strings('money.balance_card.info_sheet_title')} + + + + + {strings('money.balance_card.info_sheet_body')} + + + + ); +}; + +export default MoneyBalanceInfoSheet; diff --git a/app/components/UI/Money/components/MoneyBalanceInfoSheet/index.ts b/app/components/UI/Money/components/MoneyBalanceInfoSheet/index.ts new file mode 100644 index 00000000000..9bc437212a2 --- /dev/null +++ b/app/components/UI/Money/components/MoneyBalanceInfoSheet/index.ts @@ -0,0 +1,2 @@ +export { default } from './MoneyBalanceInfoSheet'; +export { MoneyBalanceInfoSheetTestIds } from './MoneyBalanceInfoSheet.testIds'; diff --git a/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx b/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx index 183b8878016..bb8f27d72fa 100644 --- a/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx +++ b/app/components/UI/Money/components/MoneyBalanceSummary/MoneyBalanceSummary.tsx @@ -9,6 +9,8 @@ import { IconColor, IconName, Skeleton, + Tag, + TagSeverity, Text, TextColor, TextVariant, @@ -51,7 +53,7 @@ const MoneyBalanceSummary = ({ fontWeight={FontWeight.Bold} testID={MoneyBalanceSummaryTestIds.TITLE} > - {strings('money.title')} + {strings('money.your_balance')} @@ -87,19 +89,19 @@ const MoneyBalanceSummary = ({ /> ) : ( isPositiveNumber(apy) && ( - {strings('money.apy_label', { percentage: apy })} - + ) )} {onApyInfoPress && isPositiveNumber(apy) && !isLoading && ( diff --git a/app/components/UI/Money/components/MoneyCondensedInfoCards/MoneyCondensedInfoCards.test.tsx b/app/components/UI/Money/components/MoneyCondensedInfoCards/MoneyCondensedInfoCards.test.tsx index 7fee5790fbd..b7c2c74d5d5 100644 --- a/app/components/UI/Money/components/MoneyCondensedInfoCards/MoneyCondensedInfoCards.test.tsx +++ b/app/components/UI/Money/components/MoneyCondensedInfoCards/MoneyCondensedInfoCards.test.tsx @@ -3,6 +3,9 @@ import { render, fireEvent } from '@testing-library/react-native'; import MoneyCondensedInfoCards from './MoneyCondensedInfoCards'; import { MoneyCondensedInfoCardsTestIds } from './MoneyCondensedInfoCards.testIds'; import { strings } from '../../../../../../locales/i18n'; +import ethTreeImage from '../../../../../images/eth-tree.png'; +import musdCoinImage from '../../../../../images/musd-icon-no-background-2x.png'; +import purpleOrangeRibbonImage from '../../../../../images/purple-orange-ribbon.png'; describe('MoneyCondensedInfoCards', () => { it('renders all three cards', () => { @@ -19,6 +22,26 @@ describe('MoneyCondensedInfoCards', () => { ).toBeOnTheScreen(); }); + it('renders card artwork via expo-image with expected sources and styles', () => { + const { getByTestId } = render(); + + const howItWorksImage = getByTestId( + MoneyCondensedInfoCardsTestIds.HOW_IT_WORKS_IMAGE, + ); + expect(howItWorksImage.props.source).toBe(ethTreeImage); + expect(howItWorksImage.props.style).toEqual({ height: 50, width: 64 }); + + const musdImage = getByTestId(MoneyCondensedInfoCardsTestIds.MUSD_IMAGE); + expect(musdImage.props.source).toBe(musdCoinImage); + expect(musdImage.props.style).toEqual({ height: 50, width: 50 }); + + const whatYouGetImage = getByTestId( + MoneyCondensedInfoCardsTestIds.WHAT_YOU_GET_IMAGE, + ); + expect(whatYouGetImage.props.source).toBe(purpleOrangeRibbonImage); + expect(whatYouGetImage.props.style).toEqual({ height: 64, width: 64 }); + }); + it('renders correct titles and subtitles', () => { const { getByText } = render(); diff --git a/app/components/UI/Money/components/MoneyCondensedInfoCards/MoneyCondensedInfoCards.testIds.ts b/app/components/UI/Money/components/MoneyCondensedInfoCards/MoneyCondensedInfoCards.testIds.ts index 3c98d2d374c..2e4ac210293 100644 --- a/app/components/UI/Money/components/MoneyCondensedInfoCards/MoneyCondensedInfoCards.testIds.ts +++ b/app/components/UI/Money/components/MoneyCondensedInfoCards/MoneyCondensedInfoCards.testIds.ts @@ -1,6 +1,9 @@ export const MoneyCondensedInfoCardsTestIds = { CONTAINER: 'money-condensed-info-cards-container', HOW_IT_WORKS_CARD: 'money-condensed-info-cards-how-it-works', + HOW_IT_WORKS_IMAGE: 'money-condensed-info-cards-how-it-works-image', MUSD_CARD: 'money-condensed-info-cards-musd', + MUSD_IMAGE: 'money-condensed-info-cards-musd-image', WHAT_YOU_GET_CARD: 'money-condensed-info-cards-what-you-get', + WHAT_YOU_GET_IMAGE: 'money-condensed-info-cards-what-you-get-image', } as const; diff --git a/app/components/UI/Money/components/MoneyCondensedInfoCards/MoneyCondensedInfoCards.tsx b/app/components/UI/Money/components/MoneyCondensedInfoCards/MoneyCondensedInfoCards.tsx index 8b4d478e820..3aafdc2bb91 100644 --- a/app/components/UI/Money/components/MoneyCondensedInfoCards/MoneyCondensedInfoCards.tsx +++ b/app/components/UI/Money/components/MoneyCondensedInfoCards/MoneyCondensedInfoCards.tsx @@ -15,6 +15,10 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import { MoneyCondensedInfoCardsTestIds } from './MoneyCondensedInfoCards.testIds'; +import ethTreeImage from '../../../../../images/eth-tree.png'; +import musdCoinImage from '../../../../../images/musd-icon-no-background-2x.png'; +import purpleOrangeRibbonImage from '../../../../../images/purple-orange-ribbon.png'; +import { Image, ImageProps } from 'expo-image'; interface MoneyCondensedInfoCardsProps { onHowItWorksPress?: () => void; @@ -23,13 +27,13 @@ interface MoneyCondensedInfoCardsProps { } const CondensedCard = ({ - iconName, + image, title, subtitle, onPress, testID, }: { - iconName: IconName; + image: Pick; title: string; subtitle: string; onPress?: () => void; @@ -45,11 +49,19 @@ const CondensedCard = ({ alignItems={BoxAlignItems.Center} twClassName="bg-muted rounded-xl size-[78px] justify-center" > - + {image?.source ? ( + + ) : ( + + )} @@ -77,21 +89,33 @@ const MoneyCondensedInfoCards = ({ testID={MoneyCondensedInfoCardsTestIds.CONTAINER} > ({ ...jest.requireActual('react-redux'), @@ -19,6 +27,43 @@ jest.mock('react-redux', () => ({ const mockUseSelector = useSelector as jest.Mock; +const mockUseMusdConversionTokens = jest.fn(); +const mockInitiateMaxConversion = jest.fn(); +const mockInitiateCustomConversion = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockAddProperties = jest.fn(); +const mockBuild = jest.fn(() => ({ event: 'built' })); +const mockCreateEventBuilder = jest.fn(() => ({ + addProperties: mockAddProperties, + build: mockBuild, +})); + +mockAddProperties.mockReturnValue({ + build: mockBuild, +}); + +jest.mock('../../../Earn/hooks/useMusdConversionTokens', () => ({ + useMusdConversionTokens: () => mockUseMusdConversionTokens(), +})); + +jest.mock('../../../Earn/hooks/useMusdConversion', () => ({ + useMusdConversion: () => ({ + initiateMaxConversion: mockInitiateMaxConversion, + initiateCustomConversion: mockInitiateCustomConversion, + }), +})); + +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +jest.mock('../../../Earn/utils/network', () => ({ + getNetworkName: jest.fn(() => 'Ethereum Mainnet'), +})); + jest.mock('../../../../../component-library/base-components/TagBase', () => ({ __esModule: true, default: ({ children }: { children: React.ReactNode }) => { @@ -38,9 +83,9 @@ jest.mock( }, ); -jest.mock('../../../Earn/components/Musd/ConvertTokenRow', () => { +jest.mock('../../../Earn/components/Musd/MusdConversionAssetRow', () => { const { TouchableOpacity, Text } = jest.requireActual('react-native'); - const MockConvertTokenRow = ({ + const MockMusdConversionAssetRow = ({ token, onMaxPress, onEditPress, @@ -49,27 +94,27 @@ jest.mock('../../../Earn/components/Musd/ConvertTokenRow', () => { onMaxPress: (t: unknown) => void; onEditPress: (t: unknown) => void; }) => ( - - {token.symbol} + + {token.symbol} onMaxPress(token)} /> onEditPress(token)} /> ); - MockConvertTokenRow.displayName = 'ConvertTokenRow'; + MockMusdConversionAssetRow.displayName = 'MusdConversionAssetRow'; return { __esModule: true, - default: MockConvertTokenRow, - ConvertTokenRowTestIds: { - CONTAINER: 'convert-token-row-container', - TOKEN_NAME: 'convert-token-row-token-name', - MAX_BUTTON: 'convert-token-row-max-button', - EDIT_BUTTON: 'convert-token-row-edit-button', + default: MockMusdConversionAssetRow, + MusdConversionAssetRowTestIds: { + CONTAINER: 'musd-conversion-asset-row-container', + TOKEN_NAME: 'musd-conversion-asset-row-token-name', + MAX_BUTTON: 'musd-conversion-asset-row-max-button', + EDIT_BUTTON: 'musd-conversion-asset-row-edit-button', }, }; }); @@ -103,28 +148,24 @@ const MOCK_DAI: AssetType = { const mockTokens: AssetType[] = [MOCK_USDC, MOCK_USDT, MOCK_DAI]; -const defaultProps = { - tokens: mockTokens, - onMaxPress: jest.fn(), - onEditPress: jest.fn(), - onLearnMorePress: jest.fn(), -}; - describe('MoneyConvertStablecoins', () => { beforeEach(() => { jest.clearAllMocks(); + mockAddProperties.mockReturnValue({ build: mockBuild }); + mockBuild.mockReturnValue({ event: 'built' }); mockUseSelector.mockImplementation((selector) => { if (selector === selectHasUnapprovedMusdConversion) return false; if (selector === selectHasInFlightMusdConversion) return false; if (selector === selectMusdConversionStatuses) return {}; return undefined; }); + mockUseMusdConversionTokens.mockReturnValue({ tokens: mockTokens }); }); describe('with eligible tokens', () => { it('renders the container', () => { const { getByTestId } = render( - , + , ); expect( @@ -134,7 +175,7 @@ describe('MoneyConvertStablecoins', () => { it('renders the convert title', () => { const { getByText } = render( - , + , ); expect( @@ -144,7 +185,7 @@ describe('MoneyConvertStablecoins', () => { it('renders the description with bonus text', () => { const { getByTestId } = render( - , + , ); expect( @@ -154,7 +195,7 @@ describe('MoneyConvertStablecoins', () => { it('renders feature tags', () => { const { getByTestId } = render( - , + , ); expect( @@ -162,18 +203,18 @@ describe('MoneyConvertStablecoins', () => { ).toBeOnTheScreen(); }); - it('renders a ConvertTokenRow for each token', () => { + it('renders a MusdConversionAssetRow for each token', () => { const { getAllByTestId } = render( - , + , ); - const rows = getAllByTestId(ConvertTokenRowTestIds.CONTAINER); + const rows = getAllByTestId(MusdConversionAssetRowTestIds.CONTAINER); expect(rows).toHaveLength(3); }); it('renders token symbols', () => { const { getByText } = render( - , + , ); expect(getByText('USDC')).toBeOnTheScreen(); @@ -181,69 +222,68 @@ describe('MoneyConvertStablecoins', () => { expect(getByText('DAI')).toBeOnTheScreen(); }); - it('renders the Learn more button', () => { - const { getByTestId } = render( - , + it('initiates max conversion and tracks event with location when Max is pressed', () => { + const { getAllByTestId } = render( + , ); - expect( - getByTestId(MoneyConvertStablecoinsTestIds.LEARN_MORE_CTA), - ).toBeOnTheScreen(); - }); - - it('calls onLearnMorePress when Learn more is pressed', () => { - const mockLearnMore = jest.fn(); - const { getByTestId } = render( - , + const maxButtons = getAllByTestId( + MusdConversionAssetRowTestIds.MAX_BUTTON, ); + fireEvent.press(maxButtons[0]); - fireEvent.press( - getByTestId(MoneyConvertStablecoinsTestIds.LEARN_MORE_CTA), + expect(mockInitiateMaxConversion).toHaveBeenCalledWith(MOCK_USDC); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.MONEY_HUB_TOKEN_ROW_CONVERT_CLICKED, ); - - expect(mockLearnMore).toHaveBeenCalledTimes(1); - }); - - it('calls onMaxPress with token when Max is pressed', () => { - const mockMaxPress = jest.fn(); - const { getAllByTestId } = render( - , + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: TEST_LOCATION, + button_action: 'max', + asset_symbol: 'USDC', + redirects_to: + MUSD_EVENT_LOCATIONS.QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN, + }), ); - - const maxButtons = getAllByTestId(ConvertTokenRowTestIds.MAX_BUTTON); - fireEvent.press(maxButtons[0]); - - expect(mockMaxPress).toHaveBeenCalledWith(MOCK_USDC); }); - it('calls onEditPress with token when Edit is pressed', () => { - const mockEditPress = jest.fn(); + it('initiates custom conversion and tracks event with location when Edit is pressed', () => { const { getAllByTestId } = render( - , + , ); - const editButtons = getAllByTestId(ConvertTokenRowTestIds.EDIT_BUTTON); + const editButtons = getAllByTestId( + MusdConversionAssetRowTestIds.EDIT_BUTTON, + ); fireEvent.press(editButtons[1]); - expect(mockEditPress).toHaveBeenCalledWith(MOCK_USDT); + expect(mockInitiateCustomConversion).toHaveBeenCalledWith( + expect.objectContaining({ + preferredPaymentToken: { + address: MOCK_USDT.address, + chainId: MOCK_USDT.chainId, + }, + }), + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + location: TEST_LOCATION, + button_action: 'custom', + asset_symbol: 'USDT', + redirects_to: MUSD_EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + }), + ); }); }); describe('without eligible tokens', () => { - const infoProps = { - ...defaultProps, - tokens: [], - }; + beforeEach(() => { + mockUseMusdConversionTokens.mockReturnValue({ tokens: [] }); + }); it('renders the container', () => { const { getByTestId } = render( - , + , ); expect( @@ -252,7 +292,9 @@ describe('MoneyConvertStablecoins', () => { }); it('renders the convert title', () => { - const { getByText } = render(); + const { getByText } = render( + , + ); expect( getByText(strings('money.convert_stablecoins.title')), @@ -261,7 +303,7 @@ describe('MoneyConvertStablecoins', () => { it('renders stacked token icons', () => { const { getByTestId } = render( - , + , ); expect( @@ -271,7 +313,7 @@ describe('MoneyConvertStablecoins', () => { it('renders the description', () => { const { getByTestId } = render( - , + , ); expect( @@ -281,7 +323,7 @@ describe('MoneyConvertStablecoins', () => { it('renders feature tags', () => { const { getByTestId } = render( - , + , ); expect( @@ -291,20 +333,10 @@ describe('MoneyConvertStablecoins', () => { it('does not render token rows', () => { const { queryByTestId } = render( - , + , ); - expect(queryByTestId(ConvertTokenRowTestIds.CONTAINER)).toBeNull(); - }); - - it('renders Learn more button', () => { - const { getByTestId } = render( - , - ); - - expect( - getByTestId(MoneyConvertStablecoinsTestIds.LEARN_MORE_CTA), - ).toBeOnTheScreen(); + expect(queryByTestId(MusdConversionAssetRowTestIds.CONTAINER)).toBeNull(); }); }); }); diff --git a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.testIds.ts b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.testIds.ts index 007ab9a71db..61496089ba5 100644 --- a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.testIds.ts +++ b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.testIds.ts @@ -2,6 +2,5 @@ export const MoneyConvertStablecoinsTestIds = { CONTAINER: 'money-convert-stablecoins-container', DESCRIPTION: 'money-convert-stablecoins-description', FEATURE_TAGS: 'money-convert-stablecoins-feature-tags', - LEARN_MORE_CTA: 'money-convert-stablecoins-learn-more-cta', TOKEN_ICONS: 'money-convert-stablecoins-token-icons', } as const; diff --git a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx index 411a42fbeac..536269ec90e 100644 --- a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx +++ b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx @@ -1,10 +1,7 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { Box, BoxFlexDirection, - Button, - ButtonSize, - ButtonVariant, FontWeight, Icon, IconColor, @@ -26,10 +23,10 @@ import { AvatarVariant } from '../../../../../component-library/components/Avata import { strings } from '../../../../../../locales/i18n'; import { useTheme } from '../../../../../util/theme'; import { buildTokenIconUrl } from '../../../Card/util/buildTokenIconUrl'; -import ConvertTokenRow from '../../../Earn/components/Musd/ConvertTokenRow'; +import MusdConversionAssetRow from '../../../Earn/components/Musd/MusdConversionAssetRow'; import { AssetType } from '../../../../Views/confirmations/types/token'; import { MoneyConvertStablecoinsTestIds } from './MoneyConvertStablecoins.testIds'; -import { CaipChainId } from '@metamask/utils'; +import { CaipChainId, Hex } from '@metamask/utils'; import { useSelector } from 'react-redux'; import { createTokenChainKey, @@ -37,12 +34,18 @@ import { selectHasUnapprovedMusdConversion, selectMusdConversionStatuses, } from '../../../Earn/selectors/musdConversionStatus'; +import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; +import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { MUSD_EVENTS_CONSTANTS } from '../../../Earn/constants/events/musdEvents'; +import { getNetworkName } from '../../../Earn/utils/network'; +import Logger from '../../../../../util/Logger'; + +const { EVENT_LOCATIONS: MUSD_EVENT_LOCATIONS } = MUSD_EVENTS_CONSTANTS; interface MoneyConvertStablecoinsProps { - tokens: AssetType[]; - onMaxPress: (token: AssetType) => void; - onEditPress: (token: AssetType) => void; - onLearnMorePress: () => void; + location: string; } const FEATURE_TAGS = [ @@ -134,13 +137,86 @@ const Description = () => ( ); const MoneyConvertStablecoins = ({ - tokens, - onMaxPress, - onEditPress, - onLearnMorePress, + location, }: MoneyConvertStablecoinsProps) => { + const { tokens } = useMusdConversionTokens(); + const { initiateMaxConversion, initiateCustomConversion } = + useMusdConversion(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const hasTokens = tokens.length > 0; + const handleMaxPress = useCallback( + async (token: AssetType) => { + try { + trackEvent( + createEventBuilder( + MetaMetricsEvents.MONEY_HUB_TOKEN_ROW_CONVERT_CLICKED, + ) + .addProperties({ + location, + button_type: 'text_button', + button_action: 'max', + button_text: strings('earn.musd_conversion.max'), + redirects_to: + MUSD_EVENT_LOCATIONS.QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN, + asset_symbol: token.symbol, + network_chain_id: token.chainId, + network_name: token.chainId + ? getNetworkName(token.chainId as Hex) + : 'unknown', + }) + .build(), + ); + await initiateMaxConversion(token); + } catch (error) { + Logger.error(error as Error, { + message: + '[MoneyConvertStablecoins] Failed to initiate max conversion', + }); + } + }, + [createEventBuilder, initiateMaxConversion, location, trackEvent], + ); + + const handleEditPress = useCallback( + async (token: AssetType) => { + try { + trackEvent( + createEventBuilder( + MetaMetricsEvents.MONEY_HUB_TOKEN_ROW_CONVERT_CLICKED, + ) + .addProperties({ + location, + button_type: 'icon_button', + icon: IconName.Edit, + button_action: 'custom', + redirects_to: MUSD_EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN, + asset_symbol: token.symbol, + network_chain_id: token.chainId, + network_name: token.chainId + ? getNetworkName(token.chainId as Hex) + : 'unknown', + }) + .build(), + ); + + await initiateCustomConversion({ + preferredPaymentToken: { + address: token.address as Hex, + chainId: token.chainId as Hex, + }, + }); + } catch (error) { + Logger.error(error as Error, { + message: + '[MoneyConvertStablecoins] Failed to initiate custom conversion', + }); + } + }, + [createEventBuilder, initiateCustomConversion, location, trackEvent], + ); + const hasUnapprovedMusdConversion = useSelector( selectHasUnapprovedMusdConversion, ); @@ -194,10 +270,10 @@ const MoneyConvertStablecoins = ({ {tokens.map((token) => ( - )} - - - - ); }; diff --git a/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.test.tsx b/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.test.tsx index 6decc6d7507..ee847929b0d 100644 --- a/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.test.tsx +++ b/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.test.tsx @@ -1,88 +1,107 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { render } from '@testing-library/react-native'; import MoneyEarnings from './MoneyEarnings'; import { MoneyEarningsTestIds } from './MoneyEarnings.testIds'; import { strings } from '../../../../../../locales/i18n'; +const ZERO_VALUE = '$0.00'; +const MONTHLY_VALUE = '$1.23'; +const YEARLY_VALUE = '$14.76'; + describe('MoneyEarnings', () => { it('renders the section title', () => { - const { getByText } = render(); + const { getByText } = render( + , + ); expect(getByText(strings('money.earnings.title'))).toBeOnTheScreen(); }); - it('renders both default zero values when no props are provided', () => { - const { getByTestId } = render(); + it('renders the estimated monthly and yearly labels', () => { + const { getByText } = render( + , + ); - expect(getByTestId(MoneyEarningsTestIds.LIFETIME_VALUE)).toHaveTextContent( - '$0.00', + expect( + getByText(strings('money.earnings.estimated_monthly')), + ).toBeOnTheScreen(); + expect( + getByText(strings('money.earnings.estimated_yearly')), + ).toBeOnTheScreen(); + }); + + it('renders the provided zero values when no real earnings exist', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(MoneyEarningsTestIds.MONTHLY_VALUE)).toHaveTextContent( + ZERO_VALUE, ); - expect(getByTestId(MoneyEarningsTestIds.PROJECTED_VALUE)).toHaveTextContent( - '$0.00', + expect(getByTestId(MoneyEarningsTestIds.YEARLY_VALUE)).toHaveTextContent( + ZERO_VALUE, ); }); - it('renders the provided lifetime and projected earnings values', () => { + it('renders the provided monthly and yearly earnings values', () => { const { getByTestId } = render( - , + , ); - expect(getByTestId(MoneyEarningsTestIds.LIFETIME_VALUE)).toHaveTextContent( - '$12.34', + expect(getByTestId(MoneyEarningsTestIds.MONTHLY_VALUE)).toHaveTextContent( + MONTHLY_VALUE, ); - expect(getByTestId(MoneyEarningsTestIds.PROJECTED_VALUE)).toHaveTextContent( - '$56.78', + expect(getByTestId(MoneyEarningsTestIds.YEARLY_VALUE)).toHaveTextContent( + YEARLY_VALUE, ); }); it('renders skeletons instead of values when loading', () => { - const { getByTestId, queryByTestId } = render(); + const { getByTestId, queryByTestId } = render( + , + ); expect( - getByTestId(MoneyEarningsTestIds.LIFETIME_SKELETON), - ).toBeOnTheScreen(); - expect( - getByTestId(MoneyEarningsTestIds.PROJECTED_SKELETON), + getByTestId(MoneyEarningsTestIds.MONTHLY_SKELETON), ).toBeOnTheScreen(); + expect(getByTestId(MoneyEarningsTestIds.YEARLY_SKELETON)).toBeOnTheScreen(); expect( - queryByTestId(MoneyEarningsTestIds.LIFETIME_VALUE), + queryByTestId(MoneyEarningsTestIds.MONTHLY_VALUE), ).not.toBeOnTheScreen(); expect( - queryByTestId(MoneyEarningsTestIds.PROJECTED_VALUE), + queryByTestId(MoneyEarningsTestIds.YEARLY_VALUE), ).not.toBeOnTheScreen(); }); - it('renders the navigation chevron on the projected column', () => { - const { getByTestId } = render(); - - expect( - getByTestId(MoneyEarningsTestIds.PROJECTED_CHEVRON), - ).toBeOnTheScreen(); - }); - - it('calls onProjectedPress when the projected column is tapped', () => { - const mockPress = jest.fn(); + it('renders value text in default color regardless of sign', () => { const { getByTestId } = render( - , + , ); - fireEvent.press(getByTestId(MoneyEarningsTestIds.PROJECTED)); - - expect(mockPress).toHaveBeenCalledTimes(1); - }); - - it('does not throw when the projected column is tapped without a handler', () => { - const { getByTestId } = render(); - - expect(() => { - fireEvent.press(getByTestId(MoneyEarningsTestIds.PROJECTED)); - }).not.toThrow(); - }); - - it('renders lifetime earnings in success color when value starts with +', () => { - const { getByTestId } = render(); - - const lifetimeValue = getByTestId(MoneyEarningsTestIds.LIFETIME_VALUE); - expect(lifetimeValue).toHaveTextContent('+$2.84'); + expect(getByTestId(MoneyEarningsTestIds.MONTHLY_VALUE)).toHaveTextContent( + MONTHLY_VALUE, + ); + expect(getByTestId(MoneyEarningsTestIds.YEARLY_VALUE)).toHaveTextContent( + YEARLY_VALUE, + ); }); }); diff --git a/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.testIds.ts b/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.testIds.ts index 8884c689e63..b2082b2a838 100644 --- a/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.testIds.ts +++ b/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.testIds.ts @@ -1,10 +1,9 @@ export const MoneyEarningsTestIds = { CONTAINER: 'money-earnings-container', - LIFETIME: 'money-earnings-lifetime', - LIFETIME_VALUE: 'money-earnings-lifetime-value', - LIFETIME_SKELETON: 'money-earnings-lifetime-skeleton', - PROJECTED: 'money-earnings-projected', - PROJECTED_VALUE: 'money-earnings-projected-value', - PROJECTED_SKELETON: 'money-earnings-projected-skeleton', - PROJECTED_CHEVRON: 'money-earnings-projected-chevron', + MONTHLY: 'money-earnings-monthly', + MONTHLY_VALUE: 'money-earnings-monthly-value', + MONTHLY_SKELETON: 'money-earnings-monthly-skeleton', + YEARLY: 'money-earnings-yearly', + YEARLY_VALUE: 'money-earnings-yearly-value', + YEARLY_SKELETON: 'money-earnings-yearly-skeleton', } as const; diff --git a/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.tsx b/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.tsx index fda158c0833..2ceef8618c7 100644 --- a/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.tsx +++ b/app/components/UI/Money/components/MoneyEarnings/MoneyEarnings.tsx @@ -1,14 +1,9 @@ import React from 'react'; -import { Pressable, StyleSheet } from 'react-native'; import { Box, BoxAlignItems, BoxFlexDirection, FontWeight, - Icon, - IconColor, - IconName, - IconSize, Skeleton, Text, TextColor, @@ -18,47 +13,39 @@ import { strings } from '../../../../../../locales/i18n'; import MoneySectionHeader from '../MoneySectionHeader'; import { MoneyEarningsTestIds } from './MoneyEarnings.testIds'; -const DEFAULT_VALUE = '$0.00'; - -const styles = StyleSheet.create({ - projectedColumn: { flex: 1 }, -}); - interface MoneyEarningsProps { /** - * Cumulative yield earned to date. Falls back to "$0.00" when omitted. + * Estimated monthly earnings based on current balance and APY, formatted in + * the user's selected currency. */ - lifetimeEarnings?: string; + monthlyEarnings: string; /** - * Forward-looking earnings based on current balance and APY. Falls back to - * "$0.00" when omitted. + * Estimated yearly earnings based on current balance and APY, formatted in + * the user's selected currency. */ - projectedEarnings?: string; + yearlyEarnings: string; /** * Render skeletons in place of the two earnings values while data is being * fetched. */ isLoading?: boolean; /** - * Handler fired when the projected column is tapped. Navigates to the "Earn - * on your crypto" page (MUSD follow-up). + * Handler fired when the info icon next to the section title is tapped. + * Opens the Earnings tooltip bottom sheet. */ - onProjectedPress?: () => void; + onInfoPress?: () => void; } const ValueText = ({ children, testID, - color, }: { children: string; testID: string; - color?: TextColor; }) => ( {children} @@ -66,85 +53,62 @@ const ValueText = ({ ); const MoneyEarnings = ({ - lifetimeEarnings = DEFAULT_VALUE, - projectedEarnings = DEFAULT_VALUE, + monthlyEarnings, + yearlyEarnings, isLoading = false, - onProjectedPress, + onInfoPress, }: MoneyEarningsProps) => ( - + - - - - {strings('money.earnings.lifetime')} + + + + {strings('money.earnings.estimated_monthly')} {isLoading ? ( ) : ( - - {lifetimeEarnings} + + {monthlyEarnings} )} - - - - - {strings('money.earnings.projected')} - - - - {isLoading ? ( - - ) : ( - - {projectedEarnings} - - )} - - + + {strings('money.earnings.estimated_yearly')} + + {isLoading ? ( + + ) : ( + + {yearlyEarnings} + + )} + ); diff --git a/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.styles.ts b/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.styles.ts new file mode 100644 index 00000000000..2b468c1d5dc --- /dev/null +++ b/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.styles.ts @@ -0,0 +1,14 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => + StyleSheet.create({ + content: { + paddingHorizontal: 16, + paddingBottom: 8, + gap: 16, + backgroundColor: params.theme.colors.background.default, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.test.tsx b/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.test.tsx new file mode 100644 index 00000000000..727eb9817d7 --- /dev/null +++ b/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.test.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import MoneyEarningsInfoSheet from './MoneyEarningsInfoSheet'; +import { MoneyEarningsInfoSheetTestIds } from './MoneyEarningsInfoSheet.testIds'; +import { strings } from '../../../../../../locales/i18n'; + +const mockOnCloseBottomSheet = jest.fn((cb?: () => void) => cb?.()); +const mockGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + goBack: mockGoBack, + }), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const { View, Text: RNText, Pressable } = jest.requireActual('react-native'); + + const MockBottomSheet = ReactActual.forwardRef( + ( + { children, testID }: { children: React.ReactNode; testID?: string }, + ref: React.Ref<{ onCloseBottomSheet: (cb?: () => void) => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: mockOnCloseBottomSheet, + onOpenBottomSheet: jest.fn(), + })); + return ReactActual.createElement(View, { testID }, children); + }, + ); + + const MockBottomSheetHeader = ({ + children, + onClose, + }: { + children: React.ReactNode; + onClose?: () => void; + }) => + ReactActual.createElement( + View, + { testID: 'bottom-sheet-header' }, + ReactActual.createElement( + Pressable, + { testID: 'bottom-sheet-close-button', onPress: onClose }, + ReactActual.createElement(RNText, {}, 'close'), + ), + children, + ); + + return { + ...actual, + BottomSheet: MockBottomSheet, + BottomSheetHeader: MockBottomSheetHeader, + }; +}); + +describe('MoneyEarningsInfoSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the container', () => { + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyEarningsInfoSheetTestIds.CONTAINER), + ).toBeOnTheScreen(); + }); + + it('renders the sheet title', () => { + const { getByText } = renderWithProvider(); + + expect( + getByText(strings('money.earnings_tooltip.title')), + ).toBeOnTheScreen(); + }); + + it('renders the body paragraph', () => { + const { getByText } = renderWithProvider(); + + expect(getByText(strings('money.earnings_tooltip.body'))).toBeOnTheScreen(); + }); + + it('renders the Got It footer button', () => { + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyEarningsInfoSheetTestIds.GOT_IT_BUTTON), + ).toBeOnTheScreen(); + }); + + it('renders the correct label on the Got It button', () => { + const { getByText } = renderWithProvider(); + + expect(getByText(strings('browser.got_it'))).toBeOnTheScreen(); + }); + + it('closes the sheet when the Got It button is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId(MoneyEarningsInfoSheetTestIds.GOT_IT_BUTTON)); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); + + it('closes the sheet when the close button is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press(getByTestId('bottom-sheet-close-button')); + + expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.testIds.ts b/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.testIds.ts new file mode 100644 index 00000000000..a1d718b93b6 --- /dev/null +++ b/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.testIds.ts @@ -0,0 +1,4 @@ +export const MoneyEarningsInfoSheetTestIds = { + CONTAINER: 'money-earnings-info-sheet-container', + GOT_IT_BUTTON: 'money-earnings-info-sheet-got-it-button', +} as const; diff --git a/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.tsx b/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.tsx new file mode 100644 index 00000000000..b6ea317d279 --- /dev/null +++ b/app/components/UI/Money/components/MoneyEarningsInfoSheet/MoneyEarningsInfoSheet.tsx @@ -0,0 +1,65 @@ +import React, { useCallback, useRef } from 'react'; +import { View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { + BottomSheet, + BottomSheetFooter, + BottomSheetHeader, + ButtonSize, + type BottomSheetRef, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './MoneyEarningsInfoSheet.styles'; +import { MoneyEarningsInfoSheetTestIds } from './MoneyEarningsInfoSheet.testIds'; + +const MoneyEarningsInfoSheet = () => { + const sheetRef = useRef(null); + const navigation = useNavigation(); + const { styles } = useStyles(styleSheet, {}); + + const handleGoBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleClose = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + const handleGotItPress = useCallback(() => { + sheetRef.current?.onCloseBottomSheet(); + }, []); + + return ( + + + + {strings('money.earnings_tooltip.title')} + + + + + {strings('money.earnings_tooltip.body')} + + + + + ); +}; + +export default MoneyEarningsInfoSheet; diff --git a/app/components/UI/Money/components/MoneyEarningsInfoSheet/index.ts b/app/components/UI/Money/components/MoneyEarningsInfoSheet/index.ts new file mode 100644 index 00000000000..b1c0dfbbd46 --- /dev/null +++ b/app/components/UI/Money/components/MoneyEarningsInfoSheet/index.ts @@ -0,0 +1 @@ +export { default } from './MoneyEarningsInfoSheet'; diff --git a/app/components/UI/Money/components/MoneyFooter/MoneyFooter.styles.ts b/app/components/UI/Money/components/MoneyFooter/MoneyFooter.styles.ts deleted file mode 100644 index b56bbae502d..00000000000 --- a/app/components/UI/Money/components/MoneyFooter/MoneyFooter.styles.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { StyleSheet } from 'react-native'; - -const createStyles = (bottom: number) => - StyleSheet.create({ - container: { - paddingBottom: Math.max(bottom, 16), - }, - }); - -export default createStyles; diff --git a/app/components/UI/Money/components/MoneyFooter/MoneyFooter.tsx b/app/components/UI/Money/components/MoneyFooter/MoneyFooter.tsx index 2733cbcffee..22d48ff7ba4 100644 --- a/app/components/UI/Money/components/MoneyFooter/MoneyFooter.tsx +++ b/app/components/UI/Money/components/MoneyFooter/MoneyFooter.tsx @@ -1,5 +1,4 @@ -import React, { useMemo } from 'react'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import React from 'react'; import { Box, Button, @@ -8,7 +7,6 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import { MoneyFooterTestIds } from './MoneyFooter.testIds'; -import createStyles from './MoneyFooter.styles'; interface MoneyFooterProps { onAddMoneyPress?: () => void; @@ -16,27 +14,18 @@ interface MoneyFooterProps { const MoneyFooter = ({ onAddMoneyPress = () => undefined, -}: MoneyFooterProps) => { - const { bottom } = useSafeAreaInsets(); - const styles = useMemo(() => createStyles(bottom), [bottom]); - - return ( - ( + + - - ); -}; + {strings('money.footer.add_money')} + + +); export default MoneyFooter; diff --git a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.test.tsx b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.test.tsx index e6957fb1603..2b7a7ebcc97 100644 --- a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.test.tsx +++ b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.test.tsx @@ -2,34 +2,27 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import MoneyHeader from './MoneyHeader'; import { MoneyHeaderTestIds } from './MoneyHeader.testIds'; - -const noop = jest.fn(); +import { strings } from '../../../../../../locales/i18n'; describe('MoneyHeader', () => { - it('renders the back and menu buttons', () => { - const { getByTestId } = render( - , - ); + it('renders the menu button', () => { + const { getByTestId } = render(); - expect(getByTestId(MoneyHeaderTestIds.BACK_BUTTON)).toBeOnTheScreen(); expect(getByTestId(MoneyHeaderTestIds.MENU_BUTTON)).toBeOnTheScreen(); }); - it('calls onBackPress when the back button is pressed', () => { - const mockOnBackPress = jest.fn(); - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId(MoneyHeaderTestIds.BACK_BUTTON)); + it('renders the Money title alongside the menu button', () => { + const { getByTestId } = render(); - expect(mockOnBackPress).toHaveBeenCalledTimes(1); + expect(getByTestId(MoneyHeaderTestIds.TITLE)).toHaveTextContent( + strings('money.title'), + ); }); it('calls onMenuPress when the menu button is pressed', () => { const mockOnMenuPress = jest.fn(); const { getByTestId } = render( - , + , ); fireEvent.press(getByTestId(MoneyHeaderTestIds.MENU_BUTTON)); diff --git a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.testIds.ts b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.testIds.ts index 29abd63b1cd..e41dc9605c5 100644 --- a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.testIds.ts +++ b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.testIds.ts @@ -1,5 +1,5 @@ export const MoneyHeaderTestIds = { CONTAINER: 'money-header-container', - BACK_BUTTON: 'money-header-back-button', + TITLE: 'money-header-title', MENU_BUTTON: 'money-header-menu-button', } as const; diff --git a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.tsx b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.tsx index da1cb440f54..f63fd31d16c 100644 --- a/app/components/UI/Money/components/MoneyHeader/MoneyHeader.tsx +++ b/app/components/UI/Money/components/MoneyHeader/MoneyHeader.tsx @@ -1,26 +1,25 @@ import React from 'react'; -import { HeaderStandard, IconName } from '@metamask/design-system-react-native'; +import { + HeaderBase, + HeaderBaseVariant, + IconName, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; import { MoneyHeaderTestIds } from './MoneyHeader.testIds'; interface MoneyHeaderProps { - /** - * Handler for the back/navigation button - */ - onBackPress: () => void; /** * Handler for the options menu button */ onMenuPress: () => void; } -const MoneyHeader = ({ onBackPress, onMenuPress }: MoneyHeaderProps) => ( - ( + ( testID: MoneyHeaderTestIds.MENU_BUTTON, }, ]} - /> + > + {strings('money.title')} + ); export default MoneyHeader; diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.styles.ts b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.styles.ts index 386a64c6428..8c132c100e7 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.styles.ts +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.styles.ts @@ -5,6 +5,10 @@ const styles = StyleSheet.create({ width: 104, height: 66, }, + linkCardImage: { + width: 152, + height: 96, + }, }); export default styles; diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx index 55ec813d7e1..485b94c2ad7 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx @@ -6,7 +6,9 @@ import { strings } from '../../../../../../locales/i18n'; describe('MoneyMetaMaskCard', () => { it('renders the section title and subtitle', () => { - const { getByText } = render(); + const { getByText } = render( + , + ); expect(getByText(strings('money.metamask_card.title'))).toBeOnTheScreen(); expect( @@ -15,7 +17,9 @@ describe('MoneyMetaMaskCard', () => { }); it('renders virtual card row', () => { - const { getByText, getByTestId } = render(); + const { getByText, getByTestId } = render( + , + ); expect( getByText(strings('money.metamask_card.virtual_card')), @@ -28,8 +32,10 @@ describe('MoneyMetaMaskCard', () => { ).toBeOnTheScreen(); }); - it('renders metal card row', () => { - const { getByText, getByTestId } = render(); + it('renders metal card row when showMetalCard is true', () => { + const { getByText, getByTestId } = render( + , + ); expect( getByText(strings('money.metamask_card.metal_card')), @@ -42,34 +48,58 @@ describe('MoneyMetaMaskCard', () => { ).toBeOnTheScreen(); }); - it('calls onGetNowPress with "virtual" when virtual card Get now is pressed', () => { + it('hides metal card row by default (showMetalCard not provided)', () => { + const { queryByTestId, queryByText } = render( + , + ); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).not.toBeOnTheScreen(); + expect( + queryByText(strings('money.metamask_card.metal_card')), + ).not.toBeOnTheScreen(); + }); + + it('hides metal card row when showMetalCard is false', () => { + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).not.toBeOnTheScreen(); + }); + + it('calls onGetNowPress when virtual card Get now is pressed', () => { const mockGetNow = jest.fn(); - const { getAllByText } = render( + const { getByText } = render( , ); - const getNowButtons = getAllByText(strings('money.metamask_card.get_now')); - fireEvent.press(getNowButtons[0]); + fireEvent.press(getByText(strings('money.metamask_card.get_now'))); - expect(mockGetNow).toHaveBeenCalledWith('virtual'); + expect(mockGetNow).toHaveBeenCalledTimes(1); + expect(mockGetNow.mock.calls[0]).toEqual([]); }); - it('calls onGetNowPress with "metal" when metal card Get now is pressed', () => { + it('calls onGetNowPress when metal card Get now is pressed', () => { const mockGetNow = jest.fn(); const { getAllByText } = render( - , + , ); - const getNowButtons = getAllByText(strings('money.metamask_card.get_now')); + fireEvent.press(getNowButtons[1]); - expect(mockGetNow).toHaveBeenCalledWith('metal'); + expect(mockGetNow).toHaveBeenCalledTimes(1); + expect(mockGetNow.mock.calls[0]).toEqual([]); }); describe('link mode', () => { it('renders link subtitle instead of upsell subtitle', () => { const { getByText, queryByText } = render( - , + , ); expect( @@ -81,7 +111,9 @@ describe('MoneyMetaMaskCard', () => { }); it('renders card image in link mode', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + , + ); expect( getByTestId(MoneyMetaMaskCardTestIds.LINK_CARD_IMAGE), @@ -89,7 +121,9 @@ describe('MoneyMetaMaskCard', () => { }); it('renders cashback and APY bullets', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + , + ); expect( getByTestId(MoneyMetaMaskCardTestIds.LINK_BULLET_CASHBACK), @@ -100,7 +134,9 @@ describe('MoneyMetaMaskCard', () => { }); it('renders "Link card" button', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + , + ); expect( getByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON), @@ -108,7 +144,9 @@ describe('MoneyMetaMaskCard', () => { }); it('hides virtual and metal card rows in link mode', () => { - const { queryByTestId } = render(); + const { queryByTestId } = render( + , + ); expect( queryByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW), @@ -121,7 +159,11 @@ describe('MoneyMetaMaskCard', () => { it('calls onLinkPress when "Link card" button is pressed', () => { const mockLink = jest.fn(); const { getByTestId } = render( - , + , ); fireEvent.press(getByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON)); @@ -129,7 +171,9 @@ describe('MoneyMetaMaskCard', () => { }); it('renders link-specific section title', () => { - const { getByText } = render(); + const { getByText } = render( + , + ); expect( getByText(strings('money.metamask_card.link_title')), @@ -139,7 +183,11 @@ describe('MoneyMetaMaskCard', () => { it('calls onHeaderPress when section header is tapped in link mode', () => { const mockHeader = jest.fn(); const { getByText } = render( - , + , ); fireEvent.press(getByText(strings('money.metamask_card.link_title'))); @@ -148,8 +196,10 @@ describe('MoneyMetaMaskCard', () => { }); describe('upsell mode (default)', () => { - it('renders virtual and metal card rows', () => { - const { getByTestId } = render(); + it('renders virtual and metal card rows when showMetalCard is true', () => { + const { getByTestId } = render( + , + ); expect( getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW), @@ -159,8 +209,23 @@ describe('MoneyMetaMaskCard', () => { ).toBeOnTheScreen(); }); + it('renders only the virtual card row when showMetalCard is false', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect( + getByTestId(MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW), + ).toBeOnTheScreen(); + expect( + queryByTestId(MoneyMetaMaskCardTestIds.METAL_CARD_ROW), + ).not.toBeOnTheScreen(); + }); + it('does not render link mode elements', () => { - const { queryByTestId } = render(); + const { queryByTestId } = render( + , + ); expect( queryByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON), diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx index 6f796d92b9a..aa195159598 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx @@ -13,6 +13,8 @@ import { IconColor, IconName, IconSize, + Tag, + TagSeverity, Text, TextColor, TextVariant, @@ -28,12 +30,18 @@ import mmCardMetal from '../../../../../images/mm_card_metal.png'; interface MoneyMetaMaskCardProps { /** 'upsell' (default): virtual/metal card rows. 'link': card-linking CTA layout. */ mode?: 'upsell' | 'link'; - onGetNowPress?: (cardType: string) => void; + onGetNowPress: () => void; onHeaderPress?: () => void; /** Called when the "Link card" button is pressed (link mode only). */ onLinkPress?: () => void; /** Current APY value displayed in the link mode bullet. */ apy?: number; + /** + * Whether to render the Metal card row in upsell mode. Defaults to `false` + * because the Metal card is currently only available to US users; the parent + * is expected to pass the geolocation-derived flag. + */ + showMetalCard?: boolean; } const CardRow = ({ @@ -66,15 +74,11 @@ const CardRow = ({ {cardName} - + {strings('money.metamask_card.cashback', { percentage: cashbackPercentage, })} - + - - ); -}; - const MoneyPotentialEarnings = ({ tokens, apy, onTokenPress, onViewAllPress, onHeaderPress, - condensed = false, }: MoneyPotentialEarningsProps) => { - const formatFiat = useFiatFormatter(); - const projectedMultiplier = useMemo( - () => ((apy ?? 0) / 100) * PROJECTION_YEARS, - [apy], - ); + const currentCurrency = useSelector(selectCurrentCurrency); + const apyPercent = apy ?? 0; // Tokens arrive pre-sorted (stablecoins first, then fiat desc) from // useMusdConversionTokens; strip zero-balance entries defensively — the // feature flag threshold may be set to 0 in some environments. const eligibleTokens = useMemo( - () => (tokens ?? []).filter((token) => tokenFiatValue(token) > 0), + () => tokens.filter((token) => tokenFiatValue(token) > 0), [tokens], ); const visibleTokens = useMemo( @@ -240,10 +83,16 @@ const MoneyPotentialEarnings = ({ const projectedAmount = useMemo( () => eligibleTokens.reduce( - (sum, token) => sum + tokenFiatValue(token) * projectedMultiplier, + (sum, token) => + sum + + calculateProjectedEarnings( + tokenFiatValue(token), + apyPercent, + PROJECTION_YEARS, + ), 0, ), - [eligibleTokens, projectedMultiplier], + [eligibleTokens, apyPercent], ); const handleTokenPress = useCallback( @@ -264,8 +113,8 @@ const MoneyPotentialEarnings = ({ /> {isPositiveNumber(projectedAmount) && ( - )} @@ -278,45 +127,29 @@ const MoneyPotentialEarnings = ({ - {condensed ? ( + <> + {visibleTokens.map((token) => ( + + ))} + - ) : ( - <> - {visibleTokens.map((token) => ( - - ))} - - - - - - )} + ); }; diff --git a/app/components/UI/Money/components/MoneyPotentialEarnings/PotentialEarningsTokenRow.test.tsx b/app/components/UI/Money/components/MoneyPotentialEarnings/PotentialEarningsTokenRow.test.tsx new file mode 100644 index 00000000000..73c58d09a18 --- /dev/null +++ b/app/components/UI/Money/components/MoneyPotentialEarnings/PotentialEarningsTokenRow.test.tsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PotentialEarningsTokenRow from './PotentialEarningsTokenRow'; +import { strings } from '../../../../../../locales/i18n'; +import { AssetType } from '../../../../Views/confirmations/types/token'; + +jest.mock( + '../../../../UI/Assets/components/AssetLogo/AssetLogo', + () => 'AssetLogo', +); +jest.mock( + '../../../../../component-library/components/Badges/BadgeWrapper', + () => ({ + __esModule: true, + default: 'BadgeWrapper', + BadgePosition: { BottomRight: 'BottomRight' }, + }), +); +jest.mock('../../../../../component-library/components/Badges/Badge', () => ({ + __esModule: true, + default: 'Badge', + BadgeVariant: { Network: 'Network' }, +})); +jest.mock('../../../../UI/AssetOverview/Balance/Balance', () => ({ + NetworkBadgeSource: jest.fn(() => null), +})); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(() => 'usd'), +})); + +jest.mock('../../utils/moneyFormatFiat', () => ({ + moneyFormatFiat: jest.fn((value: BigNumber) => `$${value.toFixed(2)}`), +})); + +const makeToken = (overrides: Partial): AssetType => + ({ + name: 'Token', + symbol: 'TOK', + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1', + decimals: 18, + balanceInSelectedCurrency: '$0.00', + fiat: { balance: 0 }, + ...overrides, + }) as AssetType; + +const MOCK_USDC = makeToken({ + name: 'USD Coin', + symbol: 'USDC', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + balanceInSelectedCurrency: '$5,000.00', + fiat: { balance: 5000 }, +}); + +describe('PotentialEarningsTokenRow', () => { + it('renders the token symbol', () => { + const { getByText } = render( + , + ); + + expect(getByText('USDC')).toBeOnTheScreen(); + }); + + it('renders the token fiat balance', () => { + const { getByText } = render( + , + ); + + expect(getByText('$5000.00')).toBeOnTheScreen(); + }); + + it('renders projected earnings when multiplier produces a positive value', () => { + const { getByText } = render( + , + ); + + expect(getByText('+$1000.00')).toBeOnTheScreen(); + }); + + it('hides projected earnings when multiplier is zero', () => { + const { queryByText } = render( + , + ); + + expect(queryByText(/^\+\$/)).toBeNull(); + }); + + it('renders the "No MetaMask fee" tag when hasSubsidizedFee is true', () => { + const { getByText } = render( + , + ); + + expect( + getByText(strings('money.potential_earnings.no_fee')), + ).toBeOnTheScreen(); + }); + + it('hides the "No MetaMask fee" tag when hasSubsidizedFee is false', () => { + const { queryByText } = render( + , + ); + + expect( + queryByText(strings('money.potential_earnings.no_fee')), + ).not.toBeOnTheScreen(); + }); + + it('renders the Convert button', () => { + const { getByText } = render( + , + ); + + expect( + getByText(strings('money.potential_earnings.convert')), + ).toBeOnTheScreen(); + }); + + it('calls onPress when the Convert button is pressed', () => { + const mockOnPress = jest.fn(); + const { getByText } = render( + , + ); + + fireEvent.press(getByText(strings('money.potential_earnings.convert'))); + + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('calls onPress when the row pressable area is pressed', () => { + const mockOnPress = jest.fn(); + const { getByText } = render( + , + ); + + fireEvent.press(getByText('USDC')); + + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Money/components/MoneyPotentialEarnings/PotentialEarningsTokenRow.tsx b/app/components/UI/Money/components/MoneyPotentialEarnings/PotentialEarningsTokenRow.tsx new file mode 100644 index 00000000000..58c90dfbe59 --- /dev/null +++ b/app/components/UI/Money/components/MoneyPotentialEarnings/PotentialEarningsTokenRow.tsx @@ -0,0 +1,155 @@ +import React, { useMemo } from 'react'; +import { Pressable, StyleSheet } from 'react-native'; +import { BigNumber } from 'bignumber.js'; +import { useSelector } from 'react-redux'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + Button, + ButtonSize, + ButtonVariant, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../../component-library/components/Badges/BadgeWrapper'; +import Badge, { + BadgeVariant, +} from '../../../../../component-library/components/Badges/Badge'; +import AssetLogo from '../../../Assets/components/AssetLogo/AssetLogo'; +import { NetworkBadgeSource } from '../../../AssetOverview/Balance/Balance'; +import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; +import { moneyFormatFiat } from '../../utils/moneyFormatFiat'; +import { + calculateProjectedEarnings, + PROJECTION_YEARS, +} from '../../utils/projections'; +import { tokenFiatValue } from '../../../Earn/hooks/useMusdConversionTokens'; +import { Hex } from '@metamask/utils'; +import { AssetType } from '../../../../Views/confirmations/types/token'; +import { isPositiveNumber } from '../../utils/number'; + +const styles = StyleSheet.create({ + rowPressable: { flex: 1 }, +}); + +const PotentialEarningsTokenRow = ({ + token, + hasSubsidizedFee, + apyPercent, + onPress, +}: { + token: AssetType; + hasSubsidizedFee: boolean; + /** APY as a percentage (e.g. 4 for 4%). */ + apyPercent: number; + onPress: () => void; +}) => { + const currentCurrency = useSelector(selectCurrentCurrency); + const networkBadgeSource = useMemo( + () => (token.chainId ? NetworkBadgeSource(token.chainId as Hex) : null), + [token.chainId], + ); + + const fiatBalance = tokenFiatValue(token); + const projectedFiatNumber = calculateProjectedEarnings( + fiatBalance, + apyPercent, + PROJECTION_YEARS, + ); + const projectedFiatFormatted = moneyFormatFiat( + new BigNumber(projectedFiatNumber), + currentCurrency, + ); + + const balanceFiatFormatted = moneyFormatFiat( + new BigNumber(fiatBalance), + currentCurrency, + ); + + return ( + + + + + ) + } + > + + + + + + + {token.symbol} + + {hasSubsidizedFee && ( + + + {strings('money.potential_earnings.no_fee')} + + + )} + + + + {balanceFiatFormatted} + + {isPositiveNumber(projectedFiatNumber) && ( + + {`+${projectedFiatFormatted}`} + + )} + + + + + + + + ); +}; + +export default PotentialEarningsTokenRow; diff --git a/app/components/UI/Money/components/MoneySectionHeader/MoneySectionHeader.test.tsx b/app/components/UI/Money/components/MoneySectionHeader/MoneySectionHeader.test.tsx index 76a256632f1..26afae2d946 100644 --- a/app/components/UI/Money/components/MoneySectionHeader/MoneySectionHeader.test.tsx +++ b/app/components/UI/Money/components/MoneySectionHeader/MoneySectionHeader.test.tsx @@ -38,4 +38,50 @@ describe('MoneySectionHeader', () => { expect(mockOnPress).toHaveBeenCalledTimes(1); }); + + it('does not render info button when onInfoPress is not provided', () => { + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId(MoneySectionHeaderTestIds.INFO_BUTTON), + ).not.toBeOnTheScreen(); + }); + + it('renders info button when onInfoPress is provided', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(MoneySectionHeaderTestIds.INFO_BUTTON), + ).toBeOnTheScreen(); + }); + + it('calls onInfoPress when info button is pressed', () => { + const mockOnInfoPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(MoneySectionHeaderTestIds.INFO_BUTTON)); + + expect(mockOnInfoPress).toHaveBeenCalledTimes(1); + }); + + it('renders both info button and chevron when both props are provided', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(MoneySectionHeaderTestIds.INFO_BUTTON), + ).toBeOnTheScreen(); + expect(getByTestId(MoneySectionHeaderTestIds.CHEVRON)).toBeOnTheScreen(); + }); }); diff --git a/app/components/UI/Money/components/MoneySectionHeader/MoneySectionHeader.testIds.ts b/app/components/UI/Money/components/MoneySectionHeader/MoneySectionHeader.testIds.ts index 7c16b568f8c..0ae4f80d818 100644 --- a/app/components/UI/Money/components/MoneySectionHeader/MoneySectionHeader.testIds.ts +++ b/app/components/UI/Money/components/MoneySectionHeader/MoneySectionHeader.testIds.ts @@ -1,4 +1,5 @@ export const MoneySectionHeaderTestIds = { TITLE: 'money-section-header-title', CHEVRON: 'money-section-header-chevron', + INFO_BUTTON: 'money-section-header-info-button', } as const; diff --git a/app/components/UI/Money/components/MoneySectionHeader/MoneySectionHeader.tsx b/app/components/UI/Money/components/MoneySectionHeader/MoneySectionHeader.tsx index 4f2e0c1b706..2a4531f8a52 100644 --- a/app/components/UI/Money/components/MoneySectionHeader/MoneySectionHeader.tsx +++ b/app/components/UI/Money/components/MoneySectionHeader/MoneySectionHeader.tsx @@ -4,6 +4,8 @@ import { Box, BoxAlignItems, BoxFlexDirection, + ButtonIcon, + ButtonIconSize, Icon, IconColor, IconName, @@ -22,9 +24,22 @@ interface MoneySectionHeaderProps { * When provided, renders a chevron and makes the header tappable */ onPress?: () => void; + /** + * When provided, renders an info icon button next to the title + */ + onInfoPress?: () => void; + /** + * Accessibility label for the info icon button + */ + infoAccessibilityLabel?: string; } -const MoneySectionHeader = ({ title, onPress }: MoneySectionHeaderProps) => { +const MoneySectionHeader = ({ + title, + onPress, + onInfoPress, + infoAccessibilityLabel, +}: MoneySectionHeaderProps) => { const handlePress = useCallback(() => { onPress?.(); }, [onPress]); @@ -41,6 +56,16 @@ const MoneySectionHeader = ({ title, onPress }: MoneySectionHeaderProps) => { > {title} + {onInfoPress && ( + + )} {onPress && ( + StyleSheet.create({ + list: { + paddingBottom: 16, + backgroundColor: params.theme.colors.background.default, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 16, + paddingHorizontal: 16, + paddingVertical: 8, + minHeight: 59, + }, + disabledRowContent: { + flex: 1, + flexDirection: 'column', + alignItems: 'flex-start', + gap: 4, + }, + comingSoonTag: { + borderRadius: 8, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.test.tsx b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.test.tsx new file mode 100644 index 00000000000..e32ca2b945b --- /dev/null +++ b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.test.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import MoneyTransferSheet from './MoneyTransferSheet'; +import { MoneyTransferSheetTestIds } from './MoneyTransferSheet.testIds'; +import { strings } from '../../../../../../locales/i18n'; + +const mockOnCloseBottomSheet = jest.fn((cb?: () => void) => cb?.()); +const mockGoBack = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + goBack: mockGoBack, + }), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const { forwardRef, useImperativeHandle } = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const MockBottomSheet = forwardRef( + ( + { children, testID }: { children: React.ReactNode; testID?: string }, + ref: React.Ref<{ onCloseBottomSheet: (cb?: () => void) => void }>, + ) => { + useImperativeHandle(ref, () => ({ + onCloseBottomSheet: mockOnCloseBottomSheet, + onOpenBottomSheet: jest.fn(), + })); + return {children}; + }, + ); + const MockBottomSheetHeader = ({ + children, + }: { + children: React.ReactNode; + }) => {children}; + return { + ...actual, + BottomSheet: MockBottomSheet, + BottomSheetHeader: MockBottomSheetHeader, + }; +}); + +describe('MoneyTransferSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + global.alert = jest.fn(); + }); + + it('renders the sheet title', () => { + const { getByText } = renderWithProvider(); + + expect(getByText(strings('money.transfer_sheet.title'))).toBeOnTheScreen(); + }); + + it('renders all five list items', () => { + const { getByTestId } = renderWithProvider(); + + expect( + getByTestId(MoneyTransferSheetTestIds.BETWEEN_ACCOUNTS_OPTION), + ).toBeOnTheScreen(); + expect( + getByTestId(MoneyTransferSheetTestIds.PERPS_ACCOUNT_OPTION), + ).toBeOnTheScreen(); + expect( + getByTestId(MoneyTransferSheetTestIds.PREDICTIONS_ACCOUNT_OPTION), + ).toBeOnTheScreen(); + expect( + getByTestId(MoneyTransferSheetTestIds.SEND_EXTERNAL_ROW), + ).toBeOnTheScreen(); + expect( + getByTestId(MoneyTransferSheetTestIds.WITHDRAW_TO_BANK_ROW), + ).toBeOnTheScreen(); + }); + + it('renders "Coming soon" tags on the last two items', () => { + const { getAllByText } = renderWithProvider(); + + const comingSoonTags = getAllByText( + strings('money.add_money_sheet.coming_soon'), + ); + expect(comingSoonTags).toHaveLength(2); + }); + + it('fires an alert when "Between accounts" is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press( + getByTestId(MoneyTransferSheetTestIds.BETWEEN_ACCOUNTS_OPTION), + ); + + expect(global.alert).toHaveBeenCalledWith('Under construction 🚧'); + }); + + it('fires an alert when "Perps account" is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press( + getByTestId(MoneyTransferSheetTestIds.PERPS_ACCOUNT_OPTION), + ); + + expect(global.alert).toHaveBeenCalledWith('Under construction 🚧'); + }); + + it('fires an alert when "Predictions account" is pressed', () => { + const { getByTestId } = renderWithProvider(); + + fireEvent.press( + getByTestId(MoneyTransferSheetTestIds.PREDICTIONS_ACCOUNT_OPTION), + ); + + expect(global.alert).toHaveBeenCalledWith('Under construction 🚧'); + }); +}); diff --git a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.testIds.ts b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.testIds.ts new file mode 100644 index 00000000000..1918ff09883 --- /dev/null +++ b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.testIds.ts @@ -0,0 +1,8 @@ +export const MoneyTransferSheetTestIds = { + CONTAINER: 'money-transfer-sheet-container', + BETWEEN_ACCOUNTS_OPTION: 'money-transfer-sheet-between-accounts', + PERPS_ACCOUNT_OPTION: 'money-transfer-sheet-perps-account', + PREDICTIONS_ACCOUNT_OPTION: 'money-transfer-sheet-predictions-account', + SEND_EXTERNAL_ROW: 'money-transfer-sheet-send-external', + WITHDRAW_TO_BANK_ROW: 'money-transfer-sheet-withdraw-to-bank', +} as const; diff --git a/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx new file mode 100644 index 00000000000..4f4034d0795 --- /dev/null +++ b/app/components/UI/Money/components/MoneyTransferSheet/MoneyTransferSheet.tsx @@ -0,0 +1,151 @@ +import React, { useCallback, useRef } from 'react'; +import { TouchableOpacity, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { + BottomSheet, + BottomSheetHeader, + type BottomSheetRef, + FontWeight, + Icon, + IconName, + IconSize, + IconColor, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import Tag from '../../../../../component-library/components/Tags/Tag'; +import { strings } from '../../../../../../locales/i18n'; +import { useStyles } from '../../../../../component-library/hooks'; +import styleSheet from './MoneyTransferSheet.styles'; +import { MoneyTransferSheetTestIds } from './MoneyTransferSheet.testIds'; + +interface ActiveOption { + label: string; + icon: IconName; + onPress: () => void; + testID: string; +} + +interface DisabledOption { + label: string; + icon: IconName; + testID: string; +} + +const MoneyTransferSheet = () => { + const sheetRef = useRef(null); + const navigation = useNavigation(); + const { styles } = useStyles(styleSheet, {}); + + const handleGoBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleBetweenAccounts = useCallback(() => { + // eslint-disable-next-line no-alert + alert('Under construction 🚧'); + }, []); + + const handlePerpsAccount = useCallback(() => { + // eslint-disable-next-line no-alert + alert('Under construction 🚧'); + }, []); + + const handlePredictionsAccount = useCallback(() => { + // eslint-disable-next-line no-alert + alert('Under construction 🚧'); + }, []); + + const activeOptions: ActiveOption[] = [ + { + label: strings('money.transfer_sheet.between_accounts'), + icon: IconName.SwapHorizontal, + onPress: handleBetweenAccounts, + testID: MoneyTransferSheetTestIds.BETWEEN_ACCOUNTS_OPTION, + }, + { + label: strings('money.transfer_sheet.perps_account'), + icon: IconName.Candlestick, + onPress: handlePerpsAccount, + testID: MoneyTransferSheetTestIds.PERPS_ACCOUNT_OPTION, + }, + { + label: strings('money.transfer_sheet.predictions_account'), + icon: IconName.Speedometer, + onPress: handlePredictionsAccount, + testID: MoneyTransferSheetTestIds.PREDICTIONS_ACCOUNT_OPTION, + }, + ]; + + const disabledOptions: DisabledOption[] = [ + { + label: strings('money.transfer_sheet.send_external'), + icon: IconName.Send, + testID: MoneyTransferSheetTestIds.SEND_EXTERNAL_ROW, + }, + { + label: strings('money.transfer_sheet.withdraw_to_bank'), + icon: IconName.Bank, + testID: MoneyTransferSheetTestIds.WITHDRAW_TO_BANK_ROW, + }, + ]; + + return ( + + sheetRef.current?.onCloseBottomSheet()}> + + {strings('money.transfer_sheet.title')} + + + + {activeOptions.map((item) => ( + + + + {item.label} + + + ))} + {disabledOptions.map((item) => ( + + + + + {item.label} + + + + + ))} + + + ); +}; + +export default MoneyTransferSheet; diff --git a/app/components/UI/Money/components/MoneyTransferSheet/index.ts b/app/components/UI/Money/components/MoneyTransferSheet/index.ts new file mode 100644 index 00000000000..0182b510106 --- /dev/null +++ b/app/components/UI/Money/components/MoneyTransferSheet/index.ts @@ -0,0 +1,2 @@ +export { default } from './MoneyTransferSheet'; +export { MoneyTransferSheetTestIds } from './MoneyTransferSheet.testIds'; diff --git a/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.test.tsx b/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.test.tsx index f9a9af60cba..a89016af816 100644 --- a/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.test.tsx +++ b/app/components/UI/Money/components/MoneyWhatYouGet/MoneyWhatYouGet.test.tsx @@ -27,7 +27,7 @@ describe('MoneyWhatYouGet', () => { expect(container).toHaveTextContent(/Auto-earn/); expect(container).toHaveTextContent(/dollar-backed stablecoin/); expect(container).toHaveTextContent(/Get full liquidity/); - expect(container).toHaveTextContent(/1-3% cashback/); + expect(container).toHaveTextContent(/1-3% mUSD back/); expect(container).toHaveTextContent( /Transfer money to any of your wallets/, ); diff --git a/app/components/UI/Money/constants/moneyEvents.ts b/app/components/UI/Money/constants/moneyEvents.ts index 7927c1018b2..231477334a1 100644 --- a/app/components/UI/Money/constants/moneyEvents.ts +++ b/app/components/UI/Money/constants/moneyEvents.ts @@ -1,5 +1,6 @@ const EVENT_LOCATIONS = { MONEY_HUB: 'money_hub', + ASSET_DETAIL: 'asset_detail', }; const MONEY_HUB_STATES = { diff --git a/app/components/UI/Money/hooks/useMoneyAccount.test.ts b/app/components/UI/Money/hooks/useMoneyAccount.test.ts index a0d33349326..a87f90f67a6 100644 --- a/app/components/UI/Money/hooks/useMoneyAccount.test.ts +++ b/app/components/UI/Money/hooks/useMoneyAccount.test.ts @@ -158,7 +158,7 @@ describe('useMoneyAccountDeposit', () => { await expect( act(async () => { - await result.current.initiateDeposit(BigInt(1_000_000)); + await result.current.initiateDeposit(); }), ).rejects.toThrow('Missing vault config'); @@ -173,7 +173,7 @@ describe('useMoneyAccountDeposit', () => { await expect( act(async () => { - await result.current.initiateDeposit(BigInt(1_000_000)); + await result.current.initiateDeposit(); }), ).rejects.toThrow('No provider available'); @@ -184,12 +184,12 @@ describe('useMoneyAccountDeposit', () => { const { result } = renderHook(() => useMoneyAccountDeposit()); await act(async () => { - await result.current.initiateDeposit(BigInt(1_000_000)); + await result.current.initiateDeposit(); }); expect(mockBuildDepositBatch).toHaveBeenCalledWith( expect.objectContaining({ - amount: BigInt(1_000_000), + amount: BigInt(0), chainId: MOCK_VAULT_CONFIG.chainId, boringVault: MOCK_VAULT_CONFIG.boringVault, }), @@ -220,7 +220,7 @@ describe('useMoneyAccountDeposit', () => { let caught: Error | undefined; await act(async () => { try { - await result.current.initiateDeposit(BigInt(1_000_000)); + await result.current.initiateDeposit(); } catch (error) { caught = error as Error; } @@ -242,7 +242,7 @@ describe('useMoneyAccountDeposit', () => { await expect( act(async () => { - await result.current.initiateDeposit(BigInt(1_000_000)); + await result.current.initiateDeposit(); }), ).rejects.toThrow('Network client not found'); diff --git a/app/components/UI/Money/hooks/useMoneyAccount.ts b/app/components/UI/Money/hooks/useMoneyAccount.ts index 18ad80bc3e7..6eb6b77f74c 100644 --- a/app/components/UI/Money/hooks/useMoneyAccount.ts +++ b/app/components/UI/Money/hooks/useMoneyAccount.ts @@ -12,6 +12,7 @@ import { selectPrimaryMoneyAccount } from '../../../../selectors/moneyAccountCon import { buildMoneyAccountDepositBatch, buildMoneyAccountWithdraw, + getMoneyAccountDepositAssetAddress, } from '../utils/moneyAccountTransactions'; import { getProviderByChainId } from '../../../../util/notifications/methods/common'; import Logger from '../../../../util/Logger'; @@ -36,74 +37,71 @@ export function useMoneyAccountDeposit() { const primaryMoneyAccount = useSelector(selectPrimaryMoneyAccount); const { navigateToConfirmation } = useConfirmNavigation(); - const initiateDeposit = useCallback( - // TODO: remove the account parameter and instead of directly building approve and deposit transactions - // we need to implemend a hook from `addTransactionBatch` from which we can get the user inputed amount - // and then use that to build the approve and deposit transactions. This is because user inputs the amount - // in the MM pay UI and we need to use that amount. - async (amount: bigint) => { - if (!vaultConfig) { - throw new Error(`${LOG_TAG} Missing vault config`); - } - if (!primaryMoneyAccount?.address) { - throw new Error(`${LOG_TAG} Missing money account address`); - } - - const { - chainId, - boringVault, - tellerAddress, - accountantAddress, - lensAddress, - } = vaultConfig; - - const chainIdHex = chainId as Hex; - const provider = getProviderByChainId(chainIdHex); - if (!provider) { - throw new Error( - `${LOG_TAG} No provider available for chain ${chainId}`, - ); - } - - const networkClientId = resolveNetworkClientId(chainIdHex); - - // TODO: as mentioned above this should move into hook from `addTransactionBatch`. - const { approveTx, depositTx } = await buildMoneyAccountDepositBatch({ - amount, - chainId: chainIdHex, - boringVault, - tellerAddress, - accountantAddress, - lensAddress, - provider, - }); - - // Navigate early for better UX; recover on failure below. - navigateToConfirmation({ - loader: ConfirmationLoader.CustomAmount, - stack: Routes.MONEY.ROOT, + const initiateDeposit = useCallback(async () => { + if (!vaultConfig) { + throw new Error(`${LOG_TAG} Missing vault config`); + } + if (!primaryMoneyAccount?.address) { + throw new Error(`${LOG_TAG} Missing money account address`); + } + + const { + chainId, + boringVault, + tellerAddress, + accountantAddress, + lensAddress, + } = vaultConfig; + + const chainIdHex = chainId as Hex; + const provider = getProviderByChainId(chainIdHex); + if (!provider) { + throw new Error(`${LOG_TAG} No provider available for chain ${chainId}`); + } + + const networkClientId = resolveNetworkClientId(chainIdHex); + + const { approveTx, depositTx } = await buildMoneyAccountDepositBatch({ + amount: BigInt(0), + chainId: chainIdHex, + boringVault, + tellerAddress, + accountantAddress, + lensAddress, + provider, + }); + + // Navigate early for better UX; recover on failure below. + navigateToConfirmation({ + loader: ConfirmationLoader.CustomAmount, + stack: Routes.MONEY.ROOT, + }); + + try { + // We only set the transaction from the money account perspective. + // MM Pay selects the user's account and moves funds to the money account, + // so `from` must be the money account and `networkClientId` its chain. + await addTransactionBatch({ + from: primaryMoneyAccount.address as Hex, + networkClientId, + origin: ORIGIN_METAMASK, + disableHook: true, + disableSequential: true, + transactions: [approveTx, depositTx], + requiredAssets: [ + { + address: getMoneyAccountDepositAssetAddress(chainIdHex), + amount: '0x0' as Hex, + standard: 'erc20', + }, + ], }); - - try { - // We only set the transaction from the money account perspective. - // MM Pay selects the user's account and moves funds to the money account, - // so `from` must be the money account and `networkClientId` its chain. - await addTransactionBatch({ - from: primaryMoneyAccount.address as Hex, - networkClientId, - origin: ORIGIN_METAMASK, - disableHook: true, - disableSequential: true, - transactions: [approveTx, depositTx], - }); - } catch (error) { - Logger.error(error as Error, `${LOG_TAG} Deposit transaction failed`); - // Rethrow so the caller can roll back navigation / surface a toast. - throw error; - } - }, - [navigateToConfirmation, primaryMoneyAccount, vaultConfig], - ); + } catch (error) { + Logger.error(error as Error, `${LOG_TAG} Deposit transaction failed`); + // Rethrow so the caller can roll back navigation / surface a toast. + throw error; + } + }, [navigateToConfirmation, primaryMoneyAccount, vaultConfig]); return { initiateDeposit }; } diff --git a/app/components/UI/Money/hooks/useMoneyAccountBalance.test.ts b/app/components/UI/Money/hooks/useMoneyAccountBalance.test.ts index dbcfb0723f3..581b1e24d3a 100644 --- a/app/components/UI/Money/hooks/useMoneyAccountBalance.test.ts +++ b/app/components/UI/Money/hooks/useMoneyAccountBalance.test.ts @@ -6,7 +6,10 @@ import useMoneyAccountBalance, { } from './useMoneyAccountBalance'; import { selectPrimaryMoneyAccount } from '../../../../selectors/moneyAccountController'; import { selectTokenMarketData } from '../../../../selectors/tokenRatesController'; -import { selectCurrencyRates } from '../../../../selectors/currencyRateController'; +import { + selectCurrencyRates, + selectCurrentCurrency, +} from '../../../../selectors/currencyRateController'; import { selectNetworkConfigurations } from '../../../../selectors/networkController'; import Engine from '../../../../core/Engine'; @@ -20,12 +23,6 @@ jest.mock('@tanstack/react-query', () => ({ useQueries: jest.fn(), })); -jest.mock('../../SimulationDetails/FiatDisplay/useFiatFormatter', () => ({ - __esModule: true, - default: () => (val: { toFixed: (n: number) => string }) => - `$${val.toFixed(2)}`, -})); - jest.mock('../../../../core/Engine', () => ({ __esModule: true, default: { @@ -46,6 +43,7 @@ jest.mock('../../../../selectors/tokenRatesController', () => ({ })); jest.mock('../../../../selectors/currencyRateController', () => ({ selectCurrencyRates: jest.fn(), + selectCurrentCurrency: jest.fn(), })); jest.mock('../../../../selectors/networkController', () => ({ selectNetworkConfigurations: jest.fn(), @@ -93,6 +91,9 @@ function setupDefaultSelectors() { if (selector === selectNetworkConfigurations) { return MOCK_NETWORK_CONFIGURATIONS; } + if (selector === selectCurrentCurrency) { + return 'usd'; + } return undefined; }); } @@ -107,7 +108,7 @@ const DEFAULT_MUSD_BALANCE_QUERY: QueryState<{ balance: string }> = { isLoading: false, }; const DEFAULT_VAULT_APY_QUERY: QueryState<{ apy: number }> = { - data: { apy: 5.5 }, + data: { apy: 0.05 }, isLoading: false, }; const DEFAULT_MUSD_EQUIVALENT_BALANCE_QUERY: QueryState<{ @@ -269,6 +270,9 @@ describe('useMoneyAccountBalance', () => { if (selector === selectNetworkConfigurations) { return MOCK_NETWORK_CONFIGURATIONS; } + if (selector === selectCurrentCurrency) { + return 'usd'; + } return undefined; }); @@ -286,4 +290,36 @@ describe('useMoneyAccountBalance', () => { expect(result.current.totalFiatRaw).toBe('3'); }); + + it('returns apyDecimal as the raw vault APY value from the API', () => { + const { result } = renderHook(() => useMoneyAccountBalance()); + + expect(result.current.apyDecimal).toBe(0.05); + }); + + it('returns apyPercent as the vault APY multiplied by 100', () => { + const { result } = renderHook(() => useMoneyAccountBalance()); + + expect(result.current.apyPercent).toBe(5); + }); + + it('returns apyPercentFormatted as a display-ready percentage string', () => { + const { result } = renderHook(() => useMoneyAccountBalance()); + + expect(result.current.apyPercentFormatted).toBe('5%'); + }); + + it('returns undefined for all APY fields when vault APY data is not available', () => { + mockUseQueries.mockReturnValue( + makeQueryResults({ + vaultApy: { data: undefined, isLoading: true }, + }), + ); + + const { result } = renderHook(() => useMoneyAccountBalance()); + + expect(result.current.apyDecimal).toBeUndefined(); + expect(result.current.apyPercent).toBeUndefined(); + expect(result.current.apyPercentFormatted).toBeUndefined(); + }); }); diff --git a/app/components/UI/Money/hooks/useMoneyAccountBalance.ts b/app/components/UI/Money/hooks/useMoneyAccountBalance.ts index 60c49297f7b..8d1b0609a54 100644 --- a/app/components/UI/Money/hooks/useMoneyAccountBalance.ts +++ b/app/components/UI/Money/hooks/useMoneyAccountBalance.ts @@ -7,15 +7,18 @@ import { import { useQueries, type UseQueryResult } from '@tanstack/react-query'; import BigNumber from 'bignumber.js'; import { CHAIN_IDS } from '@metamask/transaction-controller'; -import useFiatFormatter from '../../SimulationDetails/FiatDisplay/useFiatFormatter'; +import { fromTokenMinimalUnitString } from '../../../../util/number'; +import { moneyFormatFiat } from '../utils/moneyFormatFiat'; import { selectTokenMarketData } from '../../../../selectors/tokenRatesController'; -import { selectCurrencyRates } from '../../../../selectors/currencyRateController'; +import { + selectCurrencyRates, + selectCurrentCurrency, +} from '../../../../selectors/currencyRateController'; import { selectNetworkConfigurations } from '../../../../selectors/networkController'; import { MUSD_TOKEN_ADDRESS_BY_CHAIN, MUSD_DECIMALS, } from '../../Earn/constants/musd'; -import { fromTokenMinimalUnitString } from '../../../../util/number'; import { toChecksumAddress } from '../../../../util/address'; import { MoneyAccountBalanceServiceQueryKeys } from '../queryKeys'; import Engine from '../../../../core/Engine'; @@ -23,6 +26,13 @@ import { selectPrimaryMoneyAccount } from '../../../../selectors/moneyAccountCon const DEFAULT_REFETCH_INTERVAL = 30 * 1000; // 30 seconds +// TODO: Remove __DEV__ values before launch. This is temporary to circumvent the Vault's current 0% APY. +const DEV_APY = { + decimal: 0.04, + percent: 4, + percentFormatted: '4%', +}; + /** * Fetches the live exchange rate for the mUSD token. * This is necessary when we need the most current rate at runtime (e.g. Money account withdrawal). @@ -41,7 +51,7 @@ const useMoneyAccountBalance = ( const tokenMarketData = useSelector(selectTokenMarketData); const currencyRates = useSelector(selectCurrencyRates); const networkConfigurations = useSelector(selectNetworkConfigurations); - const formatFiat = useFiatFormatter(); + const currentCurrency = useSelector(selectCurrentCurrency); const [musdBalanceQuery, vaultApyQuery, musdEquivalentBalanceQuery] = useQueries({ @@ -158,13 +168,24 @@ const useMoneyAccountBalance = ( musdFiatRate, ]); - const musdFiatFormatted = musdFiat ? formatFiat(musdFiat) : undefined; + const musdFiatFormatted = musdFiat + ? moneyFormatFiat(musdFiat, currentCurrency) + : undefined; const musdSHFvdFiatFormatted = musdSHFvdFiat - ? formatFiat(musdSHFvdFiat) + ? moneyFormatFiat(musdSHFvdFiat, currentCurrency) + : undefined; + const totalFiatFormatted = totalFiat + ? moneyFormatFiat(totalFiat, currentCurrency) : undefined; - const totalFiatFormatted = totalFiat ? formatFiat(totalFiat) : undefined; const totalFiatRaw = totalFiat ? totalFiat.toString() : undefined; + const rawApy = vaultApyQuery.data?.apy; + + const apyDecimal = rawApy; + const apyPercent = rawApy !== undefined ? rawApy * 100 : undefined; + const apyPercentFormatted = + apyPercent !== undefined ? `${apyPercent}%` : undefined; + return { musdBalanceQuery, vaultApyQuery, @@ -175,6 +196,12 @@ const useMoneyAccountBalance = ( tokenTotal, totalFiatFormatted, totalFiatRaw, + // TODO: Remove __DEV__ values before launch. This is temporary to circumvent the Vault's current 0% APY. + apyDecimal: __DEV__ ? DEV_APY.decimal : apyDecimal, + apyPercent: __DEV__ ? DEV_APY.percent : apyPercent, + apyPercentFormatted: __DEV__ + ? DEV_APY.percentFormatted + : apyPercentFormatted, }; }; diff --git a/app/components/UI/Money/routes/index.test.tsx b/app/components/UI/Money/routes/index.test.tsx index ecccc38fa57..a8796dd4c36 100644 --- a/app/components/UI/Money/routes/index.test.tsx +++ b/app/components/UI/Money/routes/index.test.tsx @@ -2,7 +2,18 @@ import React from 'react'; import { Text as MockText, View as MockView } from 'react-native'; import renderWithProvider from '../../../../util/test/renderWithProvider'; import { mockTheme } from '../../../../util/theme'; -import { MoneyModalStack, MoneyScreenStack } from './index'; +import { upgradeMoneyAccount } from '../../../../actions/money'; +import { + MoneyAccountStackGate, + MoneyModalStack, + MoneyScreenStack, +} from './index'; + +jest.mock('../../../../actions/money', () => ({ + upgradeMoneyAccount: jest.fn(() => () => undefined), +})); + +const mockUpgradeMoneyAccount = jest.mocked(upgradeMoneyAccount); const EXPECTED_CARD_BACKGROUND = '#money-test-bg'; @@ -88,6 +99,28 @@ describe('MoneyScreenStack', () => { }); }); +describe('MoneyAccountStackGate', () => { + beforeEach(() => { + mockUpgradeMoneyAccount.mockClear(); + }); + + it('dispatches upgradeMoneyAccount once when the stack mounts', () => { + renderWithProvider(, { + theme: themeWithCustomBackground, + }); + + expect(mockUpgradeMoneyAccount).toHaveBeenCalledTimes(1); + }); + + it('renders the Money screen stack', () => { + const { getByTestId } = renderWithProvider(, { + theme: themeWithCustomBackground, + }); + + expect(getByTestId('money-screen-MoneyHome')).toBeOnTheScreen(); + }); +}); + describe('MoneyModalStack', () => { it('registers the Add money sheet as a modal screen', () => { const { getByTestId } = renderWithProvider(, { @@ -96,4 +129,12 @@ describe('MoneyModalStack', () => { expect(getByTestId('money-screen-MoneyAddMoneySheet')).toBeOnTheScreen(); }); + + it('registers the Money balance info sheet as a modal screen', () => { + const { getByTestId } = renderWithProvider(, { + theme: themeWithCustomBackground, + }); + + expect(getByTestId('money-screen-MoneyBalanceInfoSheet')).toBeOnTheScreen(); + }); }); diff --git a/app/components/UI/Money/routes/index.tsx b/app/components/UI/Money/routes/index.tsx index f8606e09ebd..153de51ac71 100644 --- a/app/components/UI/Money/routes/index.tsx +++ b/app/components/UI/Money/routes/index.tsx @@ -1,12 +1,20 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { createStackNavigator } from '@react-navigation/stack'; import Routes from '../../../../constants/navigation/Routes'; import { clearStackNavigatorOptions } from '../../../../constants/navigation/clearStackNavigatorOptions'; import { useTheme } from '../../../../util/theme'; +import useThunkDispatch from '../../../hooks/useThunkDispatch'; +import { upgradeMoneyAccount } from '../../../../actions/money'; import MoneyHomeView from '../Views/MoneyHomeView'; import MoneyActivityView from '../Views/MoneyActivityView'; import MoneyHowItWorksView from '../Views/MoneyHowItWorksView'; +import MoneyPotentialEarningsView from '../Views/MoneyPotentialEarningsView'; import MoneyAddMoneySheet from '../components/MoneyAddMoneySheet'; +import MoneyMoreSheet from '../components/MoneyMoreSheet'; +import MoneyTransferSheet from '../components/MoneyTransferSheet'; +import MoneyApyInfoSheet from '../components/MoneyApyInfoSheet'; +import MoneyEarningsInfoSheet from '../components/MoneyEarningsInfoSheet'; +import MoneyBalanceInfoSheet from '../components/MoneyBalanceInfoSheet'; import { Confirm } from '../../../Views/confirmations/components/confirm'; import { useEmptyNavHeaderForConfirmations } from '../../../Views/confirmations/hooks/ui/useEmptyNavHeaderForConfirmations'; @@ -19,6 +27,7 @@ const MoneyScreenStack = () => { return ( { name={Routes.MONEY.HOW_IT_WORKS} component={MoneyHowItWorksView} /> + ( component={MoneyAddMoneySheet} options={{ headerShown: false }} /> + + + + + ); -export { MoneyScreenStack, MoneyModalStack }; +const MoneyAccountStackGate = () => { + const dispatch = useThunkDispatch(); + + useEffect(() => { + dispatch(upgradeMoneyAccount()); + }, [dispatch]); + + return ; +}; + +export { MoneyAccountStackGate, MoneyScreenStack, MoneyModalStack }; diff --git a/app/components/UI/Money/utils/moneyAccountTransactions.test.ts b/app/components/UI/Money/utils/moneyAccountTransactions.test.ts index 94cef3e111f..7a1ce5f6d6a 100644 --- a/app/components/UI/Money/utils/moneyAccountTransactions.test.ts +++ b/app/components/UI/Money/utils/moneyAccountTransactions.test.ts @@ -1,5 +1,8 @@ import { ethers } from 'ethers'; -import { TransactionType } from '@metamask/transaction-controller'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; import { ORIGIN_METAMASK } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../../Earn/constants/musd'; @@ -8,7 +11,15 @@ import { getSharesForWithdrawal, buildMoneyAccountDepositBatch, buildMoneyAccountWithdraw, + updateMoneyAccountDepositTokenAmount, + updateMoneyAccountWithdrawTokenAmount, } from './moneyAccountTransactions'; +import { + type MoneyAccountVaultConfig, + selectMoneyAccountVaultConfig, +} from '../../../../selectors/featureFlagController/moneyAccount'; +import { getProviderByChainId } from '../../../../util/notifications/methods/common'; +import ReduxService from '../../../../core/redux'; jest.mock('../../Earn/constants/musd', () => ({ MUSD_TOKEN_ADDRESS_BY_CHAIN: {} as Record, @@ -21,6 +32,10 @@ jest.mock('../../../../core/AppConstants', () => ({ }, })); +jest.mock('../../../../util/notifications/methods/common'); +jest.mock('../../../../core/redux'); +jest.mock('../../../../selectors/featureFlagController/moneyAccount'); + const mockPreviewDeposit = jest.fn(); const mockGetRate = jest.fn(); @@ -45,6 +60,11 @@ jest.mock('ethers', () => { }; }); +const mockGetProviderByChainId = jest.mocked(getProviderByChainId); +const mockSelectMoneyAccountVaultConfig = jest.mocked( + selectMoneyAccountVaultConfig, +); + const MOCK_CHAIN_ID = '0x1' as Hex; const MOCK_MUSD_ADDRESS = '0xaca92e438df0b2401ff60da7e4337b687a2435da' as Hex; const MOCK_BORING_VAULT = '0xB5F07d769dD60fE54c97dd53101181073DDf21b2' as Hex; @@ -54,6 +74,14 @@ const MOCK_LENS = '0x846a7832022350434B5cC006d07cc9c782469660' as Hex; const MOCK_TO_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' as Hex; const MOCK_PROVIDER = {} as ethers.providers.Provider; +const MOCK_VAULT_CONFIG: MoneyAccountVaultConfig = { + chainId: '0xa4b1', + boringVault: MOCK_BORING_VAULT, + tellerAddress: MOCK_TELLER, + accountantAddress: MOCK_ACCOUNTANT, + lensAddress: MOCK_LENS, +}; + describe('moneyAccountTransactions', () => { beforeEach(() => { jest.clearAllMocks(); @@ -172,6 +200,113 @@ describe('moneyAccountTransactions', () => { }); }); + describe('updateMoneyAccountDepositTokenAmount', () => { + const mockTransactionMeta = { + id: 'tx-1', + chainId: MOCK_VAULT_CONFIG.chainId as Hex, + } as unknown as TransactionMeta; + + beforeEach(() => { + // Default: vault config present, provider present + mockGetProviderByChainId.mockReturnValue(MOCK_PROVIDER as never); + mockSelectMoneyAccountVaultConfig.mockReturnValue(MOCK_VAULT_CONFIG); + ( + jest.mocked(ReduxService) as unknown as { + store: { getState: jest.Mock }; + } + ).store = { getState: jest.fn().mockReturnValue({}) }; + }); + + it('returns indexed approve and deposit calls for a valid amount', async () => { + mockPreviewDeposit.mockResolvedValue(ethers.BigNumber.from('1000000')); + + const result = await updateMoneyAccountDepositTokenAmount( + mockTransactionMeta, + '1.0', + ); + + expect(result).toHaveLength(2); + expect(result[0].nestedTransactionIndex).toBe(0); + expect(result[0].transactionData).toMatch(/^0x/); + expect(result[1].nestedTransactionIndex).toBe(1); + expect(result[1].transactionData).toMatch(/^0x/); + }); + + it('calls previewDeposit with the converted amount', async () => { + mockPreviewDeposit.mockResolvedValue(ethers.BigNumber.from('1000000')); + + await updateMoneyAccountDepositTokenAmount(mockTransactionMeta, '1.0'); + + // 1.0 USDC with 6 decimals = 1_000_000 + expect(mockPreviewDeposit).toHaveBeenCalledWith( + expect.any(String), + '1000000', + MOCK_VAULT_CONFIG.boringVault, + MOCK_VAULT_CONFIG.accountantAddress, + ); + }); + + it('returns [] when vault config is missing', async () => { + mockSelectMoneyAccountVaultConfig.mockReturnValue(undefined); + + const result = await updateMoneyAccountDepositTokenAmount( + mockTransactionMeta, + '1.0', + ); + + expect(result).toEqual([]); + expect(mockGetProviderByChainId).not.toHaveBeenCalled(); + expect(mockPreviewDeposit).not.toHaveBeenCalled(); + }); + + it('returns [] when provider is missing', async () => { + mockGetProviderByChainId.mockReturnValue(undefined as never); + + const result = await updateMoneyAccountDepositTokenAmount( + mockTransactionMeta, + '1.0', + ); + + expect(result).toEqual([]); + expect(mockPreviewDeposit).not.toHaveBeenCalled(); + }); + + it('rejects when previewDeposit fails so the dispatcher can log the error', async () => { + const rpcError = new Error('RPC connection refused'); + mockPreviewDeposit.mockRejectedValue(rpcError); + + await expect( + updateMoneyAccountDepositTokenAmount(mockTransactionMeta, '1.0'), + ).rejects.toThrow('RPC connection refused'); + }); + }); + + describe('updateMoneyAccountWithdrawTokenAmount', () => { + it('resolves to an empty array (stub implementation)', async () => { + const transactionMeta = { + id: 'tx-1', + nestedTransactions: [], + } as unknown as TransactionMeta; + + await expect( + updateMoneyAccountWithdrawTokenAmount(transactionMeta, '1.23'), + ).resolves.toEqual([]); + }); + + it('resolves to an array regardless of transactionMeta shape', async () => { + const transactionMeta = { + id: 'tx-2', + } as unknown as TransactionMeta; + + const result = await updateMoneyAccountWithdrawTokenAmount( + transactionMeta, + '1.23', + ); + + expect(Array.isArray(result)).toBe(true); + }); + }); + describe('buildMoneyAccountWithdraw', () => { it('returns withdraw params with correct transaction type', async () => { mockGetRate.mockResolvedValue(ethers.BigNumber.from('1000000')); diff --git a/app/components/UI/Money/utils/moneyAccountTransactions.ts b/app/components/UI/Money/utils/moneyAccountTransactions.ts index dba593fe903..76fef2d8388 100644 --- a/app/components/UI/Money/utils/moneyAccountTransactions.ts +++ b/app/components/UI/Money/utils/moneyAccountTransactions.ts @@ -1,9 +1,19 @@ import { ethers } from 'ethers'; -import { TransactionType } from '@metamask/transaction-controller'; +import { BigNumber } from 'bignumber.js'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; import { ORIGIN_METAMASK } from '@metamask/controller-utils'; import { Hex } from '@metamask/utils'; +import { UpdateTransactionPayAmountCall } from '../../../Views/confirmations/types/transactions'; import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../../Earn/constants/musd'; import AppConstants from '../../../../core/AppConstants'; +import { calcTokenValue } from '../../../../util/transactions'; +import { getProviderByChainId } from '../../../../util/notifications/methods/common'; +import { selectMoneyAccountVaultConfig } from '../../../../selectors/featureFlagController/moneyAccount'; +import ReduxService from '../../../../core/redux'; +import type { RootState } from '../../../../reducers'; const LENS_ABI = [ 'function previewDeposit(address depositAsset, uint256 depositAmount, address boringVault, address accountant) view returns (uint256 shares)', @@ -91,6 +101,24 @@ function buildDepositData( ]) as Hex; } +/** + * Single source of truth for the deposit asset so both calldata encoding + * (`buildMoneyAccountDepositBatch`) and Pay's `requiredAssets` agree. + * @param _chainId - The chain ID to get the deposit asset address for. + * @returns The deposit asset address for the given chain ID. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function getMoneyAccountDepositAssetAddress(chainId: Hex): Hex { + // TODO: uncomment when mUSD is deployed + // const musdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[_chainId]; + // if (!musdAddress) { + // throw new Error(`mUSD not deployed on chain ${_chainId}`); + // } + // return musdAddress; + // TODO: remove when mUSD is deployed - temporarily hardcoded USDC + return '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'; +} + export interface MoneyAccountDepositBatchResult { approveTx: MoneyAccountTxParams; depositTx: MoneyAccountTxParams; @@ -121,24 +149,23 @@ export async function buildMoneyAccountDepositBatch({ lensAddress: string; provider: ethers.providers.Provider; }): Promise { - // TODO: uncomment when mUSD is deployed - // const musdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId]; - // if (!musdAddress) { - // throw new Error(`mUSD not deployed on chain ${chainId}`); - // } - // TODO: remove when mUSD is deployed - temporarily hardcoded USDC - const musdAddress = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'; + const musdAddress = getMoneyAccountDepositAssetAddress(chainId); - const expectedShares = await getExpectedDepositShares({ - lensAddress, - boringVault, - accountantAddress, - musdAddress, - amount, - provider, - }); + // Skip the RPC call for zero-amount placeholder batches (e.g. initial deposit submission). + const minimumMint = + amount === 0n + ? 0n + : applySlippage( + await getExpectedDepositShares({ + lensAddress, + boringVault, + accountantAddress, + musdAddress, + amount, + provider, + }), + ); - const minimumMint = applySlippage(expectedShares); const approveData = buildApproveData(boringVault, amount); const depositData = buildDepositData(musdAddress, amount, minimumMint); @@ -162,6 +189,72 @@ export async function buildMoneyAccountDepositBatch({ }; } +/** Decimals for USDC (the deposit asset). */ +const USDC_DECIMALS = 6; + +/** + * Returns the per-nested-call data updates required when the user changes + * the deposit amount on a Money Account deposit confirmation. + * + * Reads vault config from the Redux store, calls `previewDeposit` on the + * lens contract to derive an accurate `minimumMint`, and returns the + * re-encoded approve + deposit calldata ready for `updateAtomicBatchData`. + * + * Returns `[]` (no-op) if vault config or provider is unavailable. + * Lets `buildMoneyAccountDepositBatch` errors propagate so the dispatcher + * can log them via its prep-error handler. + */ +export async function updateMoneyAccountDepositTokenAmount( + transactionMeta: TransactionMeta, + amountHuman: string, +): Promise { + const vaultConfig = selectMoneyAccountVaultConfig( + ReduxService.store.getState() as RootState, + ); + if (!vaultConfig) return []; + + const chainIdHex = transactionMeta.chainId as Hex; + const provider = getProviderByChainId(chainIdHex); + if (!provider) return []; + + const amount = BigInt( + calcTokenValue(amountHuman, USDC_DECIMALS) + .decimalPlaces(0, BigNumber.ROUND_UP) + .toFixed(0), + ); + + const { approveTx, depositTx } = await buildMoneyAccountDepositBatch({ + amount, + chainId: chainIdHex, + boringVault: vaultConfig.boringVault, + tellerAddress: vaultConfig.tellerAddress, + accountantAddress: vaultConfig.accountantAddress, + lensAddress: vaultConfig.lensAddress, + provider, + }); + + return [ + { nestedTransactionIndex: 0, transactionData: approveTx.params.data }, + { nestedTransactionIndex: 1, transactionData: depositTx.params.data }, + ]; +} + +/** + * Returns the per-nested-call data updates required when the user changes + * the withdrawal amount on a Money Account withdraw confirmation. + * + * Stub implementation — real encoding will replace this once the withdraw + * re-encoding logic is wired in. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export async function updateMoneyAccountWithdrawTokenAmount( + _transactionMeta: TransactionMeta, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _amountHuman: string, +): Promise { + return []; +} + // -- Withdrawal helpers ---------------------------------------------------- async function getVaultRate({ diff --git a/app/components/UI/Money/utils/moneyActivityFiat.test.ts b/app/components/UI/Money/utils/moneyActivityFiat.test.ts index caf75dbf54c..01f2b245c72 100644 --- a/app/components/UI/Money/utils/moneyActivityFiat.test.ts +++ b/app/components/UI/Money/utils/moneyActivityFiat.test.ts @@ -4,10 +4,7 @@ import { } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { safeToChecksumAddress } from '../../../../util/address'; -import { - buildMoneyActivityFiatLine, - formatMoneyActivityFiatDisplay, -} from './moneyActivityFiat'; +import { buildMoneyActivityFiatLine } from './moneyActivityFiat'; import { getMusdDisplayAmountFromTransactionMeta } from '../constants/activityStyles'; import { MUSD_TOKEN_ADDRESS } from '../../Earn/constants/musd'; @@ -66,48 +63,67 @@ function makeIncomingTx( } describe('moneyActivityFiat', () => { - describe('formatMoneyActivityFiatDisplay', () => { - it('uses exactly two fractional digits', () => { - const out = formatMoneyActivityFiatDisplay(1234.5, 'USD'); - expect(out).toMatch(/1,234\.50/); - }); - }); - describe('buildMoneyActivityFiatLine', () => { - it('prefixes with sign and formats fiat in USD using market rate', () => { + it('prefixes with + and formats fiat in USD using market rate', () => { const tx = makeIncomingTx('1000000000'); - expect( - buildMoneyActivityFiatLine(tx, mockRates, 'usd', mockMarketUsd), - ).toMatch(/^\+.*1,000\.00/); + + const line = buildMoneyActivityFiatLine( + tx, + mockRates, + 'usd', + mockMarketUsd, + ); + + expect(line).toMatch(/^\+.*1,000\.00/); + }); + + it('prefixes outgoing transactions with -', () => { + const tx = makeIncomingTx('1000000000', { + type: TransactionType.simpleSend, + }); + + const line = buildMoneyActivityFiatLine( + tx, + mockRates, + 'usd', + mockMarketUsd, + ); + + expect(line).toMatch(/^-/); }); it('converts to EUR using token→ETH price and ETH→fiat rate', () => { const tx = makeIncomingTx('1000000000'); + const line = buildMoneyActivityFiatLine( tx, mockRatesEur, 'eur', mockMarketEur, ); + expect(line).toMatch(/^\+/); expect(line).toMatch(/920/); }); it('converts mUSD to fiat via peg-derived token→ETH price when market data is missing', () => { const tx = makeIncomingTx('1000000000'); - expect(buildMoneyActivityFiatLine(tx, mockRates, 'usd', {})).toMatch( - /^\+.*1,000\.00/, - ); + + const line = buildMoneyActivityFiatLine(tx, mockRates, 'usd', {}); + + expect(line).toMatch(/^\+.*1,000\.00/); }); it('converts mUSD to EUR via peg when market data is missing', () => { const tx = makeIncomingTx('1000000000'); + const line = buildMoneyActivityFiatLine(tx, mockRatesEur, 'eur', {}); + expect(line).toMatch(/^\+/); expect(line).toMatch(/920/); }); - it('returns empty when market data is missing and token is not mUSD-like', () => { + it('returns empty string when market data is missing and token is not mUSD-like', () => { const tx = makeIncomingTx('1000000000', { transferInformation: { amount: '1000000000', @@ -116,21 +132,65 @@ describe('moneyActivityFiat', () => { contractAddress: OTHER_TOKEN_CONTRACT, }, }); - expect(buildMoneyActivityFiatLine(tx, mockRates, 'usd', {})).toBe(''); + + const line = buildMoneyActivityFiatLine(tx, mockRates, 'usd', {}); + + expect(line).toBe(''); + }); + + it('returns empty string when transferInformation is absent', () => { + const tx = makeIncomingTx('1000000000', { + transferInformation: undefined, + }); + + const line = buildMoneyActivityFiatLine( + tx, + mockRates, + 'usd', + mockMarketUsd, + ); + + expect(line).toBe(''); + }); + + it('returns empty string when currentCurrency is undefined', () => { + const tx = makeIncomingTx('1000000000'); + + const line = buildMoneyActivityFiatLine( + tx, + mockRates, + undefined, + mockMarketUsd, + ); + + expect(line).toBe(''); + }); + + it('returns empty string when currencyRates are undefined', () => { + const tx = makeIncomingTx('1000000000'); + + const line = buildMoneyActivityFiatLine(tx, undefined, 'usd', {}); + + expect(line).toBe(''); }); }); describe('token amount from raw minimal units', () => { it('formats a whole-number amount with two decimals and thousands separator', () => { const tx = makeIncomingTx('1000000000'); + const display = getMusdDisplayAmountFromTransactionMeta(tx); + expect(display).toContain('1,000.00'); expect(display).toContain('mUSD'); }); it('formats a fractional amount with two decimals', () => { const tx = makeIncomingTx('1000500000'); - expect(getMusdDisplayAmountFromTransactionMeta(tx)).toContain('1,000.50'); + + const display = getMusdDisplayAmountFromTransactionMeta(tx); + + expect(display).toContain('1,000.50'); }); }); }); diff --git a/app/components/UI/Money/utils/moneyActivityFiat.ts b/app/components/UI/Money/utils/moneyActivityFiat.ts index 5461c0b7498..db067a125a0 100644 --- a/app/components/UI/Money/utils/moneyActivityFiat.ts +++ b/app/components/UI/Money/utils/moneyActivityFiat.ts @@ -1,9 +1,9 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import type { CurrencyRateState } from '@metamask/assets-controllers'; import type { Hex } from '@metamask/utils'; -import I18n from '../../../../../locales/i18n'; -import { getIntlNumberFormatter } from '../../../../util/intl'; +import BigNumber from 'bignumber.js'; import { safeToChecksumAddress } from '../../../../util/address'; +import { moneyFormatFiat } from './moneyFormatFiat'; import { balanceToFiatNumber, fromTokenMinimalUnit, @@ -130,26 +130,6 @@ function getEthToFiatConversionRate( return resolveCurrencyRateEntry(currencyRates, ETH_TICKER)?.conversionRate; } -/** - * Formats a fiat value with exactly two fractional digits for Money activity rows. - */ -export function formatMoneyActivityFiatDisplay( - amountInSelectedCurrency: number, - isoCurrencyCode: string, -): string { - try { - return getIntlNumberFormatter(I18n.locale, { - style: 'currency', - currency: isoCurrencyCode.toUpperCase(), - currencyDisplay: 'narrowSymbol', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(amountInSelectedCurrency); - } catch { - return `${amountInSelectedCurrency.toFixed(2)} ${isoCurrencyCode.toUpperCase()}`; - } -} - /** * Secondary fiat line for a Money activity row: prefix (+/-) + localized currency (2 decimals). * Converts **token → fiat** via {@link balanceToFiatNumber}: human token amount × ETH→fiat @@ -232,6 +212,5 @@ export function buildMoneyActivityFiatLine( return ''; } - const display = formatMoneyActivityFiatDisplay(fiatNumber, currentCurrency); - return `${prefix}${display}`; + return `${prefix}${moneyFormatFiat(new BigNumber(fiatNumber), currentCurrency)}`; } diff --git a/app/components/UI/Money/utils/moneyFormatFiat.test.ts b/app/components/UI/Money/utils/moneyFormatFiat.test.ts new file mode 100644 index 00000000000..0fcd4c81a77 --- /dev/null +++ b/app/components/UI/Money/utils/moneyFormatFiat.test.ts @@ -0,0 +1,64 @@ +import { BigNumber } from 'bignumber.js'; +import { formatWithThreshold } from '../../../../util/assets'; +import { getLocaleLanguageCode } from '../../../hooks/useFormatters'; +import { moneyFormatFiat } from './moneyFormatFiat'; + +jest.mock('../../../../util/assets', () => ({ + formatWithThreshold: jest.fn(), +})); + +jest.mock('../../../hooks/useFormatters', () => ({ + getLocaleLanguageCode: jest.fn(), +})); + +describe('moneyFormatFiat', () => { + const mockFormatWithThreshold = jest.mocked(formatWithThreshold); + const mockGetLocaleLanguageCode = jest.mocked(getLocaleLanguageCode); + + beforeEach(() => { + jest.clearAllMocks(); + mockGetLocaleLanguageCode.mockReturnValue('en'); + mockFormatWithThreshold.mockReturnValue('formatted'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('delegates to formatWithThreshold with locale from getLocaleLanguageCode', () => { + mockGetLocaleLanguageCode.mockReturnValue('fr'); + + moneyFormatFiat(new BigNumber(42), 'usd'); + + expect(mockFormatWithThreshold).toHaveBeenCalledWith(42, 0.01, 'fr', { + style: 'currency', + currency: 'usd', + }); + }); + + it('passes the numeric value from BigNumber.toNumber()', () => { + moneyFormatFiat(new BigNumber('99.99'), 'eur'); + + expect(mockFormatWithThreshold).toHaveBeenCalledWith(99.99, 0.01, 'en', { + style: 'currency', + currency: 'eur', + }); + }); + + it('uses the provided ISO currency code in format options', () => { + moneyFormatFiat(new BigNumber(5), 'gbp'); + + expect(mockFormatWithThreshold).toHaveBeenCalledWith(5, 0.01, 'en', { + style: 'currency', + currency: 'gbp', + }); + }); + + it('returns the string produced by formatWithThreshold', () => { + mockFormatWithThreshold.mockReturnValue('£10.00'); + + const result = moneyFormatFiat(new BigNumber(10), 'gbp'); + + expect(result).toBe('£10.00'); + }); +}); diff --git a/app/components/UI/Money/utils/moneyFormatFiat.ts b/app/components/UI/Money/utils/moneyFormatFiat.ts new file mode 100644 index 00000000000..cf0131f9860 --- /dev/null +++ b/app/components/UI/Money/utils/moneyFormatFiat.ts @@ -0,0 +1,21 @@ +import { formatWithThreshold } from '../../../../util/assets'; +import { getLocaleLanguageCode } from '../../../hooks/useFormatters'; + +const THRESHOLD = 0.01; + +/** + * + * Helper that wraps formatWithThreshold to centralize Money formatting logic. + * + * @param value - The fiat value to format. + * @param currentCurrency - The user's selected currency to format the value in. + * @returns The formatted fiat value. + */ +export const moneyFormatFiat = ( + value: BigNumber, + currentCurrency: string, +): string => + formatWithThreshold(value.toNumber(), THRESHOLD, getLocaleLanguageCode(), { + style: 'currency', + currency: currentCurrency, + }); diff --git a/app/components/UI/Money/utils/projections.test.ts b/app/components/UI/Money/utils/projections.test.ts new file mode 100644 index 00000000000..20e3c74823f --- /dev/null +++ b/app/components/UI/Money/utils/projections.test.ts @@ -0,0 +1,47 @@ +import { calculateProjectedEarnings } from './projections'; + +describe('calculateProjectedEarnings', () => { + it('returns zero when principal is zero', () => { + const result = calculateProjectedEarnings(0, 4); + + expect(result).toBe(0); + }); + + it('returns zero when APY is zero', () => { + const result = calculateProjectedEarnings(1000, 0); + + expect(result).toBe(0); + }); + + it('calculates 1-year earnings using the default year parameter', () => { + const result = calculateProjectedEarnings(1000, 4); + + expect(result).toBe(40); + }); + + it('calculates multi-year earnings with an explicit year parameter', () => { + const result = calculateProjectedEarnings(1000, 4, 5); + + expect(result).toBe(200); + }); + + it('uses linear projection, not compound', () => { + const linear = calculateProjectedEarnings(1000, 4, 5); + const compound = 1000 * (Math.pow(1 + 4 / 100, 5) - 1); + + expect(linear).toBe(200); + expect(linear).not.toBeCloseTo(compound, 1); + }); + + it('handles fractional APY values', () => { + const result = calculateProjectedEarnings(1000, 5.5, 1); + + expect(result).toBe(55); + }); + + it('handles fractional year values', () => { + const result = calculateProjectedEarnings(1000, 4, 0.5); + + expect(result).toBe(20); + }); +}); diff --git a/app/components/UI/Money/utils/projections.ts b/app/components/UI/Money/utils/projections.ts new file mode 100644 index 00000000000..fdb1203676a --- /dev/null +++ b/app/components/UI/Money/utils/projections.ts @@ -0,0 +1,20 @@ +/** Number of years the projected earnings are simulated over. */ +export const PROJECTION_YEARS = 1; + +/** + * Linear projection of earnings over a given time period. + * + * We intentionally use simple interest rather than compound interest because + * the APY rate is variable and may change daily. Compounding a variable rate + * over multiple years would create unrealistic return expectations. + * + * @param principalFiat - The fiat value of the principal amount. + * @param apyPercent - APY expressed as a percentage (e.g. 4 for 4%). + * @param years - The number of years to project over (defaults to 1). + * @returns The projected earnings in fiat. + */ +export const calculateProjectedEarnings = ( + principalFiat: number, + apyPercent: number, + years: number = 1, +): number => principalFiat * (apyPercent / 100) * years; diff --git a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx index ab09e422929..1469dbcb56d 100644 --- a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx +++ b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx @@ -27,6 +27,7 @@ import BadgeWrapper from '../../../component-library/components/Badges/BadgeWrap import Badge, { BadgeVariant, } from '../../../component-library/components/Badges/Badge'; +import { AvatarSize } from '../../../component-library/components/Avatars/Avatar'; import { getNetworkImageSource } from '../../../util/networks'; import { parseCaipAssetType } from '@metamask/utils'; import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; @@ -102,10 +103,13 @@ const MultichainBridgeTransactionListItem = ({ const networkImageSource = getNetworkImageSource({ chainId }); return ( } > diff --git a/app/components/UI/MultichainTransactionListItem/MultichainTransactionListItem.tsx b/app/components/UI/MultichainTransactionListItem/MultichainTransactionListItem.tsx index 24b416ef220..ce4454228a7 100644 --- a/app/components/UI/MultichainTransactionListItem/MultichainTransactionListItem.tsx +++ b/app/components/UI/MultichainTransactionListItem/MultichainTransactionListItem.tsx @@ -21,6 +21,7 @@ import BadgeWrapper from '../../../component-library/components/Badges/BadgeWrap import Badge, { BadgeVariant, } from '../../../component-library/components/Badges/Badge'; +import { AvatarSize } from '../../../component-library/components/Avatars/Avatar'; import { getNetworkImageSource } from '../../../util/networks'; import Routes from '../../../constants/navigation/Routes'; import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; @@ -91,10 +92,13 @@ const MultichainTransactionListItem = ({ return ( } > diff --git a/app/components/UI/Navbar/Navbar.testIds.ts b/app/components/UI/Navbar/Navbar.testIds.ts new file mode 100644 index 00000000000..503ce2f5862 --- /dev/null +++ b/app/components/UI/Navbar/Navbar.testIds.ts @@ -0,0 +1,4 @@ +export const NavbarSelectorsIDs = { + ANDROID_BACK_BUTTON: 'nav-android-back', + BACK_BUTTON_SIMPLE_WEBVIEW: 'back_button_simple_webview', +}; diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index b639085f6dd..7c671c31fc8 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -5,7 +5,6 @@ import ModalNavbarTitle from '../ModalNavbarTitle'; import { Alert, Image, - Platform, StyleSheet, Text, TouchableOpacity, @@ -21,9 +20,7 @@ import { analytics } from '../../../util/analytics/analytics'; import { Authentication } from '../../../core'; import { isNotificationsFeatureEnabled } from '../../../util/notifications'; import Device from '../../../util/device'; -import generateTestId from '../../../../wdio/utils/generateTestId'; -import { NAV_ANDROID_BACK_BUTTON } from '../../../../wdio/screen-objects/testIDs/Screens/NetworksScreen.testids'; -import { BACK_BUTTON_SIMPLE_WEBVIEW } from '../../../../wdio/screen-objects/testIDs/Components/SimpleWebView.testIds'; +import { NavbarSelectorsIDs } from './Navbar.testIds'; import Routes from '../../../constants/navigation/Routes'; import { @@ -602,7 +599,7 @@ export function getClosableNavigationOptions( { type="confirm" onPress={onClose} containerStyle={styles.closeButton} - testID={NETWORK_EDUCATION_MODAL_CLOSE_BUTTON} + testID={NetworkEducationModalSelectorsIDs.CLOSE_BUTTON} > {strings('network_information.got_it')} diff --git a/app/components/UI/Notification/Modal/index.tsx b/app/components/UI/Notification/Modal/index.tsx index 9a37c8f3cae..4f4077167e8 100644 --- a/app/components/UI/Notification/Modal/index.tsx +++ b/app/components/UI/Notification/Modal/index.tsx @@ -1,18 +1,16 @@ import React from 'react'; import { View } from 'react-native'; import Checkbox from '../../../../component-library/components/Checkbox/Checkbox'; -import Icon, { - IconColor, - IconName, - IconSize, -} from '../../../../component-library/components/Icons/Icon'; -import Text, { - TextVariant, -} from '../../../../component-library/components/Texts/Text'; import { Button, ButtonVariant, ButtonSize, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextVariant, } from '@metamask/design-system-react-native'; import createStyles from './styles'; import { useTheme } from '../../../../util/theme'; @@ -60,10 +58,10 @@ const ModalContent = ({ size={iconSize} style={styles.icon} /> - + {title} - + {message} diff --git a/app/components/UI/Perps/Debug/HIP3DebugView.tsx b/app/components/UI/Perps/Debug/HIP3DebugView.tsx index bd018ef1bbe..d0357376799 100644 --- a/app/components/UI/Perps/Debug/HIP3DebugView.tsx +++ b/app/components/UI/Perps/Debug/HIP3DebugView.tsx @@ -52,13 +52,13 @@ const HIP3DebugView: React.FC = () => { // Balance and positions display state const [balanceInfo, setBalanceInfo] = useState<{ totalBalance: string; - availableBalance: string; + spendableBalance: string; marginUsed: string; positionCount: number; subAccountCount: number; subAccountBreakdown?: Record< string, - { availableBalance: string; totalBalance: string } + { spendableBalance: string; totalBalance: string } >; } | null>(null); @@ -167,7 +167,7 @@ const HIP3DebugView: React.FC = () => { const accountSummary = { totalBalance: accountState.totalBalance, - availableBalance: accountState.availableBalance, + spendableBalance: accountState.spendableBalance, marginUsed: accountState.marginUsed, unrealizedPnl: accountState.unrealizedPnl, positions: positions.length, @@ -187,7 +187,7 @@ const HIP3DebugView: React.FC = () => { ([subAccount, breakdown]) => { const subAccountInfo = { totalBalance: breakdown.totalBalance, - availableBalance: breakdown.availableBalance, + spendableBalance: breakdown.spendableBalance, }; DevLogger.log( ` ${subAccount || 'main'} sub-account:\n` + @@ -200,7 +200,7 @@ const HIP3DebugView: React.FC = () => { // Update UI state setBalanceInfo({ totalBalance: accountState.totalBalance, - availableBalance: accountState.availableBalance, + spendableBalance: accountState.spendableBalance, marginUsed: accountState.marginUsed, positionCount: positions.length, subAccountCount, @@ -267,10 +267,10 @@ const HIP3DebugView: React.FC = () => { try { // Get current balance on selected DEX const accountState = await provider.getAccountState(); - const availableBalance = - accountState.subAccountBreakdown?.[selectedDex]?.availableBalance; + const spendableBalance = + accountState.subAccountBreakdown?.[selectedDex]?.spendableBalance; - if (!availableBalance || parseFloat(availableBalance) <= 0) { + if (!spendableBalance || parseFloat(spendableBalance) <= 0) { const message = `⚠️ No available balance on ${selectedDex} DEX to transfer`; DevLogger.log(message); setTransferResult({ @@ -281,17 +281,17 @@ const HIP3DebugView: React.FC = () => { } DevLogger.log( - `Transferring ALL available balance ($${availableBalance}) from ${selectedDex} to main`, + `Transferring ALL available balance ($${spendableBalance}) from ${selectedDex} to main`, ); const result = await provider.transferBetweenDexs({ sourceDex: selectedDex, destinationDex: '', - amount: availableBalance, + amount: spendableBalance, }); if (result.success) { - const message = `✅ Reset complete: Transferred $${availableBalance} from ${selectedDex} to main DEX`; + const message = `✅ Reset complete: Transferred $${spendableBalance} from ${selectedDex} to main DEX`; DevLogger.log(message); setTransferResult({ status: 'success', @@ -623,7 +623,7 @@ const HIP3DebugView: React.FC = () => { Total: ${balanceInfo.totalBalance} - Available: ${balanceInfo.availableBalance} + Available: ${balanceInfo.spendableBalance} Margin Used: ${balanceInfo.marginUsed} @@ -662,7 +662,7 @@ const HIP3DebugView: React.FC = () => { variant={TextVariant.BodySM} style={styles.successText} > - {' '}Available: ${breakdown.availableBalance} + {' '}Available: ${breakdown.spendableBalance} ), diff --git a/app/components/UI/Perps/Perps.testIds.ts b/app/components/UI/Perps/Perps.testIds.ts index 3dcaa9ff3ef..921e4cb7885 100644 --- a/app/components/UI/Perps/Perps.testIds.ts +++ b/app/components/UI/Perps/Perps.testIds.ts @@ -331,8 +331,8 @@ export const PerpsWithdrawViewSelectorsIDs = { RECEIVE_VALUE: 'perps-withdraw-receive-value', FEE_VALUE: 'perps-withdraw-fee-value', TIME_VALUE: 'perps-withdraw-time-value', - // Must render availableBalance only (not availableToTradeBalance): - // withdraw does not offer spot collateral. + // Renders withdrawableBalance (provider handles any spot→perps sweep + // internally, so the field already reflects the max that can exit). AVAILABLE_BALANCE_TEXT: 'perps-withdraw-available-balance-text', }; diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx index 4cfb56fad70..948a508e4f5 100644 --- a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx @@ -222,7 +222,7 @@ describe('PerpsAdjustMarginView', () => { newLiquidationPrice: 1900, currentLiquidationDistance: 5, newLiquidationDistance: 5, - availableBalance: 1000, + spendableBalance: 1000, currentPrice: 2000, isAddMode: true, positionLeverage: 10, @@ -305,7 +305,7 @@ describe('PerpsAdjustMarginView', () => { newLiquidationPrice: 1900, currentLiquidationDistance: 5, newLiquidationDistance: 5, - availableBalance: 1000, + spendableBalance: 1000, currentPrice: 2000, isAddMode: false, positionLeverage: 10, @@ -373,7 +373,7 @@ describe('PerpsAdjustMarginView', () => { newLiquidationPrice: 0, currentLiquidationDistance: 0, newLiquidationDistance: 0, - availableBalance: 0, + spendableBalance: 0, currentPrice: 0, isAddMode: true, positionLeverage: 10, @@ -493,7 +493,7 @@ describe('PerpsAdjustMarginView', () => { newLiquidationPrice: 1900, currentLiquidationDistance: 5, newLiquidationDistance: 5, - availableBalance: 1000, + spendableBalance: 1000, currentPrice: 2000, isAddMode: false, positionLeverage: 10, diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx index b4511051839..0e5d68c95bd 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.test.tsx @@ -4,6 +4,25 @@ import PerpsHomeView from './PerpsHomeView'; import { PERPS_EVENT_VALUE } from '@metamask/perps-controller'; import { selectPerpsFeedbackEnabledFlag } from '../../selectors/featureFlags'; import { mockTheme } from '../../../../../util/theme'; +import { useDiscoveryScrollManager } from '../../../Predict/hooks/useDiscoveryScrollManager'; + +// Mock useDiscoveryScrollManager +const mockPerpsOnTabEnter = jest.fn(); +const mockPerpsScrollHandler = jest.fn(); +jest.mock('../../../Predict/hooks/useDiscoveryScrollManager', () => ({ + useDiscoveryScrollManager: jest.fn(() => ({ + scrollHandler: mockPerpsScrollHandler, + onTabEnter: mockPerpsOnTabEnter, + headerHidden: false, + })), +})); + +// Mock react-native-reanimated +jest.mock('react-native-reanimated', () => { + const Reanimated = jest.requireActual('react-native-reanimated/mock'); + Reanimated.default.ScrollView = jest.requireActual('react-native').ScrollView; + return Reanimated; +}); // Mock navigation const mockNavigate = jest.fn(); @@ -113,7 +132,8 @@ jest.mock('../../hooks/stream', () => ({ usePerpsLiveAccount: jest.fn(() => ({ account: { totalBalance: '0', - availableBalance: '0', + spendableBalance: '0', + withdrawableBalance: '0', unrealizedPnl: '0', returnOnEquity: '0', }, @@ -876,4 +896,77 @@ describe('PerpsHomeView', () => { }); }); }); + + describe('hideHeader prop', () => { + it('renders the header by default', () => { + const { getByTestId } = render(); + expect(getByTestId('back-button')).toBeTruthy(); + expect(getByTestId('perps-home-search-toggle')).toBeTruthy(); + }); + + it('hides the header when hideHeader is true', () => { + const { queryByTestId } = render(); + expect(queryByTestId('back-button')).toBeNull(); + expect(queryByTestId('perps-home-search-toggle')).toBeNull(); + }); + + it('still renders content when hideHeader is true', () => { + const { UNSAFE_getByType } = render(); + expect( + UNSAFE_getByType('PerpsMarketBalanceActions' as never), + ).toBeTruthy(); + }); + }); + + describe('tabEnterCallbackRef prop', () => { + it('populates tabEnterCallbackRef.current with onTabEnter after mount', () => { + const ref = { current: null } as React.MutableRefObject< + (() => void) | null + >; + render(); + expect(ref.current).toBe(mockPerpsOnTabEnter); + }); + + it('updates tabEnterCallbackRef.current when onTabEnter changes', () => { + const ref = { current: null } as React.MutableRefObject< + (() => void) | null + >; + const newOnTabEnter = jest.fn(); + (useDiscoveryScrollManager as jest.Mock).mockReturnValueOnce({ + scrollHandler: mockPerpsScrollHandler, + onTabEnter: newOnTabEnter, + headerHidden: false, + }); + render(); + expect(ref.current).toBe(newOnTabEnter); + }); + + it('does not throw when tabEnterCallbackRef is not provided', () => { + expect(() => render()).not.toThrow(); + }); + }); + + describe('useDiscoveryScrollManager integration', () => { + it('passes walletHeaderHeight to useDiscoveryScrollManager', () => { + render(); + expect(useDiscoveryScrollManager).toHaveBeenCalledWith( + expect.objectContaining({ walletHeaderHeight: 56 }), + ); + }); + + it('passes onHeaderHiddenChange to useDiscoveryScrollManager', () => { + const onHeaderHiddenChange = jest.fn(); + render(); + expect(useDiscoveryScrollManager).toHaveBeenCalledWith( + expect.objectContaining({ onHeaderHiddenChange }), + ); + }); + + it('uses default walletHeaderHeight of 0 when not provided', () => { + render(); + expect(useDiscoveryScrollManager).toHaveBeenCalledWith( + expect.objectContaining({ walletHeaderHeight: 0 }), + ); + }); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx index 2baecf8cf13..b43a4169ee4 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx @@ -5,7 +5,7 @@ import React, { useEffect, useMemo, } from 'react'; -import { View, ScrollView, Modal } from 'react-native'; +import { View, Modal, NativeScrollEvent } from 'react-native'; import { useSelector } from 'react-redux'; import { SafeAreaView, @@ -57,6 +57,8 @@ import PerpsHomeHeader from '../../components/PerpsHomeHeader'; import type { PerpsNavigationParamList } from '../../types/navigation'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import Reanimated, { SharedValue } from 'react-native-reanimated'; +import { useDiscoveryScrollManager } from '../../../Predict/hooks/useDiscoveryScrollManager'; import styleSheet from './PerpsHomeView.styles'; import { TraceName } from '../../../../../util/trace'; import { @@ -72,7 +74,30 @@ import PerpsNavigationCard, { NavigationItem, } from '../../components/PerpsNavigationCard/PerpsNavigationCard'; -const PerpsHomeView = () => { +interface PerpsHomeViewProps { + hideHeader?: boolean; + walletHeaderTranslateY?: SharedValue; + walletHeaderHeight?: number; + /** Ref populated with this tab's onTabEnter so the parent can call it on tab switch. */ + tabEnterCallbackRef?: React.MutableRefObject<(() => void) | null>; + /** Forwarded to useDiscoveryScrollManager to sync icon animations with header hide/show. */ + onHeaderHiddenChange?: (hidden: boolean) => void; + /** + * Top padding applied inside the scroll content container — used by HomepageDiscoveryTabs + * (Hub Page Discovery Tabs feature flag treatment) so the perps gradient extends up + * directly under the discovery tab bar instead of leaving a transparent gap. + */ + topInset?: number; +} + +const PerpsHomeView = ({ + hideHeader = false, + walletHeaderTranslateY, + walletHeaderHeight = 0, + tabEnterCallbackRef, + onHeaderHiddenChange, + topInset = 0, +}: PerpsHomeViewProps) => { const { styles } = useStyles(styleSheet, {}); const insets = useSafeAreaInsets(); const navigation = useNavigation(); @@ -124,6 +149,38 @@ const PerpsHomeView = () => { const { handleSectionLayout, handleScroll, resetTracking } = usePerpsHomeSectionTracking(); + // Bridge analytics handler into the Reanimated worklet via onScrollEvent + const handleScrollEvent = useCallback( + (scrollY: number, viewportHeight: number) => { + handleScroll({ + nativeEvent: { + contentOffset: { x: 0, y: scrollY }, + layoutMeasurement: { width: 0, height: viewportHeight }, + } as NativeScrollEvent, + }); + }, + [handleScroll], + ); + + const { scrollHandler: perpsScrollHandler, onTabEnter: perpsOnTabEnter } = + useDiscoveryScrollManager({ + walletHeaderHeight, + walletHeaderTranslateY, + onScrollEvent: handleScrollEvent, + onHeaderHiddenChange, + }); + + // Expose onTabEnter to the parent so it can restore this tab's header state on switch. + useEffect(() => { + if (tabEnterCallbackRef) { + tabEnterCallbackRef.current = perpsOnTabEnter; + return () => { + tabEnterCallbackRef.current = null; + }; + } + return undefined; + }, [tabEnterCallbackRef, perpsOnTabEnter]); + // Get balance state directly from Redux const { account: perpsAccount } = usePerpsLiveAccount({ throttleMs: 1000 }); const totalBalance = perpsAccount?.totalBalance || '0'; @@ -417,20 +474,28 @@ const PerpsHomeView = () => { const handleBackPress = perpsNavigation.navigateToWallet; return ( - + {/* Header */} - + {!hideHeader && ( + + )} {/* Main Content - ScrollView with all carousels */} - 0 ? { paddingTop: topInset } : null, + ]} showsVerticalScrollIndicator={false} - onScroll={handleScroll} + onScroll={perpsScrollHandler} scrollEventThrottle={16} > { {/* Bottom spacing for tab bar */} - + {/* Close All Positions Bottom Sheet */} {showCloseAllSheet && ( diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index a2937d7337a..e7eb831f526 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -755,7 +755,8 @@ describe('PerpsMarketDetailsView', () => { beforeEach(() => { mockUsePerpsAccount.mockReturnValue({ account: { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', marginUsed: '0.00', unrealizedPnl: '0.00', returnOnEquity: '0.00', @@ -766,7 +767,8 @@ describe('PerpsMarketDetailsView', () => { mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '1000', + spendableBalance: '1000', + withdrawableBalance: '1000', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -992,7 +994,8 @@ describe('PerpsMarketDetailsView', () => { }); mockUsePerpsAccount.mockReturnValue({ account: { - availableBalance: '0.00', + spendableBalance: '0.00', + withdrawableBalance: '0.00', marginUsed: '0.00', unrealizedPnl: '0.00', returnOnEquity: '0.00', @@ -1003,7 +1006,8 @@ describe('PerpsMarketDetailsView', () => { mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '0', + spendableBalance: '0', + withdrawableBalance: '0', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -1039,7 +1043,8 @@ describe('PerpsMarketDetailsView', () => { mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null); mockUsePerpsAccount.mockReturnValue({ account: { - availableBalance: '0.00', + spendableBalance: '0.00', + withdrawableBalance: '0.00', marginUsed: '0.00', unrealizedPnl: '0.00', returnOnEquity: '0.00', @@ -1049,7 +1054,8 @@ describe('PerpsMarketDetailsView', () => { }); mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '0', + spendableBalance: '0', + withdrawableBalance: '0', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -1080,7 +1086,8 @@ describe('PerpsMarketDetailsView', () => { mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null); mockUsePerpsAccount.mockReturnValue({ account: { - availableBalance: '0.00', + spendableBalance: '0.00', + withdrawableBalance: '0.00', marginUsed: '0.00', unrealizedPnl: '0.00', returnOnEquity: '0.00', @@ -1090,7 +1097,8 @@ describe('PerpsMarketDetailsView', () => { }); mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '0', + spendableBalance: '0', + withdrawableBalance: '0', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -1121,7 +1129,8 @@ describe('PerpsMarketDetailsView', () => { mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null); mockUsePerpsAccount.mockReturnValue({ account: { - availableBalance: '0.00', + spendableBalance: '0.00', + withdrawableBalance: '0.00', marginUsed: '0.00', unrealizedPnl: '0.00', returnOnEquity: '0.00', @@ -1131,7 +1140,8 @@ describe('PerpsMarketDetailsView', () => { }); mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '0', + spendableBalance: '0', + withdrawableBalance: '0', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -1169,7 +1179,8 @@ describe('PerpsMarketDetailsView', () => { mockUseDefaultPayWithTokenWhenNoPerpsBalance.mockReturnValue(null); mockUsePerpsAccount.mockReturnValue({ account: { - availableBalance: '0.00', + spendableBalance: '0.00', + withdrawableBalance: '0.00', marginUsed: '0.00', unrealizedPnl: '0.00', returnOnEquity: '0.00', @@ -1179,7 +1190,8 @@ describe('PerpsMarketDetailsView', () => { }); mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '0', + spendableBalance: '0', + withdrawableBalance: '0', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -1213,7 +1225,8 @@ describe('PerpsMarketDetailsView', () => { // Override with non-zero balance and existing position mockUsePerpsAccount.mockReturnValue({ account: { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', marginUsed: '500.00', unrealizedPnl: '50.00', returnOnEquity: '3.33', diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 4f170ff800d..87e54a385e4 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -443,14 +443,12 @@ const PerpsMarketDetailsView: React.FC = () => { useDefaultPayWithTokenWhenNoPerpsBalance(); const { depositWithConfirmation } = usePerpsTrading(); const { navigateToConfirmation } = useConfirmNavigation(); - const tradeableBalance = Number.parseFloat( - account?.availableToTradeBalance?.toString() ?? - account?.availableBalance?.toString() ?? - '0', + const spendableBalance = Number.parseFloat( + account?.spendableBalance?.toString() ?? '0', ); const hasDirectOrderFundingPath = !isLoadingAccount && - (tradeableBalance >= PERPS_MIN_BALANCE_THRESHOLD || + (spendableBalance >= PERPS_MIN_BALANCE_THRESHOLD || defaultPayTokenWhenNoPerpsBalance !== null); const handleAddFunds = useCallback(async () => { diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index 862a34e1d6c..b5c21785947 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -5,13 +5,13 @@ import React, { useMemo, useCallback, } from 'react'; +import { HeaderStandard } from '@metamask/design-system-react-native'; import { View, Animated } from 'react-native'; import { useStyles } from '../../../../../component-library/hooks'; import Icon, { IconName, IconSize, } from '../../../../../component-library/components/Icons/Icon'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import TextFieldSearch from '../../../../../component-library/components/Form/TextFieldSearch/TextFieldSearch'; import { strings } from '../../../../../../locales/i18n'; import Text, { @@ -67,6 +67,7 @@ const PerpsMarketListView = ({ route.params?.showWatchlistOnly ?? propShowWatchlistOnly ?? false; const defaultMarketTypeFilter = route.params?.defaultMarketTypeFilter ?? 'all'; + const defaultSortOptionId = route.params?.defaultSortOptionId; const fadeAnimation = useRef(new Animated.Value(0)).current; const [isSortFieldSheetVisible, setIsSortFieldSheetVisible] = useState(false); @@ -84,6 +85,7 @@ const PerpsMarketListView = ({ enablePolling: false, showWatchlistOnly, defaultMarketTypeFilter, + defaultSortOptionId, showZeroVolume: __DEV__, }); @@ -317,7 +319,7 @@ const PerpsMarketListView = ({ return ( - { jest.mock('../../hooks/stream', () => ({ usePerpsLiveAccount: jest.fn(() => ({ account: { - availableBalance: '1000', + spendableBalance: '1000', + withdrawableBalance: '1000', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -570,7 +571,8 @@ jest.mock('../../../../../core/Engine', () => ({ subscribeToPrices: jest.fn(() => jest.fn()), getAccountState: jest.fn().mockResolvedValue({ totalBalance: '1000', - availableBalance: '1000', + spendableBalance: '1000', + withdrawableBalance: '1000', marginUsed: '0', unrealizedPnl: '0', }), @@ -714,7 +716,8 @@ const defaultMockRoute = { const defaultMockHooks = { usePerpsLiveAccount: { account: { - availableBalance: '1000', + spendableBalance: '1000', + withdrawableBalance: '1000', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -1569,7 +1572,8 @@ describe('PerpsOrderView', () => { it('handles zero balance warning', async () => { (usePerpsLiveAccount as jest.Mock).mockReturnValue({ balance: '0', - availableBalance: '0', + spendableBalance: '0', + withdrawableBalance: '0', accountInfo: { marginSummary: { accountValue: 0, @@ -1590,7 +1594,8 @@ describe('PerpsOrderView', () => { // Mock insufficient balance (usePerpsLiveAccount as jest.Mock).mockReturnValue({ balance: '10', - availableBalance: '10', + spendableBalance: '10', + withdrawableBalance: '10', accountInfo: { marginSummary: { accountValue: 10, @@ -3744,7 +3749,8 @@ describe('PerpsOrderView', () => { // appear while account data is still loading (usePerpsLiveAccount as jest.Mock).mockReturnValue({ account: { - availableBalance: '0', // Zero balance + spendableBalance: '0', // Zero balance + withdrawableBalance: '0', // Zero balance marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -3773,7 +3779,8 @@ describe('PerpsOrderView', () => { // Arrange - Mock sufficient balance and adjust order form amount (usePerpsLiveAccount as jest.Mock).mockReturnValue({ account: { - availableBalance: '10000', // High balance + spendableBalance: '10000', // High balance + withdrawableBalance: '10000', // High balance marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 16aa59640f8..2d683c72426 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -291,7 +291,7 @@ const PerpsOrderViewContentBase: React.FC = ({ handlePercentageAmount, handleMaxAmount, maxPossibleAmount, - balanceForValidation: availableBalance, + balanceForValidation: spendableBalance, // existingPosition is available in context but not used in this component } = usePerpsOrderContext(); @@ -776,7 +776,7 @@ const PerpsOrderViewContentBase: React.FC = ({ orderForm, positionSize, assetPrice: assetData.price, - availableBalance, + spendableBalance, marginRequired: marginRequired || '0', existingPositionLeverage: existingPositionLeverageForValidation, skipValidation: isInputFocused, @@ -1248,7 +1248,7 @@ const PerpsOrderViewContentBase: React.FC = ({ useInitPerpsPaymentToken(orderForm.asset ?? ''); // Use the same calculation as handleMaxAmount in usePerpsOrderForm to avoid insufficient funds error - const amountTimesLeverage = Math.floor(availableBalance * orderForm.leverage); + const amountTimesLeverage = Math.floor(spendableBalance * orderForm.leverage); const isAmountDisabled = amountTimesLeverage < minimumOrderAmount; @@ -1329,12 +1329,12 @@ const PerpsOrderViewContentBase: React.FC = ({ {/* Amount Display */} 0 && !!filteredErrors.length} + hasError={spendableBalance > 0 && !!filteredErrors.length} isLoading={isLoadingAccount} /> @@ -1853,7 +1853,7 @@ const PerpsOrderViewContentBase: React.FC = ({ // Check if current amount exceeds new maximum value and adjust if needed const currentAmount = parseFloat(orderForm.amount || '0'); - const newMaxAmount = availableBalance * leverage; + const newMaxAmount = spendableBalance * leverage; if (currentAmount > newMaxAmount) { setAmount(Math.floor(newMaxAmount).toString()); } diff --git a/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.test.tsx b/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.test.tsx index d3abf488f8e..e8cbb9991d2 100644 --- a/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.test.tsx @@ -153,7 +153,8 @@ const mockPositions: Position[] = [ const mockAccountState = { totalBalance: '10000', - availableBalance: '4700', + spendableBalance: '4700', + withdrawableBalance: '4700', marginUsed: '5300', }; @@ -239,7 +240,7 @@ describe('PerpsPositionsView', () => { // Check that the actual formatted values appear in the UI // PRICE_RANGES_MINIMAL_VIEW: Fixed 2 decimals, trailing zeros removed expect(screen.getByText('$10,000')).toBeOnTheScreen(); // totalBalance - expect(screen.getByText('$4,700')).toBeOnTheScreen(); // availableBalance + expect(screen.getByText('$4,700')).toBeOnTheScreen(); // spendableBalance expect(screen.getByText('$5,300')).toBeOnTheScreen(); // marginUsed expect(screen.getByText('+$75.50')).toBeOnTheScreen(); // total PnL }); @@ -387,7 +388,8 @@ describe('PerpsPositionsView', () => { (usePerpsLiveAccount as jest.Mock).mockReturnValue({ account: { totalBalance: null, - availableBalance: undefined, + spendableBalance: undefined, + withdrawableBalance: undefined, marginUsed: '', }, isInitialLoading: false, diff --git a/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx b/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx index 7c9e99a430b..2a344c369da 100644 --- a/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx +++ b/app/components/UI/Perps/Views/PerpsPositionsView/PerpsPositionsView.tsx @@ -185,9 +185,9 @@ const PerpsPositionsView: React.FC = () => { isHidden={privacyMode} length={SensitiveTextLength.Short} > - {account?.availableBalance !== undefined && - account?.availableBalance !== null - ? formatPerpsFiat(account.availableBalance, { + {account?.spendableBalance !== undefined && + account?.spendableBalance !== null + ? formatPerpsFiat(account.spendableBalance, { ranges: PRICE_RANGES_MINIMAL_VIEW, }) : PERPS_CONSTANTS.FallbackDataDisplay} diff --git a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx index 7affd0d3a59..492ba221de9 100644 --- a/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTabView/PerpsTabView.test.tsx @@ -122,7 +122,8 @@ jest.mock('../../hooks/stream', () => ({ usePerpsLiveOrders: jest.fn(() => ({ orders: [] })), usePerpsLiveAccount: jest.fn(() => ({ account: { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', marginUsed: '0.00', unrealizedPnl: '0.00', returnOnEquity: '0.00', diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx index ccdf9d0ae52..a9e0d008a6c 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsFundingTransactionView.tsx @@ -9,6 +9,7 @@ import { Button, ButtonVariant, ButtonSize, + HeaderStandard, } from '@metamask/design-system-react-native'; import Text, { TextColor, @@ -17,7 +18,6 @@ import Text, { import { useStyles } from '../../../../../component-library/hooks'; import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import ScreenView from '../../../../Base/ScreenView'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import PerpsTransactionDetailAssetHero from '../../components/PerpsTransactionDetailAssetHero'; import { usePerpsBlockExplorerUrl } from '../../hooks'; import { @@ -54,10 +54,7 @@ const PerpsFundingTransactionView: React.FC = () => { if (!transaction) { return ( - navigation.goBack()} - /> + navigation.goBack()} /> {strings('perps.transactions.not_found')} @@ -109,7 +106,7 @@ const PerpsFundingTransactionView: React.FC = () => { return ( - navigation.goBack()} diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx index 258bd99d72c..f7c42f46fc6 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView.tsx @@ -13,11 +13,11 @@ import { Button, ButtonVariant, ButtonSize, + HeaderStandard, } from '@metamask/design-system-react-native'; import { useStyles } from '../../../../../component-library/hooks'; import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import ScreenView from '../../../../Base/ScreenView'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import PerpsTransactionDetailAssetHero from '../../components/PerpsTransactionDetailAssetHero'; import { usePerpsBlockExplorerUrl, usePerpsOrderFees } from '../../hooks'; import { PerpsOrderTransactionRouteProp } from '../../types/transactionHistory'; @@ -54,10 +54,7 @@ const PerpsOrderTransactionView: React.FC = () => { if (!transaction) { return ( - navigation.goBack()} - /> + navigation.goBack()} /> {strings('perps.transactions.not_found')} @@ -127,7 +124,7 @@ const PerpsOrderTransactionView: React.FC = () => { return ( - navigation.goBack()} diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx index df790bf2a70..37d3e3f54df 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx @@ -13,12 +13,12 @@ import { Button, ButtonVariant, ButtonSize, + HeaderStandard, } from '@metamask/design-system-react-native'; import { useStyles } from '../../../../../component-library/hooks'; import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import Routes from '../../../../../constants/navigation/Routes'; import ScreenView from '../../../../Base/ScreenView'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import PerpsTransactionDetailAssetHero from '../../components/PerpsTransactionDetailAssetHero'; import { usePerpsBlockExplorerUrl } from '../../hooks'; import { @@ -69,10 +69,7 @@ const PerpsPositionTransactionView: React.FC = () => { // Handle missing transaction data return ( - navigation.goBack()} - /> + navigation.goBack()} /> {strings('perps.transactions.not_found')} @@ -174,7 +171,7 @@ const PerpsPositionTransactionView: React.FC = () => { return ( - navigation.goBack()} includesTopInset diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx index a4ea86bc919..deca0f24dec 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.test.tsx @@ -23,6 +23,7 @@ import { mockNetworkState } from '../../../../../util/test/network'; import { TRANSACTION_DETAIL_EVENTS } from '../../../../../core/Analytics/events/transactions'; import { MonetizedPrimitive } from '../../../../../core/Analytics/MetaMetrics.types'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; +import { AccountGroupType, AccountWalletType } from '@metamask/account-api'; const mockTrackEvent = jest.fn(); const mockAddProperties = jest.fn(); @@ -68,6 +69,55 @@ const mockInitialState: DeepPartial = { }, }; +const createMockSelectedAccountGroupState = ( + addresses: string[], + selectedAddress: string, +) => { + const accountsControllerState = createMockAccountsControllerState( + addresses, + selectedAddress, + ); + const accountIds = addresses.map( + (address) => + accountsControllerState.accountIdByAddress[address.toLowerCase()], + ); + const walletId = 'entropy:wallet1' as const; + const groupId = 'entropy:wallet1/0' as const; + + return { + AccountsController: accountsControllerState, + AccountTreeController: { + accountTree: { + wallets: { + [walletId]: { + id: walletId, + type: AccountWalletType.Entropy as AccountWalletType.Entropy, + metadata: { + name: 'Wallet 1', + entropy: { id: 'wallet1' }, + }, + groups: { + [groupId]: { + id: groupId, + type: AccountGroupType.MultichainAccount as AccountGroupType.MultichainAccount, + accounts: accountIds, + metadata: { + name: 'Account 1', + entropy: { groupIndex: 0 }, + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + }, + }, + }, + selectedAccountGroup: groupId, + }, + }; +}; + const mockTransactions = [ { id: 'fill-1', @@ -739,16 +789,17 @@ describe('PerpsTransactionsView', () => { }); it('computes and passes accountId when address and chainId are available', async () => { + const accountState = createMockSelectedAccountGroupState( + [mockSelectedAddress], + mockSelectedAddress, + ); const stateWithAccount = { ...mockInitialState, engine: { ...mockInitialState.engine, backgroundState: { ...backgroundState, - AccountsController: createMockAccountsControllerState( - [mockSelectedAddress], - mockSelectedAddress, - ), + ...accountState, NetworkController: mockNetworkState({ chainId: mockChainId, id: 'arbitrum', @@ -867,6 +918,10 @@ describe('PerpsTransactionsView', () => { const secondAddress = '0x9876543210987654321098765432109876543210'; const secondAccountId = 'eip155:42161:0x9876543210987654321098765432109876543210' as CaipAccountId; + const accountState = createMockSelectedAccountGroupState( + [secondAddress], + secondAddress, + ); const stateWithSecondAddress = { ...mockInitialState, @@ -874,10 +929,7 @@ describe('PerpsTransactionsView', () => { ...mockInitialState.engine, backgroundState: { ...backgroundState, - AccountsController: createMockAccountsControllerState( - [secondAddress], - secondAddress, - ), + ...accountState, NetworkController: mockNetworkState({ chainId: mockChainId, id: 'arbitrum', @@ -913,6 +965,10 @@ describe('PerpsTransactionsView', () => { const secondChainId = '0x1'; // Ethereum mainnet (1) const secondAccountId = 'eip155:1:0x1234567890123456789012345678901234567890' as CaipAccountId; + const accountState = createMockSelectedAccountGroupState( + [mockSelectedAddress], + mockSelectedAddress, + ); const stateWithSecondChainId = { ...mockInitialState, @@ -920,10 +976,7 @@ describe('PerpsTransactionsView', () => { ...mockInitialState.engine, backgroundState: { ...backgroundState, - AccountsController: createMockAccountsControllerState( - [mockSelectedAddress], - mockSelectedAddress, - ), + ...accountState, NetworkController: mockNetworkState({ chainId: secondChainId, id: 'mainnet', diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx index 3a97cf35e6f..d2fe1a0bd8d 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsTransactionsView.tsx @@ -25,7 +25,7 @@ import { useStyles } from '../../../../../component-library/hooks'; import { TabEmptyState } from '../../../../../component-library/components-temp/TabEmptyState'; import ButtonFilter from '../../../../../component-library/components-temp/ButtonFilter'; import Routes from '../../../../../constants/navigation/Routes'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../../../selectors/accountsController'; +import { selectSelectedAccountGroupEvmInternalAccount } from '../../../../../selectors/multichainAccounts/accountTreeController'; import { selectChainId } from '../../../../../selectors/networkController'; import { formatAccountToCaipAccountId, @@ -70,9 +70,8 @@ const PerpsTransactionsView: React.FC = () => { const { isConnected, isConnecting } = usePerpsConnection(); - const selectedAddress = useSelector( - selectSelectedInternalAccountFormattedAddress, - ); + const evmAccount = useSelector(selectSelectedAccountGroupEvmInternalAccount); + const selectedAddress = evmAccount?.address; const currentChainId = useSelector(selectChainId); const accountId = useMemo(() => { if (!selectedAddress || !currentChainId) { diff --git a/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.test.tsx b/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.test.tsx index f9655ca53e8..0c4be82bcfc 100644 --- a/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.test.tsx @@ -44,7 +44,8 @@ jest.mock('../../../../../component-library/components/Buttons/Button', () => ({ jest.mock('../../hooks/stream', () => ({ usePerpsLiveAccount: jest.fn(() => ({ account: { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', marginUsed: '0.00', unrealizedPnl: '0.00', returnOnEquity: '0.00', @@ -87,7 +88,8 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ // Mock hooks jest.mock('../../hooks', () => ({ usePerpsAccount: jest.fn(() => ({ - availableBalance: '$1000.00', + spendableBalance: '$1000.00', + withdrawableBalance: '$1000.00', })), usePerpsWithdrawQuote: jest.fn(() => ({ formattedQuoteData: { @@ -281,7 +283,8 @@ describe('PerpsWithdrawView', () => { jest.requireMock('../../hooks/stream').usePerpsLiveAccount; mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', marginUsed: '0.00', unrealizedPnl: '0.00', returnOnEquity: '0.00', @@ -308,13 +311,15 @@ describe('PerpsWithdrawView', () => { ).toBeOnTheScreen(); }); - it('uses availableToTradeBalance for the displayed Unified Account balance', () => { + it('uses withdrawableBalance for the displayed Unified Account balance', () => { const mockUsePerpsLiveAccount = jest.requireMock('../../hooks/stream').usePerpsLiveAccount; + // Diverge spendable from withdrawable so the assertion proves the view + // reads `withdrawableBalance` specifically (not `spendableBalance`). mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '0.00', - availableToTradeBalance: '2500.00', + spendableBalance: '0.00', + withdrawableBalance: '2500.00', marginUsed: '0.00', unrealizedPnl: '0.00', returnOnEquity: '0.00', diff --git a/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx b/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx index bde50cae4b5..77ebbca0194 100644 --- a/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx +++ b/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.tsx @@ -17,8 +17,8 @@ import { Button, ButtonVariant, ButtonSize, + HeaderStandard, } from '@metamask/design-system-react-native'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { PerpsWithdrawViewSelectorsIDs } from '../../Perps.testIds'; import { strings } from '../../../../../../locales/i18n'; @@ -106,23 +106,20 @@ const PerpsWithdrawView: React.FC = () => { // Get withdrawal tokens from hook const { destToken } = useWithdrawTokens(); - // Release-branch bridge for Unified Account: availableToTradeBalance includes - // collateral HL can use in target mode. The full balance contract will replace - // this with an explicit withdrawableBalance field. Truncate so users can - // withdraw exactly the amount they see. - const availableBalance = useMemo(() => { - const balance = - account?.availableToTradeBalance ?? account?.availableBalance; - if (!balance) return 0; - return truncateToTwoDecimals(parseCurrencyString(balance)); - }, [account?.availableBalance, account?.availableToTradeBalance]); + // Truncate to 2 decimals so the user can withdraw exactly what they see. + const withdrawableBalance = useMemo(() => { + if (!account?.withdrawableBalance) return 0; + return truncateToTwoDecimals( + parseCurrencyString(account.withdrawableBalance), + ); + }, [account?.withdrawableBalance]); const formattedBalance = useMemo( - () => formatPerpsFiat(availableBalance), - [availableBalance], + () => formatPerpsFiat(withdrawableBalance), + [withdrawableBalance], ); - const hasPositiveBalance = availableBalance > 0; + const hasPositiveBalance = withdrawableBalance > 0; // Get withdrawal validation const { @@ -157,9 +154,9 @@ const PerpsWithdrawView: React.FC = () => { usePerpsMeasurement({ traceName: TraceName.PerpsWithdrawView, conditions: [ - !!(account?.availableToTradeBalance ?? account?.availableBalance), + !!account?.withdrawableBalance, !!destToken, - availableBalance !== undefined, + withdrawableBalance !== undefined, ], }); @@ -218,7 +215,7 @@ const PerpsWithdrawView: React.FC = () => { (percentage: number) => { if (!hasPositiveBalance) return; - const amount = availableBalance * percentage; + const amount = withdrawableBalance * percentage; // Format to 2 or 6 decimal places for USDC let formattedAmount = '0'; if (amount < 0.01) { @@ -238,10 +235,10 @@ const PerpsWithdrawView: React.FC = () => { DevLogger.log( `Percentage selected: ${ percentage * 100 - }%, Amount: ${formattedAmount}, Available Perps Balance: ${availableBalance}`, + }%, Amount: ${formattedAmount}, Withdrawable Balance: ${withdrawableBalance}`, ); }, - [availableBalance, hasPositiveBalance], + [withdrawableBalance, hasPositiveBalance], ); const handleMaxPress = useCallback(() => { @@ -374,7 +371,7 @@ const PerpsWithdrawView: React.FC = () => { {/* Header */} - { jest.mock('../../hooks/stream/usePerpsLiveAccount', () => ({ usePerpsLiveAccount: jest.fn(() => ({ account: { - availableBalance: '1000', + spendableBalance: '1000', + withdrawableBalance: '1000', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', diff --git a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx index 1e84531fc34..68328fc0a55 100644 --- a/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx +++ b/app/components/UI/Perps/components/PerpsBottomSheetTooltip/PerpsBottomSheetTooltip.tsx @@ -1,10 +1,10 @@ /* eslint-disable jsdoc/check-indentation */ import React, { useRef, useCallback, useMemo } from 'react'; +import { HeaderStandard } from '@metamask/design-system-react-native'; import { View } from 'react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import BottomSheetFooter, { ButtonsAlignment, } from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; @@ -147,7 +147,7 @@ const PerpsBottomSheetTooltip = React.memo( testID={testID} > {!hasCustomHeader && ( - ({ jest.mock('../../utils/formatUtils', () => ({ formatPositionSize: jest.fn((value) => value), formatPerpsFiat: jest.fn((value) => `$${value.toFixed(2)}`), - PRICE_RANGES_MINIMAL_VIEW: {}, + PRICE_RANGES_UNIVERSAL: [{ id: 'universal' }], })); jest.mock('@metamask/perps-controller', () => ({ @@ -157,6 +157,17 @@ describe('PerpsCompactOrderRow', () => { expect(formatPerpsFiat).toHaveBeenCalledWith(50000, expect.any(Object)); }); + it('formats price with PRICE_RANGES_UNIVERSAL for market-appropriate decimals', () => { + const { formatPerpsFiat, PRICE_RANGES_UNIVERSAL } = jest.requireMock( + '../../utils/formatUtils', + ); + render(); + + expect(formatPerpsFiat).toHaveBeenCalledWith(50000, { + ranges: PRICE_RANGES_UNIVERSAL, + }); + }); + it('calls onPress when tapped', () => { const mockOnPress = jest.fn(); render( diff --git a/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.tsx b/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.tsx index 5af651d3e70..4c8d63dc43a 100644 --- a/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.tsx +++ b/app/components/UI/Perps/components/PerpsCompactOrderRow/PerpsCompactOrderRow.tsx @@ -8,7 +8,7 @@ import { useStyles } from '../../../../../component-library/hooks'; import { formatPositionSize, formatPerpsFiat, - PRICE_RANGES_MINIMAL_VIEW, + PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; import { getPerpsDisplaySymbol, type Order } from '@metamask/perps-controller'; import { strings } from '../../../../../../locales/i18n'; @@ -49,7 +49,7 @@ const PerpsCompactOrderRow: React.FC = ({ const formattedPrice = priceValue !== null ? formatPerpsFiat(priceValue, { - ranges: PRICE_RANGES_MINIMAL_VIEW, + ranges: PRICE_RANGES_UNIVERSAL, }) : strings('perps.order.market'); diff --git a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx index 9df9c7e333e..d194e6944eb 100644 --- a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.test.tsx @@ -314,7 +314,8 @@ const mockUseConfirmNavigation = useConfirmNavigation as jest.Mock; describe('PerpsMarketBalanceActions', () => { const defaultPerpsAccount = { totalBalance: '10.57', - availableBalance: '10.57', + spendableBalance: '10.57', + withdrawableBalance: '10.57', marginUsed: '0.00', totalUSDBalance: 10.57, positions: [], @@ -513,7 +514,8 @@ describe('PerpsMarketBalanceActions', () => { account: { ...defaultPerpsAccount, totalBalance: '15.50', - availableBalance: '15.50', + spendableBalance: '15.50', + withdrawableBalance: '15.50', }, isInitialLoading: false, isLoading: false, @@ -579,7 +581,8 @@ describe('PerpsMarketBalanceActions', () => { account: { ...defaultPerpsAccount, totalBalance: '0', - availableBalance: '0', + spendableBalance: '0', + withdrawableBalance: '0', }, isInitialLoading: false, isLoading: false, @@ -620,13 +623,49 @@ describe('PerpsMarketBalanceActions', () => { }); describe('Edge Cases', () => { + it('shows funded UI when spendableBalance is 0 but totalBalance > 0 (collateral locked in open positions)', () => { + // Arrange — account with all equity in margin: spendable=0, total>0 + mockUsePerpsLiveAccount.mockReturnValue({ + account: { + ...defaultPerpsAccount, + totalBalance: '125.00', + spendableBalance: '0', + withdrawableBalance: '0', + marginUsed: '125.00', + }, + isInitialLoading: false, + isLoading: false, + error: null, + }); + + // Act + const { getByTestId, getByText } = renderWithProvider( + , + { state: createMockState() }, + false, + ); + + // Assert — funded UI: real totalBalance + Withdraw + Add Funds (no $0 empty state) + expect( + getByTestId(PerpsMarketBalanceActionsSelectorsIDs.BALANCE_VALUE), + ).toBeOnTheScreen(); + expect(getByText('$125.00')).toBeOnTheScreen(); + expect( + getByTestId(PerpsMarketBalanceActionsSelectorsIDs.WITHDRAW_BUTTON), + ).toBeOnTheScreen(); + expect( + getByTestId(PerpsMarketBalanceActionsSelectorsIDs.ADD_FUNDS_BUTTON), + ).toBeOnTheScreen(); + }); + it('shows empty state when balance is zero', () => { // Arrange mockUsePerpsLiveAccount.mockReturnValue({ account: { ...defaultPerpsAccount, totalBalance: '0.00', - availableBalance: '0.00', + spendableBalance: '0.00', + withdrawableBalance: '0.00', }, isInitialLoading: false, isLoading: false, diff --git a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx index 354265ac7e9..e9c35e24a66 100644 --- a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx +++ b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx @@ -84,7 +84,17 @@ const PerpsMarketBalanceActions: React.FC = ({ }); const totalBalance = perpsAccount?.totalBalance || '0'; - const isBalanceEmpty = BigNumber(totalBalance).isZero(); + const spendableBalance = perpsAccount?.spendableBalance || '0'; + // "Empty" gates on totalBalance — venue equity. Accounts with all collateral + // tied up in open positions have spendableBalance = 0 but totalBalance > 0; + // they are funded users who should see the normal balance + Withdraw/Add Funds + // surface, not the $0 empty state. + // + // During loading, totalBalance may carry a sentinel string + // (PERPS_CONSTANTS.FallbackDataDisplay = '--'). Treat non-finite parses + // as empty so skeleton / empty-state renders until real data lands. + const totalBn = BigNumber(totalBalance); + const isBalanceEmpty = !totalBn.isFinite() || totalBn.isZero(); // Use hook for eligibility checks and action handlers // Determine button location based on whether balance is empty (empty state) or not (home) @@ -179,14 +189,6 @@ const PerpsMarketBalanceActions: React.FC = ({ [stopBalanceAnimation], ); - // Order-entry surface reads availableToTradeBalance (withdrawable + - // unreserved spot collateral). Withdraw surfaces keep reading - // availableBalance directly. - const availableBalance = - perpsAccount?.availableToTradeBalance ?? - perpsAccount?.availableBalance ?? - '0'; - // Show skeleton while loading initial account data if (isInitialLoading) { return ; @@ -268,7 +270,7 @@ const PerpsMarketBalanceActions: React.FC = ({ isHidden={privacyMode} length={SensitiveTextLength.Short} > - {formatPerpsBalance(availableBalance)} + {formatPerpsBalance(spendableBalance)} {' '} diff --git a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.test.tsx b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.test.tsx index 09c629bd4f4..647448555c4 100644 --- a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.test.tsx @@ -20,8 +20,10 @@ jest.mock('../../../../../../locales/i18n', () => ({ })); jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); const { View, Text: RNText } = jest.requireActual('react-native'); return { + ...actual, Box: View, Text: RNText, BoxFlexDirection: { Row: 'row' }, @@ -89,55 +91,6 @@ jest.mock('../../../../../component-library/hooks', () => ({ }), })); -jest.mock( - '../../../../../component-library/components-temp/HeaderCompactStandard', - () => { - const { View, Text, TouchableOpacity } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - title, - onBack, - endButtonIconProps, - testID, - }: { - title: string; - onBack: () => void; - endButtonIconProps?: { - iconName: string; - onPress: () => void; - testID?: string; - }[]; - testID?: string; - }) => ( - - - Back - - {title} - {endButtonIconProps?.map( - ( - props: { iconName: string; onPress: () => void; testID?: string }, - index: number, - ) => ( - - {props.iconName} - - ), - )} - - ), - }; - }, -); - describe('PerpsMarketListHeader', () => { const mockGoBack = jest.fn(); const mockCanGoBack = jest.fn(); diff --git a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx index 7a40e2f6442..d08ccbde34f 100644 --- a/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx +++ b/app/components/UI/Perps/components/PerpsTabControlBar/PerpsTabControlBar.test.tsx @@ -68,7 +68,8 @@ jest.mock('../../hooks/stream', () => ({ usePerpsLiveAccount: jest.fn(() => ({ account: { totalBalance: '10000.00', - availableBalance: '1000.50', + spendableBalance: '1000.50', + withdrawableBalance: '1000.50', marginUsed: '9000.00', unrealizedPnl: '100.50', returnOnEquity: '0.15', @@ -159,7 +160,8 @@ describe('PerpsTabControlBar', () => { // Default mock return values const defaultAccountState = { totalBalance: '1000.50', - availableBalance: '800.25', + spendableBalance: '800.25', + withdrawableBalance: '800.25', marginUsed: '200.25', unrealizedPnl: '50.75', }; diff --git a/app/components/UI/Perps/hooks/stream/hasCachedPerpsData.test.ts b/app/components/UI/Perps/hooks/stream/hasCachedPerpsData.test.ts index e4a2e07eacc..222e759e649 100644 --- a/app/components/UI/Perps/hooks/stream/hasCachedPerpsData.test.ts +++ b/app/components/UI/Perps/hooks/stream/hasCachedPerpsData.test.ts @@ -81,7 +81,7 @@ describe('hasPreloadedData', () => { mockCachedUserDataForActiveProvider = { positions: [], orders: [], - accountState: { availableBalance: '1000' }, + accountState: { spendableBalance: '1000' }, }; expect(hasPreloadedData('cachedAccountState')).toBe(true); }); @@ -162,7 +162,7 @@ describe('getPreloadedData', () => { }); it('returns cached accountState when available', () => { - const accountState = { availableBalance: '1000' }; + const accountState = { spendableBalance: '1000' }; mockCachedUserDataForActiveProvider = { positions: [], orders: [], diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLiveAccount.test.ts b/app/components/UI/Perps/hooks/stream/usePerpsLiveAccount.test.ts index c72041558c3..01c158f0e62 100644 --- a/app/components/UI/Perps/hooks/stream/usePerpsLiveAccount.test.ts +++ b/app/components/UI/Perps/hooks/stream/usePerpsLiveAccount.test.ts @@ -74,7 +74,8 @@ describe('usePerpsLiveAccount', () => { describe('state from PerpsController', () => { it('returns account state from the channel snapshot before controller cache', () => { const mockAccountState: AccountState = { - availableBalance: '7000', + spendableBalance: '7000', + withdrawableBalance: '7000', marginUsed: '500', unrealizedPnl: '25', returnOnEquity: '1.0', @@ -96,7 +97,8 @@ describe('usePerpsLiveAccount', () => { it('returns account state from PerpsController', () => { const mockAccountState: AccountState = { - availableBalance: '3000', + spendableBalance: '3000', + withdrawableBalance: '3000', marginUsed: '1000', unrealizedPnl: '50', returnOnEquity: '5.0', @@ -121,7 +123,8 @@ describe('usePerpsLiveAccount', () => { it('handles zero balance account state', () => { const mockAccountState: AccountState = { - availableBalance: '0', + spendableBalance: '0', + withdrawableBalance: '0', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -143,7 +146,7 @@ describe('usePerpsLiveAccount', () => { account: mockAccountState, isInitialLoading: false, }); - expect(result.current?.account?.availableBalance).toBe('0'); + expect(result.current?.account?.spendableBalance).toBe('0'); expect(result.current?.account?.totalBalance).toBe('0'); }); }); @@ -151,7 +154,8 @@ describe('usePerpsLiveAccount', () => { describe('partial state handling', () => { it('handles partial account state', () => { const partialAccountState: AccountState = { - availableBalance: '100', + spendableBalance: '100', + withdrawableBalance: '100', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -168,7 +172,7 @@ describe('usePerpsLiveAccount', () => { state: {}, }); - expect(result.current?.account?.availableBalance).toBe('100'); + expect(result.current?.account?.spendableBalance).toBe('100'); expect(result.current?.account?.totalBalance).toBe('200'); }); @@ -190,7 +194,8 @@ describe('usePerpsLiveAccount', () => { describe('account state scenarios', () => { it('handles account with positive PnL', () => { const positivePnlState: AccountState = { - availableBalance: '5000', + spendableBalance: '5000', + withdrawableBalance: '5000', marginUsed: '1000', unrealizedPnl: '500', returnOnEquity: '50.0', @@ -213,7 +218,8 @@ describe('usePerpsLiveAccount', () => { it('handles account with negative PnL', () => { const negativePnlState: AccountState = { - availableBalance: '3000', + spendableBalance: '3000', + withdrawableBalance: '3000', marginUsed: '2000', unrealizedPnl: '-500', returnOnEquity: '-25.0', @@ -236,7 +242,8 @@ describe('usePerpsLiveAccount', () => { it('handles account with high margin usage', () => { const highMarginState: AccountState = { - availableBalance: '500', + spendableBalance: '500', + withdrawableBalance: '500', marginUsed: '9500', unrealizedPnl: '0', returnOnEquity: '0', @@ -255,12 +262,13 @@ describe('usePerpsLiveAccount', () => { expect(result.current?.account?.marginUsed).toBe('9500'); expect(Number(result.current?.account?.marginUsed)).toBe(9500); - expect(Number(result.current?.account?.availableBalance)).toBe(500); + expect(Number(result.current?.account?.spendableBalance)).toBe(500); }); it('handles account with no margin used', () => { const noMarginState: AccountState = { - availableBalance: '10000', + spendableBalance: '10000', + withdrawableBalance: '10000', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -278,7 +286,7 @@ describe('usePerpsLiveAccount', () => { }); expect(result.current?.account?.marginUsed).toBe('0'); - expect(result.current?.account?.availableBalance).toBe( + expect(result.current?.account?.spendableBalance).toBe( result.current?.account?.totalBalance, ); }); @@ -287,7 +295,8 @@ describe('usePerpsLiveAccount', () => { describe('initial state from cache', () => { it('seeds account from cache when fresh cached data exists', () => { const cachedAccount: AccountState = { - availableBalance: '5000', + spendableBalance: '5000', + withdrawableBalance: '5000', marginUsed: '2000', unrealizedPnl: '100', returnOnEquity: '2.0', @@ -348,7 +357,8 @@ describe('usePerpsLiveAccount', () => { describe('memoization', () => { it('returns same reference for same state', () => { const mockAccountState: AccountState = { - availableBalance: '1000', + spendableBalance: '1000', + withdrawableBalance: '1000', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', diff --git a/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.test.ts b/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.test.ts index 92a4480c89a..c72ae480c75 100644 --- a/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.test.ts +++ b/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.test.ts @@ -18,7 +18,7 @@ const mockUsePerpsPaymentTokens = jest.requireMock< function getState( overrides: { - perpsAccount?: { availableBalance: string } | null; + perpsAccount?: { spendableBalance: string } | null; allowlistAssets?: string[]; isTestnet?: boolean; activeProvider?: 'hyperliquid' | 'myx' | 'aggregated'; @@ -26,7 +26,7 @@ function getState( } = {}, ) { const { - perpsAccount = { availableBalance: '0' }, + perpsAccount = { spendableBalance: '0' }, allowlistAssets = [], isTestnet = false, activeProvider, @@ -82,7 +82,7 @@ describe('useDefaultPayWithTokenWhenNoPerpsBalance', () => { const { result } = runHook( getState({ - perpsAccount: { availableBalance: '100' }, + perpsAccount: { spendableBalance: '100' }, allowlistAssets: ['0xa4b1.0xusdc'], }), ); diff --git a/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.ts b/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.ts index 77896bb1e0f..ff98b88a066 100644 --- a/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.ts +++ b/app/components/UI/Perps/hooks/useDefaultPayWithTokenWhenNoPerpsBalance.ts @@ -39,13 +39,11 @@ export function useDefaultPayWithTokenWhenNoPerpsBalance(): PerpsSelectedPayment if (!featureEnabled) { return null; } - const tradeableBalance = Number.parseFloat( - perpsAccount?.availableToTradeBalance?.toString() ?? - perpsAccount?.availableBalance?.toString() ?? - '0', + const spendableBalance = Number.parseFloat( + perpsAccount?.spendableBalance?.toString() ?? '0', ); - if (tradeableBalance > PERPS_MIN_BALANCE_THRESHOLD) { + if (spendableBalance > PERPS_MIN_BALANCE_THRESHOLD) { return null; } if (!allowlistAssets?.length) { @@ -94,8 +92,7 @@ export function useDefaultPayWithTokenWhenNoPerpsBalance(): PerpsSelectedPayment }; }, [ featureEnabled, - perpsAccount?.availableBalance, - perpsAccount?.availableToTradeBalance, + perpsAccount?.spendableBalance, allowlistAssets, activeProvider, currentNetwork, diff --git a/app/components/UI/Perps/hooks/usePerpsAdjustMarginData.test.ts b/app/components/UI/Perps/hooks/usePerpsAdjustMarginData.test.ts index b4c6c404206..fc00c883fa1 100644 --- a/app/components/UI/Perps/hooks/usePerpsAdjustMarginData.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsAdjustMarginData.test.ts @@ -53,7 +53,8 @@ describe('usePerpsAdjustMarginData', () => { }; const mockAccount = { - availableBalance: '10000', + spendableBalance: '10000', + withdrawableBalance: '10000', totalBalance: '15000', marginUsed: '5000', unrealizedPnl: '500', @@ -178,7 +179,7 @@ describe('usePerpsAdjustMarginData', () => { }), ); - expect(result.current.availableBalance).toBe(10000); + expect(result.current.spendableBalance).toBe(10000); }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsAdjustMarginData.ts b/app/components/UI/Perps/hooks/usePerpsAdjustMarginData.ts index b715a724ba6..d4f988e4560 100644 --- a/app/components/UI/Perps/hooks/usePerpsAdjustMarginData.ts +++ b/app/components/UI/Perps/hooks/usePerpsAdjustMarginData.ts @@ -43,7 +43,7 @@ export interface UsePerpsAdjustMarginDataReturn { /** New liquidation distance percentage */ newLiquidationDistance: number; /** Available balance for add mode */ - availableBalance: number; + spendableBalance: number; /** Current market price */ currentPrice: number; /** Whether this is add mode */ @@ -129,8 +129,8 @@ export function usePerpsAdjustMarginData( [livePrices, symbol], ); - const availableBalance = useMemo( - () => parseFloat(account?.availableBalance || '0'), + const spendableBalance = useMemo( + () => parseFloat(account?.spendableBalance || '0'), [account], ); @@ -139,7 +139,7 @@ export function usePerpsAdjustMarginData( // Calculate max removable/addable amount const maxAmount = useMemo(() => { if (isAddMode) { - return Math.max(0, availableBalance); + return Math.max(0, spendableBalance); } return calculateMaxRemovableMargin({ currentMargin, @@ -151,7 +151,7 @@ export function usePerpsAdjustMarginData( }); }, [ isAddMode, - availableBalance, + spendableBalance, currentMargin, positionSize, entryPrice, @@ -221,7 +221,7 @@ export function usePerpsAdjustMarginData( newLiquidationPrice, currentLiquidationDistance, newLiquidationDistance, - availableBalance, + spendableBalance, currentPrice, isAddMode, positionLeverage, diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts index 3f9ca045db2..33613b35b3e 100644 --- a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts @@ -85,7 +85,7 @@ describe('usePerpsBalanceTokenFilter', () => { mockUseSelector.mockImplementation( (selector: (state: unknown) => unknown) => { if (selector === selectPerpsAccountState) { - return { availableBalance: '1500.00' }; + return { spendableBalance: '1500.00' }; } if (selector === selectPerpsPayWithAnyTokenAllowlistAssets) { return []; @@ -153,7 +153,8 @@ describe('usePerpsBalanceTokenFilter', () => { it('prepends highlighted row with perps balance and Add funds button', () => { mockUseSelector.mockReturnValue({ - availableBalance: '2000.50', + spendableBalance: '2000.50', + withdrawableBalance: '2000.50', }); mockUseIsPerpsBalanceSelected.mockReturnValue(true); const inputTokens: AssetType[] = [ @@ -190,9 +191,10 @@ describe('usePerpsBalanceTokenFilter', () => { expect((output[1] as AssetType).address).toBe('0xusdc'); }); - it('uses availableBalance from perps account in highlighted row', () => { + it('uses spendableBalance from perps account in highlighted row', () => { mockUseSelector.mockReturnValue({ - availableBalance: '999.99', + spendableBalance: '999.99', + withdrawableBalance: '999.99', }); const inputTokens: AssetType[] = []; @@ -207,14 +209,14 @@ describe('usePerpsBalanceTokenFilter', () => { } }); - it('prefers availableToTradeBalance for Unified Account users', () => { - // Unified Account / Portfolio Margin: collateral lives in spot, so - // HL's `clearinghouseState.withdrawable` (mirrored as availableBalance) - // is $0. The synthetic Perps balance row in the Pay-with sheet must - // read the unified-aware `availableToTradeBalance` instead. + it('uses spendableBalance for Unified Account users', () => { + // Unified Account / Portfolio Margin: collateral lives in spot. The + // provider folds free spot USDC into spendableBalance via + // addSpotBalanceToAccountState, so the Pay-with sheet's synthetic + // Perps row sees the unified total without branching on mode. mockUseSelector.mockReturnValue({ - availableBalance: '0.00', - availableToTradeBalance: '2500.00', + spendableBalance: '2500.00', + withdrawableBalance: '2500.00', }); const inputTokens: AssetType[] = []; @@ -254,7 +256,7 @@ describe('usePerpsBalanceTokenFilter', () => { mockUseSelector.mockImplementation( (selector: (state: unknown) => unknown) => { if (selector === selectPerpsAccountState) - return { availableBalance: '1500.00' }; + return { spendableBalance: '1500.00' }; if (selector === selectPerpsPayWithAnyTokenAllowlistAssets) return []; return undefined; }, @@ -286,7 +288,7 @@ describe('usePerpsBalanceTokenFilter', () => { mockUseSelector.mockImplementation( (selector: (state: unknown) => unknown) => { if (selector === selectPerpsAccountState) - return { availableBalance: '1500.00' }; + return { spendableBalance: '1500.00' }; if (selector === selectPerpsPayWithAnyTokenAllowlistAssets) return []; return undefined; }, @@ -312,7 +314,7 @@ describe('usePerpsBalanceTokenFilter', () => { mockUseSelector.mockImplementation( (selector: (state: unknown) => unknown) => { if (selector === selectPerpsAccountState) - return { availableBalance: '100.00' }; + return { spendableBalance: '100.00' }; if (selector === selectPerpsPayWithAnyTokenAllowlistAssets) return [allowlistKey]; return []; @@ -346,7 +348,8 @@ describe('usePerpsBalanceTokenFilter', () => { it('calls onReject, depositWithConfirmation and navigation.navigate when Add funds is pressed', async () => { mockUseSelector.mockReturnValue({ - availableBalance: '500.00', + spendableBalance: '500.00', + withdrawableBalance: '500.00', }); const inputTokens: AssetType[] = [ { @@ -382,7 +385,8 @@ describe('usePerpsBalanceTokenFilter', () => { it('calls onPerpsPaymentTokenChange with null when row action is invoked', () => { mockUseSelector.mockReturnValue({ - availableBalance: '100.00', + spendableBalance: '100.00', + withdrawableBalance: '100.00', }); const inputTokens: AssetType[] = []; diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts index 2e4a5a0ee57..86ee09a367b 100644 --- a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts @@ -83,16 +83,9 @@ export function usePerpsBalanceTokenFilter(): ( return tokens; } - // Prefer `availableToTradeBalance` so Unified Account / Portfolio - // Margin users see their real spendable balance in the Pay-with - // header — `availableBalance` mirrors HL's perps-only - // `clearinghouseState.withdrawable`, which is $0 in unified mode. - const availableBalance = - perpsAccount?.availableToTradeBalance ?? - perpsAccount?.availableBalance ?? - '0'; + const spendableBalance = perpsAccount?.spendableBalance || '0'; const balanceInSelectedCurrency = formatFiat( - new BigNumber(availableBalance), + new BigNumber(spendableBalance), ); const perpsBalanceName = strings('perps.adjust_margin.perps_balance'); @@ -141,8 +134,7 @@ export function usePerpsBalanceTokenFilter(): ( formatFiat, onPerpsPaymentTokenChange, isPerpsBalanceSelected, - perpsAccount?.availableBalance, - perpsAccount?.availableToTradeBalance, + perpsAccount?.spendableBalance, transactionMeta, ], ); diff --git a/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts index 1a7fa9fbafd..73b36a7c1b8 100644 --- a/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.test.ts @@ -95,7 +95,9 @@ describe('usePerpsCloseAllCalculations', () => { mockUseSelector.mockImplementation(() => { selectorCallCount++; if (selectorCallCount % 2 === 1) { - return '0x1234567890123456789012345678901234567890'; // selectedAddress + return { + address: '0x1234567890123456789012345678901234567890', + }; // selected account group EVM account } return '0xa4b1'; // chainId }); diff --git a/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts index b7407aaf2ae..ba9cc6af95f 100644 --- a/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts +++ b/app/components/UI/Perps/hooks/usePerpsCloseAllCalculations.ts @@ -10,7 +10,7 @@ import type { EstimatedPointsDto, } from '../../../../core/Engine/controllers/rewards-controller/types'; import Engine from '../../../../core/Engine'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../../selectors/accountsController'; +import { selectSelectedAccountGroupEvmInternalAccount } from '../../../../selectors/multichainAccounts/accountTreeController'; import { selectChainId } from '../../../../selectors/networkController'; /** @@ -93,9 +93,8 @@ export function usePerpsCloseAllCalculations({ priceData, }: UsePerpsCloseAllCalculationsParams): CloseAllCalculationsResult { // Selectors for account and chain - const selectedAddress = useSelector( - selectSelectedInternalAccountFormattedAddress, - ); + const evmAccount = useSelector(selectSelectedAccountGroupEvmInternalAccount); + const selectedAddress = evmAccount?.address; const currentChainId = useSelector(selectChainId); // Use ref to access latest priceData without triggering re-renders diff --git a/app/components/UI/Perps/hooks/usePerpsDepositProgress.test.ts b/app/components/UI/Perps/hooks/usePerpsDepositProgress.test.ts index b427ed9cc9c..8acb5044581 100644 --- a/app/components/UI/Perps/hooks/usePerpsDepositProgress.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsDepositProgress.test.ts @@ -36,7 +36,8 @@ describe('usePerpsDepositProgress', () => { // Default mock for usePerpsLiveAccount mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', marginUsed: '9000.00', unrealizedPnl: '100.00', returnOnEquity: '0.15', @@ -208,7 +209,8 @@ describe('usePerpsDepositProgress', () => { // Act - Update balance to simulate deposit completion mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '1500.00', // Increased from 1000.00 + spendableBalance: '1500.00', // Increased from 1000.00 + withdrawableBalance: '1500.00', // Increased from 1000.00 marginUsed: '9000.00', unrealizedPnl: '100.00', returnOnEquity: '0.15', @@ -223,6 +225,44 @@ describe('usePerpsDepositProgress', () => { expect(result.current.isDepositInProgress).toBe(false); }); + it('does not clear deposit in progress when only totalBalance increases from unrealized pnl', () => { + const { result, rerender } = renderHook(() => usePerpsDepositProgress()); + + act(() => { + const transactionHandler = mockSubscribe.mock.calls.find( + (call) => + call[0] === 'TransactionController:transactionStatusUpdated', + )?.[1]; + if (transactionHandler) { + transactionHandler({ + transactionMeta: { + id: 'test-tx-id', + type: TransactionType.perpsDeposit, + status: TransactionStatus.approved, + } as TransactionMeta, + }); + } + }); + + expect(result.current.isDepositInProgress).toBe(true); + + mockUsePerpsLiveAccount.mockReturnValue({ + account: { + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '9000.00', + unrealizedPnl: '600.00', + returnOnEquity: '0.15', + totalBalance: '10600.00', + }, + isInitialLoading: false, + }); + + rerender({}); + + expect(result.current.isDepositInProgress).toBe(true); + }); + it('does not clear deposit in progress when balance decreases', () => { // Arrange const { result, rerender } = renderHook(() => usePerpsDepositProgress()); @@ -249,7 +289,8 @@ describe('usePerpsDepositProgress', () => { // Act - Update balance to simulate decrease mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '500.00', // Decreased from 1000.00 + spendableBalance: '500.00', // Decreased from 1000.00 + withdrawableBalance: '500.00', // Decreased from 1000.00 marginUsed: '9000.00', unrealizedPnl: '100.00', returnOnEquity: '0.15', @@ -290,7 +331,8 @@ describe('usePerpsDepositProgress', () => { // Act - Update balance to same value mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '1000.00', // Same as initial + spendableBalance: '1000.00', // Same as initial + withdrawableBalance: '1000.00', // Same as initial marginUsed: '9000.00', unrealizedPnl: '100.00', returnOnEquity: '0.15', @@ -319,11 +361,12 @@ describe('usePerpsDepositProgress', () => { expect(result.current.isDepositInProgress).toBe(false); }); - it('handles undefined availableBalance gracefully', () => { + it('handles undefined spendableBalance gracefully', () => { // Arrange mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '0', + spendableBalance: '0', + withdrawableBalance: '0', marginUsed: '9000.00', unrealizedPnl: '100.00', returnOnEquity: '0.15', @@ -407,7 +450,8 @@ describe('usePerpsDepositProgress', () => { // Act - Small increase in decimal balance mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '1000.01', // Small increase + spendableBalance: '1000.01', // Small increase + withdrawableBalance: '1000.01', // Small increase marginUsed: '9000.00', unrealizedPnl: '100.00', returnOnEquity: '0.15', diff --git a/app/components/UI/Perps/hooks/usePerpsDepositProgress.ts b/app/components/UI/Perps/hooks/usePerpsDepositProgress.ts index 021d06dc84f..d44ff701780 100644 --- a/app/components/UI/Perps/hooks/usePerpsDepositProgress.ts +++ b/app/components/UI/Perps/hooks/usePerpsDepositProgress.ts @@ -20,7 +20,7 @@ export const usePerpsDepositProgress = () => { // Track if we're expecting a deposit const [isDepositInProgress, setIsDepositInProgress] = useState(false); - const prevAvailableBalanceRef = useRef('0'); + const prevSpendableBalanceRef = useRef('0'); const liveAccountRef = useRef(liveAccount); // Update the ref whenever liveAccount changes @@ -46,8 +46,8 @@ export const usePerpsDepositProgress = () => { // Handle PerpsDeposit approved - set deposit in progress if (transactionMeta.status === TransactionStatus.approved) { setIsDepositInProgress(true); - prevAvailableBalanceRef.current = - liveAccountRef.current?.availableBalance || '0'; + prevSpendableBalanceRef.current = + liveAccountRef.current?.spendableBalance || '0'; } // Handle PerpsDeposit failed - clear deposit in progress @@ -69,24 +69,22 @@ export const usePerpsDepositProgress = () => { }; }, []); - // Watch for balance increases when expecting a deposit + // Watch for spendable-balance increases when expecting a deposit. A live + // totalBalance move can be pure unrealized PnL and must not clear progress. + const liveSpendable = liveAccount?.spendableBalance; useEffect(() => { - if (!isDepositInProgress || !liveAccount) { + if (!isDepositInProgress || liveSpendable == null) { return; } - const currentBalance = Number.parseFloat( - liveAccount.availableBalance || '0', - ); - const previousBalance = Number.parseFloat(prevAvailableBalanceRef.current); + const currentBalance = Number.parseFloat(liveSpendable || '0'); + const previousBalance = Number.parseFloat(prevSpendableBalanceRef.current); - // Check if balance increased if (currentBalance > previousBalance) { - // Deposit completed successfully setIsDepositInProgress(false); - prevAvailableBalanceRef.current = liveAccount.availableBalance; + prevSpendableBalanceRef.current = liveSpendable; } - }, [isDepositInProgress, liveAccount]); + }, [isDepositInProgress, liveSpendable]); return { isDepositInProgress }; }; diff --git a/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts b/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts index 4a58332ccd9..b90a4c841ca 100644 --- a/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsDepositStatus.test.ts @@ -99,7 +99,8 @@ describe('usePerpsDepositStatus', () => { // Default mock for usePerpsLiveAccount mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', totalBalance: '10000.00', marginUsed: '9000.00', unrealizedPnl: '100.00', @@ -466,7 +467,8 @@ describe('usePerpsDepositStatus', () => { // Update balance to simulate deposit completion mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '1500.00', // Increased from 1000.00 + spendableBalance: '1500.00', // Increased from 1000.00 + withdrawableBalance: '1500.00', // Increased from 1000.00 marginUsed: '9000.00', unrealizedPnl: '100.00', returnOnEquity: '0.15', @@ -489,7 +491,7 @@ describe('usePerpsDepositStatus', () => { }); expect( mockPerpsToastOptions.accountManagement.deposit.success, - ).toHaveBeenCalledWith('1500.00'); // Current balance + ).toHaveBeenCalledWith('1500.00'); }); it('should not show success toast when balance decreases', () => { @@ -515,7 +517,8 @@ describe('usePerpsDepositStatus', () => { // Update balance to simulate decrease mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '500.00', // Decreased from 1000.00 + spendableBalance: '500.00', // Decreased from 1000.00 + withdrawableBalance: '500.00', // Decreased from 1000.00 marginUsed: '9000.00', unrealizedPnl: '100.00', returnOnEquity: '0.15', @@ -535,7 +538,8 @@ describe('usePerpsDepositStatus', () => { // Update balance without setting up waiting for funds mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '1500.00', + spendableBalance: '1500.00', + withdrawableBalance: '1500.00', marginUsed: '9000.00', unrealizedPnl: '100.00', returnOnEquity: '0.15', @@ -548,6 +552,44 @@ describe('usePerpsDepositStatus', () => { expect(mockShowToast).not.toHaveBeenCalledWith({ success: true }); }); + + it('should not show success toast when only totalBalance increases from unrealized pnl', () => { + const { rerender } = renderHook(() => usePerpsDepositStatus()); + + act(() => { + const transactionHandler = mockSubscribe.mock.calls.find( + (call) => + call[0] === 'TransactionController:transactionStatusUpdated', + )?.[1]; + if (transactionHandler) { + transactionHandler({ + transactionMeta: { + id: 'test-tx-id', + type: TransactionType.perpsDeposit, + status: TransactionStatus.approved, + } as TransactionMeta, + }); + } + }); + + mockUsePerpsLiveAccount.mockReturnValue({ + account: { + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', + marginUsed: '9000.00', + unrealizedPnl: '600.00', + returnOnEquity: '0.15', + totalBalance: '10600.00', + }, + isInitialLoading: false, + }); + + rerender({}); + + expect( + mockPerpsToastOptions.accountManagement.deposit.success, + ).not.toHaveBeenCalled(); + }); }); describe('Controller Result Handling', () => { diff --git a/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts b/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts index ba9fcd245ee..6aae71a93d6 100644 --- a/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts +++ b/app/components/UI/Perps/hooks/usePerpsDepositStatus.ts @@ -6,7 +6,6 @@ import { import { useEffect, useRef } from 'react'; import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; -import { selectTransactionBridgeQuotesById } from '../../../../core/redux/slices/confirmationMetrics'; import type { RootState } from '../../../../reducers'; import { ARBITRUM_MAINNET_CHAIN_ID_HEX, @@ -36,7 +35,13 @@ export const usePerpsDepositStatus = () => { // Track if we're expecting a deposit const expectingDepositRef = useRef(false); - const prevAvailableBalanceRef = useRef('0'); + const prevSpendableBalanceRef = useRef('0'); + const liveAccountRef = useRef(liveAccount); + + // Keep ref in sync without re-subscribing the transaction handler. + useEffect(() => { + liveAccountRef.current = liveAccount; + }, [liveAccount]); // Get deposit state from controller const depositInProgress = useSelector( @@ -49,18 +54,6 @@ export const usePerpsDepositStatus = () => { state.engine.backgroundState.PerpsController?.lastDepositResult ?? null, ); - // Get the internal transaction ID from the controller. Needed to get bridge quotes. - const lastDepositTransactionId = useSelector( - (state: RootState) => - state.engine.backgroundState.PerpsController?.lastDepositTransactionId ?? - null, - ); - - // For Perps deposits this array typically contains only one element. - const bridgeQuotes = useSelector((state: RootState) => - selectTransactionBridgeQuotesById(state, lastDepositTransactionId ?? ''), - ); - // Listen for PerpsDeposit approval - Used to display deposit in progress toast useEffect(() => { const handleTransactionApproved = ({ @@ -79,7 +72,8 @@ export const usePerpsDepositStatus = () => { transactionMeta.status === TransactionStatus.approved ) { expectingDepositRef.current = true; - prevAvailableBalanceRef.current = liveAccount?.availableBalance || '0'; + prevSpendableBalanceRef.current = + liveAccountRef.current?.spendableBalance || '0'; const processingTimeSeconds = isArbUSDCDeposit ? 0 : 60; // hardcoded to 1 minute to avoid estimation failures of multiple bridges @@ -103,37 +97,31 @@ export const usePerpsDepositStatus = () => { handleTransactionApproved, ); }; - }, [ - PerpsToastOptions.accountManagement.deposit, - bridgeQuotes, - liveAccount?.availableBalance, - showToast, - ]); - - // Watch for balance increases when expecting a deposit + // liveAccount.spendableBalance is read via ref to avoid rebinding the + // messenger listener on every balance fluctuation. + }, [PerpsToastOptions.accountManagement.deposit, showToast]); + + // Watch for spendable-balance increases when expecting a deposit. Using + // totalBalance here is incorrect because unrealized PnL can move it without + // any deposit settling. + const liveSpendable = liveAccount?.spendableBalance; useEffect(() => { - if (!expectingDepositRef.current || !liveAccount) { + if (!expectingDepositRef.current || liveSpendable == null) { return; } - const currentBalance = Number.parseFloat( - liveAccount.availableBalance || '0', - ); - const previousBalance = Number.parseFloat(prevAvailableBalanceRef.current); - // Check if balance increased + const currentBalance = Number.parseFloat(liveSpendable || '0'); + const previousBalance = Number.parseFloat(prevSpendableBalanceRef.current); + if (currentBalance > previousBalance) { - // Show success toast showToast( - PerpsToastOptions.accountManagement.deposit.success( - liveAccount?.availableBalance, - ), + PerpsToastOptions.accountManagement.deposit.success(liveSpendable), ); - // Reset state expectingDepositRef.current = false; - prevAvailableBalanceRef.current = liveAccount.availableBalance; + prevSpendableBalanceRef.current = liveSpendable; } - }, [liveAccount, showToast, PerpsToastOptions.accountManagement.deposit]); + }, [liveSpendable, showToast, PerpsToastOptions.accountManagement.deposit]); // Handle deposit errors from controller state useEffect(() => { diff --git a/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts b/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts index 2390f2de6b6..cbaca476250 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketListView.test.ts @@ -305,6 +305,68 @@ describe('usePerpsMarketListView', () => { }); }); + it('defaultSortOptionId overrides the saved sort option and resets direction to default', () => { + let selectorCallCount = 0; + mockUseSelector.mockImplementation(() => { + selectorCallCount++; + if (selectorCallCount % 2 === 1) { + return ['BTC']; + } + // Saved preference is volume/asc — user had it sorted ascending + return { optionId: 'volume', direction: 'asc' }; + }); + + renderHook(() => + usePerpsMarketListView({ defaultSortOptionId: 'priceChange' }), + ); + + // Option overridden → direction must reset to default (desc), not carry 'asc' + expect(mockUsePerpsSorting).toHaveBeenCalledWith({ + initialOptionId: 'priceChange', + initialDirection: 'desc', + }); + }); + + it('preserves saved direction when defaultSortOptionId matches the saved option', () => { + let selectorCallCount = 0; + mockUseSelector.mockImplementation(() => { + selectorCallCount++; + if (selectorCallCount % 2 === 1) { + return ['BTC']; + } + // Saved preference is priceChange/asc + return { optionId: 'priceChange', direction: 'asc' }; + }); + + renderHook(() => + usePerpsMarketListView({ defaultSortOptionId: 'priceChange' }), + ); + + // Same option — carry the saved direction, don't reset + expect(mockUsePerpsSorting).toHaveBeenCalledWith({ + initialOptionId: 'priceChange', + initialDirection: 'asc', + }); + }); + + it('falls back to saved sort preference when defaultSortOptionId is not provided', () => { + let selectorCallCount = 0; + mockUseSelector.mockImplementation(() => { + selectorCallCount++; + if (selectorCallCount % 2 === 1) { + return ['BTC']; + } + return { optionId: 'fundingRate', direction: 'asc' }; + }); + + renderHook(() => usePerpsMarketListView()); + + expect(mockUsePerpsSorting).toHaveBeenCalledWith({ + initialOptionId: 'fundingRate', + initialDirection: 'asc', + }); + }); + it('exposes sort state correctly', () => { const { result } = renderHook(() => usePerpsMarketListView()); diff --git a/app/components/UI/Perps/hooks/usePerpsMarketListView.ts b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts index 6953520563c..8eeb0afff5c 100644 --- a/app/components/UI/Perps/hooks/usePerpsMarketListView.ts +++ b/app/components/UI/Perps/hooks/usePerpsMarketListView.ts @@ -4,6 +4,7 @@ import { usePerpsMarkets } from './usePerpsMarkets'; import { usePerpsSearch } from './usePerpsSearch'; import { usePerpsSorting } from './usePerpsSorting'; import { + MARKET_SORTING_CONFIG, sortMarkets, type PerpsMarketData, type MarketTypeFilter, @@ -33,6 +34,11 @@ interface UsePerpsMarketListViewParams { * @default 'all' */ defaultMarketTypeFilter?: MarketTypeFilter; + /** + * Initial sort option ID — overrides the persisted user preference when provided. + * @default undefined (falls back to saved user preference) + */ + defaultSortOptionId?: SortOptionId; /** * Show markets with $0.00 volume * @default false @@ -133,6 +139,7 @@ export const usePerpsMarketListView = ({ enablePolling = false, showWatchlistOnly = false, defaultMarketTypeFilter = 'all', + defaultSortOptionId, showZeroVolume = false, }: UsePerpsMarketListViewParams = {}): UsePerpsMarketListViewReturn => { // Fetch markets data @@ -196,10 +203,20 @@ export const usePerpsMarketListView = ({ return searchedMarkets; }, [searchedMarkets, marketTypeFilter]); - // Use sorting hook for sort state and sorting logic + // Use sorting hook for sort state and sorting logic. + // defaultSortOptionId (from navigation params) takes precedence over the saved user + // preference. When it overrides a *different* option, reset direction to the default + // so the market list opens sorted the same way the explore feed displayed it (always desc). + // When there is no override, or the override matches the saved option, carry the saved direction. + const isOptionOverridden = + defaultSortOptionId !== undefined && + defaultSortOptionId !== savedSortPreference.optionId; const sortingHook = usePerpsSorting({ - initialOptionId: savedSortPreference.optionId as SortOptionId, - initialDirection: savedSortPreference.direction, + initialOptionId: (defaultSortOptionId ?? + savedSortPreference.optionId) as SortOptionId, + initialDirection: isOptionOverridden + ? MARKET_SORTING_CONFIG.DefaultDirection + : savedSortPreference.direction, }); // Wrap handleOptionChange to save preference to PerpsController diff --git a/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts index 262bd4dd24a..6ab40ec0228 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts @@ -34,6 +34,15 @@ jest.mock('../../../../selectors/accountsController', () => ({ .mockReturnValue('0x1234567890123456789012345678901234567890'), })); +jest.mock( + '../../../../selectors/multichainAccounts/accountTreeController', + () => ({ + selectSelectedAccountGroupEvmInternalAccount: jest.fn().mockReturnValue({ + address: '0x1234567890123456789012345678901234567890', + }), + }), +); + jest.mock('../../../../selectors/networkController', () => ({ selectChainId: jest.fn().mockReturnValue('0xa4b1'), })); diff --git a/app/components/UI/Perps/hooks/usePerpsOrderFees.ts b/app/components/UI/Perps/hooks/usePerpsOrderFees.ts index d6fc28367f2..8da7dca07c0 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderFees.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderFees.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../../selectors/accountsController'; +import { selectSelectedAccountGroupEvmInternalAccount } from '../../../../selectors/multichainAccounts/accountTreeController'; import { selectChainId } from '../../../../selectors/networkController'; import { setMeasurement } from '@sentry/react-native'; @@ -112,9 +112,8 @@ export function usePerpsOrderFees({ currentBidPrice, }: UsePerpsOrderFeesParams): OrderFeesResult { const { calculateFees } = usePerpsTrading(); - const selectedAddress = useSelector( - selectSelectedInternalAccountFormattedAddress, - ); + const evmAccount = useSelector(selectSelectedAccountGroupEvmInternalAccount); + const selectedAddress = evmAccount?.address; const currentChainId = useSelector(selectChainId); const isMaker = useMemo(() => { diff --git a/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts index d4cf235ba96..57e29bb8382 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderForm.test.ts @@ -142,7 +142,8 @@ describe('usePerpsOrderForm', () => { mockUsePerpsNetwork.mockReturnValue('mainnet'); mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '1000', + spendableBalance: '1000', + withdrawableBalance: '1000', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -426,7 +427,8 @@ describe('usePerpsOrderForm', () => { // Arrange - Set low available balance mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '2', // $2 available balance + spendableBalance: '2', // $2 available balance + withdrawableBalance: '2', // $2 available balance marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -450,7 +452,8 @@ describe('usePerpsOrderForm', () => { // Arrange - Set sufficient available balance mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '5', // $5 available balance + spendableBalance: '5', // $5 available balance + withdrawableBalance: '5', // $5 available balance marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -477,7 +480,8 @@ describe('usePerpsOrderForm', () => { // Arrange - Start with balance high enough that max >= 999 after 0.5% buffer (e.g. 335 * 3x → floor(1005*0.995) = 999) const mockAccount = { account: { - availableBalance: '335', + spendableBalance: '335', + withdrawableBalance: '335', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -503,7 +507,7 @@ describe('usePerpsOrderForm', () => { expect(result.current.orderForm.amount).toBe('999'); // Act - Change the available balance so the new max is below user's amount - mockAccount.account.availableBalance = '1'; // $1 balance → max order size drops below 999 + mockAccount.account.spendableBalance = '1'; // $1 balance → max order size drops below 999 mockUsePerpsLiveAccount.mockReturnValue(mockAccount); rerender({}); @@ -521,7 +525,8 @@ describe('usePerpsOrderForm', () => { // Test 1: Low balance scenario mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '2', // $2 balance, 3x leverage: max = floor(6 * 0.995) = 5 (less than $10 default) + spendableBalance: '2', // $2 balance, 3x leverage: max = floor(6 * 0.995) = 5 (less than $10 default) + withdrawableBalance: '2', // $2 balance, 3x leverage: max = floor(6 * 0.995) = 5 (less than $10 default) marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -539,7 +544,8 @@ describe('usePerpsOrderForm', () => { // Test 2: High balance scenario mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '100', // $100 balance = $300 max with 3x leverage (more than $10 default) + spendableBalance: '100', // $100 balance = $300 max with 3x leverage (more than $10 default) + withdrawableBalance: '100', // $100 balance = $300 max with 3x leverage (more than $10 default) marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -753,7 +759,8 @@ describe('usePerpsOrderForm', () => { it('should not update amount when balance is 0', () => { mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '0', + spendableBalance: '0', + withdrawableBalance: '0', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', diff --git a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts index af7d842319c..802cc2b0d29 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderForm.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderForm.ts @@ -89,16 +89,14 @@ export function usePerpsOrderForm( selectPendingTradeConfiguration(state, initialAsset), ); - const availableBalance = Number.parseFloat( + const spendableBalance = Number.parseFloat( effectiveAvailableBalanceParam != null ? effectiveAvailableBalanceParam.toString() - : (account?.availableToTradeBalance?.toString() ?? - account?.availableBalance?.toString() ?? - '0'), + : (account?.spendableBalance?.toString() ?? '0'), ); // When paying with a custom token, use selected token amount in USD (including 0); otherwise use Perps balance - const balanceForMax = effectiveAvailableBalanceParam ?? availableBalance; + const balanceForMax = effectiveAvailableBalanceParam ?? spendableBalance; // Determine default amount based on network const defaultAmount = @@ -132,7 +130,7 @@ export function usePerpsOrderForm( } const tempMaxAmount = getMaxAllowedAmount({ - availableBalance: balanceForMax, + spendableBalance: balanceForMax, assetPrice: Number.parseFloat(currentPrice.price), assetSzDecimals: marketData?.szDecimals ?? 6, leverage: defaultLeverage, // Use default leverage for initial calculation @@ -163,8 +161,8 @@ export function usePerpsOrderForm( const initialMarginRequired = Number.parseFloat(initialAmountValue) / defaultLeverage; const initialBalancePercent = - availableBalance > 0 - ? Math.min((initialMarginRequired / availableBalance) * 100, 100) + spendableBalance > 0 + ? Math.min((initialMarginRequired / spendableBalance) * 100, 100) : TRADING_DEFAULTS.marginPercent; // Initialize form state with pending config if available @@ -184,7 +182,7 @@ export function usePerpsOrderForm( const maxPossibleAmount = useMemo( () => getMaxAllowedAmount({ - availableBalance: balanceForMax, + spendableBalance: balanceForMax, assetPrice: Number.parseFloat(currentPrice?.price) || 0, assetSzDecimals: marketData?.szDecimals ?? 6, leverage: orderForm.leverage, // Use current leverage instead of default diff --git a/app/components/UI/Perps/hooks/usePerpsOrderValidation.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderValidation.test.ts index a3dbea63163..10f5ba025e5 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderValidation.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderValidation.test.ts @@ -70,7 +70,7 @@ describe('usePerpsOrderValidation', () => { orderForm: defaultOrderForm, positionSize: '0.002', assetPrice: 50000, - availableBalance: 1000, + spendableBalance: 1000, marginRequired: '10.00', }; @@ -160,7 +160,7 @@ describe('usePerpsOrderValidation', () => { const { result } = renderHook(() => usePerpsOrderValidation({ ...defaultParams, - availableBalance: 5, + spendableBalance: 5, marginRequired: '10.00', }), ); @@ -433,7 +433,7 @@ describe('usePerpsOrderValidation', () => { const { result } = renderHook(() => usePerpsOrderValidation({ ...defaultParams, - availableBalance: 5, + spendableBalance: 5, marginRequired: '10.00', }), ); diff --git a/app/components/UI/Perps/hooks/usePerpsOrderValidation.ts b/app/components/UI/Perps/hooks/usePerpsOrderValidation.ts index 6ae286bfe41..a3365ec03df 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderValidation.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderValidation.ts @@ -21,7 +21,8 @@ interface UsePerpsOrderValidationParams { orderForm: OrderFormState; positionSize: string; assetPrice: number; - availableBalance: number; + /** Max USD that can collateralize a new position (mirrors AccountState.spendableBalance). */ + spendableBalance: number; marginRequired: string; existingPositionLeverage?: number; skipValidation?: boolean; @@ -53,7 +54,7 @@ export function usePerpsOrderValidation( orderForm, positionSize, assetPrice, - availableBalance, + spendableBalance, marginRequired, existingPositionLeverage, skipValidation, @@ -92,11 +93,11 @@ export function usePerpsOrderValidation( // Balance validation (immediate) const requiredMargin = Number.parseFloat(marginRequired); - if (requiredMargin > availableBalance) { + if (requiredMargin > spendableBalance) { immediateErrors.push( strings('perps.order.validation.insufficient_balance', { required: marginRequired, - available: availableBalance.toString(), + available: spendableBalance.toString(), }), ); } @@ -228,7 +229,7 @@ export function usePerpsOrderValidation( orderForm.type, positionSize, assetPrice, - availableBalance, + spendableBalance, marginRequired, existingPositionLeverage, originalUsdAmount, diff --git a/app/components/UI/Perps/hooks/usePerpsPaymentTokens.test.ts b/app/components/UI/Perps/hooks/usePerpsPaymentTokens.test.ts index a22f5731676..98e7b5b3bcc 100644 --- a/app/components/UI/Perps/hooks/usePerpsPaymentTokens.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsPaymentTokens.test.ts @@ -49,7 +49,8 @@ describe('usePerpsPaymentTokens', () => { }; const mockAccountState: AccountState = { - availableBalance: '1000.50', + spendableBalance: '1000.50', + withdrawableBalance: '1000.50', marginUsed: '300.25', unrealizedPnl: '50.25', returnOnEquity: '0', @@ -279,7 +280,8 @@ describe('usePerpsPaymentTokens', () => { it('handles zero Hyperliquid balance', () => { const zeroBalanceAccountState = { ...mockAccountState, - availableBalance: '0', + spendableBalance: '0', + withdrawableBalance: '0', }; mockUsePerpsLiveAccount.mockReturnValue({ @@ -294,15 +296,16 @@ describe('usePerpsPaymentTokens', () => { expect(hyperliquidUsdc.balanceFiat).toBe('$0.00'); }); - it('uses availableToTradeBalance for Unified Account users', () => { - // Unified Account / Portfolio Margin: collateral lives in spot, so HL's - // `clearinghouseState.withdrawable` is $0. The Pay-with sheet must read - // `availableToTradeBalance` (perps + folded spot USDC) instead. + it('uses spendableBalance for Unified Account users', () => { + // Unified Account / Portfolio Margin: collateral lives in spot. The + // provider folds free spot USDC into spendableBalance via + // addSpotBalanceToAccountState, so the Pay-with sheet sees the unified + // total without branching on mode. mockUsePerpsLiveAccount.mockReturnValue({ account: { ...mockAccountState, - availableBalance: '0', - availableToTradeBalance: '2500.00', + spendableBalance: '2500.00', + withdrawableBalance: '2500.00', }, isInitialLoading: false, }); diff --git a/app/components/UI/Perps/hooks/usePerpsPaymentTokens.ts b/app/components/UI/Perps/hooks/usePerpsPaymentTokens.ts index e7ec860c2c1..152942a2496 100644 --- a/app/components/UI/Perps/hooks/usePerpsPaymentTokens.ts +++ b/app/components/UI/Perps/hooks/usePerpsPaymentTokens.ts @@ -34,16 +34,12 @@ export function usePerpsPaymentTokens(): PerpsToken[] { // Use ref to store previous token array const previousTokensRef = useRef([]); - // Get Hyperliquid account balance. Prefer `availableToTradeBalance` so - // Unified Account / Portfolio Margin users see their real spendable balance - // in the Pay-with sheet — `availableBalance` mirrors HL's perps-only - // `clearinghouseState.withdrawable`, which is $0 in unified mode. + // Get Hyperliquid account balance from the reshaped balance contract. + // `spendableBalance` is the Unified-aware tradeable amount. const { account } = usePerpsLiveAccount(); const currentNetwork = usePerpsNetwork(); const hyperliquidBalance = Number.parseFloat( - ( - account?.availableToTradeBalance ?? account?.availableBalance - )?.toString() || '0', + account?.spendableBalance?.toString() || '0', ); // Get all chain IDs to search for tokens (exclude Hyperliquid chains) diff --git a/app/components/UI/Perps/hooks/usePerpsPortfolioBalance.test.ts b/app/components/UI/Perps/hooks/usePerpsPortfolioBalance.test.ts index 488ae3f5673..5ce3650ace5 100644 --- a/app/components/UI/Perps/hooks/usePerpsPortfolioBalance.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsPortfolioBalance.test.ts @@ -70,7 +70,8 @@ describe('usePerpsPortfolioBalance', () => { // Set up default mock for usePerpsLiveAccount with zero balances mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '0', + spendableBalance: '0', + withdrawableBalance: '0', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -116,7 +117,8 @@ describe('usePerpsPortfolioBalance', () => { }; const accountData = { - availableBalance: '1000.50', + spendableBalance: '1000.50', + withdrawableBalance: '1000.50', marginUsed: '0', unrealizedPnl: '50.25', returnOnEquity: '0', @@ -155,7 +157,8 @@ describe('usePerpsPortfolioBalance', () => { }; const accountData = { - availableBalance: '1500.75', + spendableBalance: '1500.75', + withdrawableBalance: '1500.75', marginUsed: '0', unrealizedPnl: '-25.15', returnOnEquity: '0', @@ -183,7 +186,8 @@ describe('usePerpsPortfolioBalance', () => { }; const accountData = { - availableBalance: '2500.00', + spendableBalance: '2500.00', + withdrawableBalance: '2500.00', marginUsed: '0', unrealizedPnl: '100.00', returnOnEquity: '0', @@ -213,7 +217,8 @@ describe('usePerpsPortfolioBalance', () => { }; const accountData = { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', marginUsed: '0', unrealizedPnl: '50.00', returnOnEquity: '0', @@ -240,7 +245,8 @@ describe('usePerpsPortfolioBalance', () => { }; const accountData = { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', marginUsed: '0', unrealizedPnl: '50.00', returnOnEquity: '0', @@ -329,7 +335,8 @@ describe('usePerpsPortfolioBalance', () => { }; const accountData = { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', marginUsed: '0', unrealizedPnl: '50.00', returnOnEquity: '0', @@ -362,7 +369,8 @@ describe('usePerpsPortfolioBalance', () => { }; const accountData = { - availableBalance: 'invalid', + spendableBalance: 'invalid', + withdrawableBalance: 'invalid', marginUsed: '0', unrealizedPnl: 'NaN', returnOnEquity: '0', @@ -391,7 +399,8 @@ describe('usePerpsPortfolioBalance', () => { }; const accountData = { - availableBalance: '-100.50', + spendableBalance: '-100.50', + withdrawableBalance: '-100.50', marginUsed: '0', unrealizedPnl: '-200.25', returnOnEquity: '0', @@ -418,7 +427,8 @@ describe('usePerpsPortfolioBalance', () => { }; const accountData = { - availableBalance: '999999999.99', + spendableBalance: '999999999.99', + withdrawableBalance: '999999999.99', marginUsed: '0', unrealizedPnl: '123456789.12', returnOnEquity: '0', @@ -444,7 +454,8 @@ describe('usePerpsPortfolioBalance', () => { }; const accountData = { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', marginUsed: '0', unrealizedPnl: '0', // Missing field defaults to 0 returnOnEquity: '0', @@ -472,7 +483,8 @@ describe('usePerpsPortfolioBalance', () => { }; const accountData = { - availableBalance: '500.00', + spendableBalance: '500.00', + withdrawableBalance: '500.00', marginUsed: '0', unrealizedPnl: '25.00', returnOnEquity: '0', @@ -502,7 +514,8 @@ describe('usePerpsPortfolioBalance', () => { }; const accountData = { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', marginUsed: '0', unrealizedPnl: '50.00', returnOnEquity: '0', @@ -537,7 +550,8 @@ describe('usePerpsPortfolioBalance', () => { }; let accountData = { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', marginUsed: '0', unrealizedPnl: '50.00', returnOnEquity: '0', @@ -561,7 +575,8 @@ describe('usePerpsPortfolioBalance', () => { }; accountData = { - availableBalance: '2000.00', + spendableBalance: '2000.00', + withdrawableBalance: '2000.00', marginUsed: '0', unrealizedPnl: '100.00', returnOnEquity: '0', @@ -588,7 +603,8 @@ describe('usePerpsPortfolioBalance', () => { }; const accountData = { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', marginUsed: '0', unrealizedPnl: '50.00', returnOnEquity: '0', diff --git a/app/components/UI/Perps/hooks/usePerpsPositionForAsset.test.ts b/app/components/UI/Perps/hooks/usePerpsPositionForAsset.test.ts index d76b8f9f75d..a02af1b7597 100644 --- a/app/components/UI/Perps/hooks/usePerpsPositionForAsset.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsPositionForAsset.test.ts @@ -50,7 +50,8 @@ const mockPosition: Position = { }; const mockAccountState: AccountState = { - availableBalance: '10000', + spendableBalance: '10000', + withdrawableBalance: '10000', marginUsed: '800', unrealizedPnl: '100', returnOnEquity: '0.03', @@ -58,19 +59,23 @@ const mockAccountState: AccountState = { }; const mockUserAddress = '0x1234567890123456789012345678901234567890'; +const mockNonEvmAddress = 'bc1qn7ag0000000000000000000000000000000000'; const createMockState = ( - overrides?: Partial<{ isTestnet: boolean }>, + overrides?: Partial<{ + isTestnet: boolean; + selectedAccountId: string; + selectedGroupAccountIds: string[]; + }>, ): DeepPartial => ({ engine: { backgroundState: { PerpsController: { - isTestnet: false, - ...overrides, + isTestnet: overrides?.isTestnet ?? false, }, AccountsController: { internalAccounts: { - selectedAccount: 'account-1', + selectedAccount: overrides?.selectedAccountId ?? 'account-1', accounts: { 'account-1': { id: 'account-1', @@ -86,6 +91,53 @@ const createMockState = ( methods: [], options: {}, }, + 'account-btc': { + id: 'account-btc', + type: 'bip122:p2wpkh', + address: mockNonEvmAddress, + metadata: { + name: 'Bitcoin Account', + keyring: { type: 'Snap Keyring' }, + importTime: 1234567890, + lastSelected: 1234567891, + }, + scopes: ['bip122:000000000019d6689c085ae165831e93'], + methods: [], + options: {}, + }, + }, + }, + }, + KeyringController: { + keyrings: [ + { + accounts: [mockUserAddress], + type: 'HD Key Tree', + }, + ], + }, + AccountTreeController: { + selectedAccountGroup: 'entropy:wallet-1/0', + accountTree: { + wallets: { + 'entropy:wallet-1': { + id: 'entropy:wallet-1', + metadata: { + name: 'Wallet 1', + }, + groups: { + 'entropy:wallet-1/0': { + id: 'entropy:wallet-1/0', + metadata: { + name: 'Account 1', + }, + accounts: overrides?.selectedGroupAccountIds ?? [ + 'account-1', + 'account-btc', + ], + }, + }, + }, }, }, }, @@ -211,6 +263,50 @@ describe('usePerpsPositionForAsset', () => { }); }); + it('uses the selected account group EVM address when the selected account is non-EVM', async () => { + renderHookWithProvider(() => usePerpsPositionForAsset('ETH'), { + state: createMockState({ selectedAccountId: 'account-btc' }), + }); + + await waitFor(() => { + expect(mockGetPositions).toHaveBeenCalledWith({ + standalone: true, + userAddress: mockUserAddress, + }); + }); + + expect(mockGetAccountState).toHaveBeenCalledWith({ + standalone: true, + userAddress: mockUserAddress, + }); + expect(mockGetAccountState).not.toHaveBeenCalledWith( + expect.objectContaining({ userAddress: mockNonEvmAddress }), + ); + }); + + it('returns empty state when the selected account group has no EVM account', async () => { + const { result } = renderHookWithProvider( + () => usePerpsPositionForAsset('ETH'), + { + state: createMockState({ + selectedAccountId: 'account-btc', + selectedGroupAccountIds: ['account-btc'], + }), + }, + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.position).toBeNull(); + expect(result.current.hasFundsInPerps).toBe(false); + expect(result.current.accountState).toBeNull(); + expect(result.current.error).toBeNull(); + expect(mockGetPositions).not.toHaveBeenCalled(); + expect(mockGetAccountState).not.toHaveBeenCalled(); + }); + it('handles case-insensitive symbol matching', async () => { const { result } = renderHookWithProvider( () => usePerpsPositionForAsset('eth'), diff --git a/app/components/UI/Perps/hooks/usePerpsPositionForAsset.ts b/app/components/UI/Perps/hooks/usePerpsPositionForAsset.ts index bbf479c81dc..1e6f3ae06f1 100644 --- a/app/components/UI/Perps/hooks/usePerpsPositionForAsset.ts +++ b/app/components/UI/Perps/hooks/usePerpsPositionForAsset.ts @@ -4,7 +4,7 @@ import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import type { AccountState, Position } from '@metamask/perps-controller'; import { usePerpsTrading } from './usePerpsTrading'; import { usePerpsNetwork } from './usePerpsNetwork'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../../selectors/accountsController'; +import { selectSelectedAccountGroupEvmInternalAccount } from '../../../../selectors/multichainAccounts/accountTreeController'; import { PerpsCacheInvalidator } from '../services/PerpsCacheInvalidator'; /** @@ -89,9 +89,8 @@ export const usePerpsPositionForAsset = ( ): UsePerpsPositionForAssetResult => { const { getPositions, getAccountState } = usePerpsTrading(); const perpsNetwork = usePerpsNetwork(); - const userAddress = useSelector( - selectSelectedInternalAccountFormattedAddress, - ); + const evmAccount = useSelector(selectSelectedAccountGroupEvmInternalAccount); + const userAddress = evmAccount?.address; // Track if component is still mounted const isMountedRef = useRef(true); diff --git a/app/components/UI/Perps/hooks/usePerpsSelector.test.ts b/app/components/UI/Perps/hooks/usePerpsSelector.test.ts index 49d866d1d2b..91a8e92a81b 100644 --- a/app/components/UI/Perps/hooks/usePerpsSelector.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsSelector.test.ts @@ -142,7 +142,8 @@ describe('usePerpsSelector', () => { const mockState: PerpsControllerState = { accountState: { totalBalance: '1000.00', - availableBalance: '750.00', + spendableBalance: '750.00', + withdrawableBalance: '750.00', marginUsed: '250.00', }, } as PerpsControllerState; @@ -158,7 +159,7 @@ describe('usePerpsSelector', () => { ) => ({ total: Number.parseFloat(state?.accountState?.totalBalance ?? '0'), available: Number.parseFloat( - state?.accountState?.availableBalance ?? '0', + state?.accountState?.spendableBalance ?? '0', ), used: Number.parseFloat(state?.accountState?.marginUsed ?? '0'), }); diff --git a/app/components/UI/Perps/hooks/usePerpsTrading.test.ts b/app/components/UI/Perps/hooks/usePerpsTrading.test.ts index 5f62f6d8373..f5791cdb39b 100644 --- a/app/components/UI/Perps/hooks/usePerpsTrading.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsTrading.test.ts @@ -280,7 +280,8 @@ describe('usePerpsTrading', () => { describe('getAccountState', () => { it('should call PerpsController.getAccountState and return account state', async () => { const mockAccountState: AccountState = { - availableBalance: '10000', + spendableBalance: '10000', + withdrawableBalance: '10000', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '16.67', @@ -304,7 +305,8 @@ describe('usePerpsTrading', () => { it('should call getAccountState without parameters', async () => { const mockAccountState: AccountState = { - availableBalance: '10000', + spendableBalance: '10000', + withdrawableBalance: '10000', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '16.67', diff --git a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts index d8d25ef080d..e71f25d24c0 100644 --- a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.test.ts @@ -185,7 +185,9 @@ describe('usePerpsTransactionHistory', () => { // Mock Redux selectors: first call = wallet transactions, second = selected address mockUseSelector.mockImplementation(() => { const len = mockUseSelector.mock.calls.length; - return len % 2 === 1 ? [] : '0x1234567890123456789012345678901234567890'; + return len % 2 === 1 + ? [] + : { address: '0x1234567890123456789012345678901234567890' }; }); // Mock provider @@ -327,7 +329,7 @@ describe('usePerpsTransactionHistory', () => { txParams: { from: selectedAddr }, }, ] - : selectedAddr; + : { address: selectedAddr }; }); const { result } = renderHook(() => @@ -396,7 +398,7 @@ describe('usePerpsTransactionHistory', () => { txParams: { from: selectedAddr }, }, ] - : selectedAddr; + : { address: selectedAddr }; }); const { result } = renderHook(() => @@ -466,7 +468,7 @@ describe('usePerpsTransactionHistory', () => { nestedTransactions: [{ type: 'perpsWithdraw' }], }, ] - : selectedAddr; + : { address: selectedAddr }; }); const { result } = renderHook(() => @@ -547,7 +549,7 @@ describe('usePerpsTransactionHistory', () => { nestedTransactions: [{ type: 'perpsWithdraw' }], }, ] - : selectedAddr; + : { address: selectedAddr }; }); const { result } = renderHook(() => diff --git a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts index 1d353efe902..85c69021eb8 100644 --- a/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts +++ b/app/components/UI/Perps/hooks/usePerpsTransactionHistory.ts @@ -28,7 +28,7 @@ import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import type { CaipAccountId } from '@metamask/utils'; import { areAddressesEqual } from '../../../../util/address'; import { selectNonReplacedTransactions } from '../../../../selectors/transactionController'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../../selectors/accountsController'; +import { selectSelectedAccountGroupEvmInternalAccount } from '../../../../selectors/multichainAccounts/accountTreeController'; import { PerpsTransaction, PerpsTransactionType, @@ -120,9 +120,8 @@ export const usePerpsTransactionHistory = ({ // Wallet perps deposits and withdrawals for the Deposits tab const walletTransactions = useSelector(selectNonReplacedTransactions); - const selectedAddress = useSelector( - selectSelectedInternalAccountFormattedAddress, - ); + const evmAccount = useSelector(selectSelectedAccountGroupEvmInternalAccount); + const selectedAddress = evmAccount?.address; const { walletDepositTransactions, walletWithdrawalTransactions } = useMemo(() => { if (!selectedAddress) { diff --git a/app/components/UI/Perps/hooks/usePerpsWithdrawProgress.test.ts b/app/components/UI/Perps/hooks/usePerpsWithdrawProgress.test.ts index 9e3496f8f0d..7315353a0ac 100644 --- a/app/components/UI/Perps/hooks/usePerpsWithdrawProgress.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsWithdrawProgress.test.ts @@ -22,7 +22,8 @@ describe('usePerpsWithdrawProgress', () => { // Default mock for usePerpsLiveAccount mockUsePerpsLiveAccount.mockReturnValue({ account: { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', totalBalance: '10000.00', marginUsed: '9000.00', unrealizedPnl: '100.00', @@ -80,7 +81,8 @@ describe('usePerpsWithdrawProgress', () => { it('captures balance when withdrawal starts', () => { // Arrange const mockAccount = { - availableBalance: '500.00', + spendableBalance: '500.00', + withdrawableBalance: '500.00', totalBalance: '10000.00', marginUsed: '9500.00', unrealizedPnl: '100.00', @@ -107,7 +109,8 @@ describe('usePerpsWithdrawProgress', () => { it('completes withdrawal when balance decreases', () => { // Arrange const initialAccount = { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', totalBalance: '10000.00', marginUsed: '9000.00', unrealizedPnl: '100.00', @@ -115,7 +118,8 @@ describe('usePerpsWithdrawProgress', () => { }; const updatedAccount = { - availableBalance: '500.00', // Balance decreased + spendableBalance: '500.00', // Balance decreased + withdrawableBalance: '500.00', // Balance decreased totalBalance: '9500.00', marginUsed: '9000.00', unrealizedPnl: '100.00', @@ -150,7 +154,8 @@ describe('usePerpsWithdrawProgress', () => { it('does not complete withdrawal when balance increases', () => { // Arrange const initialAccount = { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', totalBalance: '10000.00', marginUsed: '9000.00', unrealizedPnl: '100.00', @@ -158,7 +163,8 @@ describe('usePerpsWithdrawProgress', () => { }; const updatedAccount = { - availableBalance: '1500.00', // Balance increased + spendableBalance: '1500.00', // Balance increased + withdrawableBalance: '1500.00', // Balance increased totalBalance: '10500.00', marginUsed: '9000.00', unrealizedPnl: '100.00', @@ -193,7 +199,8 @@ describe('usePerpsWithdrawProgress', () => { it('does not complete withdrawal when balance stays the same', () => { // Arrange const account = { - availableBalance: '1000.00', + spendableBalance: '1000.00', + withdrawableBalance: '1000.00', totalBalance: '10000.00', marginUsed: '9000.00', unrealizedPnl: '100.00', @@ -251,10 +258,11 @@ describe('usePerpsWithdrawProgress', () => { expect(result.current.isWithdrawInProgress).toBe(false); }); - it('handles missing availableBalance gracefully', () => { + it('handles missing withdrawableBalance gracefully', () => { // Arrange const accountWithoutBalance = { - availableBalance: '', + spendableBalance: '', + withdrawableBalance: '', totalBalance: '10000.00', marginUsed: '9000.00', unrealizedPnl: '100.00', diff --git a/app/components/UI/Perps/hooks/usePerpsWithdrawProgress.ts b/app/components/UI/Perps/hooks/usePerpsWithdrawProgress.ts index 16184b0b047..879397a8f28 100644 --- a/app/components/UI/Perps/hooks/usePerpsWithdrawProgress.ts +++ b/app/components/UI/Perps/hooks/usePerpsWithdrawProgress.ts @@ -16,7 +16,7 @@ export const usePerpsWithdrawProgress = () => { // Track if we're expecting a withdrawal const [isWithdrawInProgress, setIsWithdrawInProgress] = useState(false); - const prevAvailableBalanceRef = useRef('0'); + const prevWithdrawableBalanceRef = useRef('0'); const liveAccountRef = useRef(liveAccount); // Get withdrawal progress state from controller @@ -35,8 +35,8 @@ export const usePerpsWithdrawProgress = () => { if (withdrawInProgress) { // Withdrawal started - set progress state and capture current balance setIsWithdrawInProgress(true); - prevAvailableBalanceRef.current = - liveAccountRef.current?.availableBalance || '0'; + prevWithdrawableBalanceRef.current = + liveAccountRef.current?.withdrawableBalance || '0'; } else { // Withdrawal completed or failed - clear progress state setIsWithdrawInProgress(false); @@ -49,14 +49,14 @@ export const usePerpsWithdrawProgress = () => { return; } - const currentBalance = parseFloat(liveAccount.availableBalance || '0'); - const previousBalance = parseFloat(prevAvailableBalanceRef.current); + const currentBalance = parseFloat(liveAccount.withdrawableBalance || '0'); + const previousBalance = parseFloat(prevWithdrawableBalanceRef.current); // Check if balance decreased (funds withdrawn) if (currentBalance < previousBalance) { // Withdrawal completed successfully setIsWithdrawInProgress(false); - prevAvailableBalanceRef.current = liveAccount.availableBalance; + prevWithdrawableBalanceRef.current = liveAccount.withdrawableBalance; } }, [isWithdrawInProgress, liveAccount]); diff --git a/app/components/UI/Perps/hooks/useWithdrawValidation.test.ts b/app/components/UI/Perps/hooks/useWithdrawValidation.test.ts index 9277463c6f2..6e99660cac1 100644 --- a/app/components/UI/Perps/hooks/useWithdrawValidation.test.ts +++ b/app/components/UI/Perps/hooks/useWithdrawValidation.test.ts @@ -63,7 +63,8 @@ describe('useWithdrawValidation', () => { jest.clearAllMocks(); (usePerpsLiveAccount as jest.Mock).mockReturnValue({ account: { - availableBalance: '$1000.00', + spendableBalance: '$1000.00', + withdrawableBalance: '$1000.00', }, isInitialLoading: false, }); @@ -78,14 +79,14 @@ describe('useWithdrawValidation', () => { useWithdrawValidation({ withdrawAmount: '100' }), ); - expect(result.current.availableBalance).toBe('1000'); + expect(result.current.withdrawableBalance).toBe('1000'); }); - it('prefers availableToTradeBalance for Unified Account target state', () => { + it('uses withdrawableBalance populated by spot fold for Unified Account', () => { (usePerpsLiveAccount as jest.Mock).mockReturnValue({ account: { - availableBalance: '$0.00', - availableToTradeBalance: '$2500.00', + spendableBalance: '$2500.00', + withdrawableBalance: '$2500.00', }, isInitialLoading: false, }); @@ -94,14 +95,15 @@ describe('useWithdrawValidation', () => { useWithdrawValidation({ withdrawAmount: '100' }), ); - expect(result.current.availableBalance).toBe('2500'); + expect(result.current.withdrawableBalance).toBe('2500'); expect(result.current.hasInsufficientBalance).toBe(false); }); it('should handle empty balance', () => { (usePerpsLiveAccount as jest.Mock).mockReturnValue({ account: { - availableBalance: null, + spendableBalance: null, + withdrawableBalance: null, }, isInitialLoading: false, }); @@ -110,7 +112,7 @@ describe('useWithdrawValidation', () => { useWithdrawValidation({ withdrawAmount: '100' }), ); - expect(result.current.availableBalance).toBe('0'); + expect(result.current.withdrawableBalance).toBe('0'); }); it('should detect insufficient balance', () => { @@ -124,7 +126,8 @@ describe('useWithdrawValidation', () => { it('should truncate available balance to 2 decimal places for validation', () => { (usePerpsLiveAccount as jest.Mock).mockReturnValue({ account: { - availableBalance: '$16.069', + spendableBalance: '$16.069', + withdrawableBalance: '$16.069', }, isInitialLoading: false, }); @@ -133,14 +136,15 @@ describe('useWithdrawValidation', () => { useWithdrawValidation({ withdrawAmount: '16.06' }), ); - expect(result.current.availableBalance).toBe('16.06'); + expect(result.current.withdrawableBalance).toBe('16.06'); expect(result.current.hasInsufficientBalance).toBe(false); }); it('should show insufficient balance when typing more than truncated balance', () => { (usePerpsLiveAccount as jest.Mock).mockReturnValue({ account: { - availableBalance: '$16.069', + spendableBalance: '$16.069', + withdrawableBalance: '$16.069', }, isInitialLoading: false, }); diff --git a/app/components/UI/Perps/hooks/useWithdrawValidation.ts b/app/components/UI/Perps/hooks/useWithdrawValidation.ts index 3a8adb55263..13992bde788 100644 --- a/app/components/UI/Perps/hooks/useWithdrawValidation.ts +++ b/app/components/UI/Perps/hooks/useWithdrawValidation.ts @@ -28,12 +28,9 @@ export const useWithdrawValidation = ({ const perpsNetwork = usePerpsNetwork(); const isTestnet = perpsNetwork === 'testnet'; - // Release-branch bridge for Unified Account: availableToTradeBalance includes - // collateral HL can use in target mode. The full balance contract will replace - // this with an explicit withdrawableBalance field. - const availableBalance = useMemo(() => { - const balance = - account?.availableToTradeBalance ?? account?.availableBalance ?? '0'; + // Truncate to 2 decimal places so validation matches the displayed balance. + const withdrawableBalance = useMemo(() => { + const balance = account?.withdrawableBalance || '0'; return truncateToTwoDecimals(parseCurrencyString(balance)).toString(); }, [account]); @@ -52,11 +49,11 @@ export const useWithdrawValidation = ({ // Validation checks const hasInsufficientBalance = useMemo(() => { - if (!withdrawAmount || !availableBalance) return false; + if (!withdrawAmount || !withdrawableBalance) return false; return ( - Number.parseFloat(withdrawAmount) > Number.parseFloat(availableBalance) + Number.parseFloat(withdrawAmount) > Number.parseFloat(withdrawableBalance) ); - }, [withdrawAmount, availableBalance]); + }, [withdrawAmount, withdrawableBalance]); const isBelowMinimum = useMemo(() => { if (!withdrawAmount) return false; @@ -95,7 +92,7 @@ export const useWithdrawValidation = ({ ); return { - availableBalance, + withdrawableBalance, withdrawalRoute, hasInsufficientBalance, isBelowMinimum, diff --git a/app/components/UI/Perps/hooks/useWithdrawalRequests.ts b/app/components/UI/Perps/hooks/useWithdrawalRequests.ts index 660f5707aa0..f479e5e525f 100644 --- a/app/components/UI/Perps/hooks/useWithdrawalRequests.ts +++ b/app/components/UI/Perps/hooks/useWithdrawalRequests.ts @@ -34,6 +34,9 @@ interface UseWithdrawalRequestsResult { refetch: () => Promise; } +const EMPTY_WITHDRAWAL_REQUESTS: WithdrawalRequest[] = []; +const EMPTY_TX_HASHES: string[] = []; + const WITHDRAWAL_POLL_INTERVAL_MS = 5000; const WITHDRAWAL_SEARCH_BUFFER_MS = 60000; @@ -56,8 +59,10 @@ export const useWithdrawalRequests = ( const allWithdrawals = useStableArray( usePerpsSelector((state) => { - const withdrawals = state?.withdrawalRequests || []; - if (!selectedAddress) return []; + const withdrawals = + state?.withdrawalRequests ?? EMPTY_WITHDRAWAL_REQUESTS; + if (!selectedAddress || withdrawals.length === 0) + return EMPTY_WITHDRAWAL_REQUESTS; return withdrawals.filter( (req) => req.accountAddress?.toLowerCase() === selectedAddress.toLowerCase(), @@ -79,7 +84,7 @@ export const useWithdrawalRequests = ( (state) => state?.lastCompletedWithdrawalTimestamp ?? null, ); const lastCompletedTxHashes = usePerpsSelector( - (state) => state?.lastCompletedWithdrawalTxHashes ?? [], + (state) => state?.lastCompletedWithdrawalTxHashes ?? EMPTY_TX_HASHES, ); const initialFetchDoneRef = useRef(false); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx index 900da7ab83c..3eef6447d59 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.test.tsx @@ -262,7 +262,8 @@ describe('PerpsStreamManager', () => { ]); jest.spyOn(testStreamManager.account, 'getSnapshot').mockReturnValue({ totalBalance: '5000', - availableBalance: '4000', + spendableBalance: '4000', + withdrawableBalance: '4000', marginUsed: '1000', unrealizedPnl: '0', returnOnEquity: '0', @@ -2707,6 +2708,259 @@ describe('PerpsStreamManager', () => { }); }); + describe('StreamChannel pause reference counting', () => { + let mockOrdersSubscribe: jest.Mock; + let mockOrdersUnsubscribe: jest.Mock; + let orderCallback: ((orders: Order[]) => void) | null; + + const SAMPLE_ORDER: Order = { + orderId: '1', + symbol: 'BTC', + side: 'buy', + orderType: 'limit', + size: '1.0', + originalSize: '1.0', + price: '50000', + filledSize: '0', + remainingSize: '1.0', + status: 'open', + timestamp: Date.now(), + detailedOrderType: 'Limit', + isTrigger: false, + reduceOnly: false, + }; + + beforeEach(() => { + orderCallback = null; + mockOrdersUnsubscribe = jest.fn(); + mockOrdersSubscribe = jest + .fn() + .mockImplementation( + (params: { callback: (orders: Order[]) => void }) => { + orderCallback = params.callback; + return mockOrdersUnsubscribe; + }, + ); + mockEngine.context.PerpsController.subscribeToOrders = + mockOrdersSubscribe; + mockEngine.context.PerpsController.isCurrentlyReinitializing = jest + .fn() + .mockReturnValue(false); + }); + + it('requires all pause holders to resume before emission unblocks', async () => { + const callback = jest.fn(); + const unsubscribe = testStreamManager.orders.subscribe({ + callback, + throttleMs: 0, + }); + + await waitFor(() => expect(mockOrdersSubscribe).toHaveBeenCalled()); + + // Deliver first update so hasReceivedFirstUpdate is set + act(() => { + orderCallback?.([SAMPLE_ORDER]); + }); + await waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); + callback.mockClear(); + + // Two independent callers pause — simulating tab visibility + controller op + act(() => { + testStreamManager.orders.pause(); // caller A: tab + testStreamManager.orders.pause(); // caller B: controller + }); + + act(() => { + orderCallback?.([{ ...SAMPLE_ORDER, orderId: '2' }]); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(callback).not.toHaveBeenCalled(); + + // Caller A releases — count drops to 1, still blocked + act(() => { + testStreamManager.orders.resume(); + }); // caller A: tab + act(() => { + orderCallback?.([{ ...SAMPLE_ORDER, orderId: '3' }]); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(callback).not.toHaveBeenCalled(); + + // Caller B releases — count drops to 0, emission resumes + act(() => { + testStreamManager.orders.resume(); + }); // caller B: controller + act(() => { + orderCallback?.([{ ...SAMPLE_ORDER, orderId: '4' }]); + }); + await waitFor(() => { + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ orderId: '4' })]), + ); + }); + + unsubscribe(); + }); + + it('tab resume does not release a concurrent controller pause', async () => { + // Regression: the old boolean would allow tab resume to unblock the channel + // while a controller operation (e.g. closePosition) was still in-flight. + const callback = jest.fn(); + const unsubscribe = testStreamManager.orders.subscribe({ + callback, + throttleMs: 0, + }); + + await waitFor(() => expect(mockOrdersSubscribe).toHaveBeenCalled()); + + act(() => { + orderCallback?.([SAMPLE_ORDER]); + }); + await waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); + callback.mockClear(); + + act(() => { + // Predictions tab becomes active + testStreamManager.orders.pause(); // tab: count → 1 + // Controller starts a trade operation on the same channel + testStreamManager.orders.pause(); // controller: count → 2 + }); + + // User switches back to Portfolio — tab releases its pause + act(() => { + testStreamManager.orders.resume(); + }); // tab: count → 1 + + // Update arrives while controller op is still running — must be blocked + act(() => { + orderCallback?.([{ ...SAMPLE_ORDER, orderId: 'mid-op' }]); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(callback).not.toHaveBeenCalled(); + + // Controller finally block releases — count → 0, emission resumes + act(() => { + testStreamManager.orders.resume(); + }); // controller: count → 0 + act(() => { + orderCallback?.([{ ...SAMPLE_ORDER, orderId: 'after-op' }]); + }); + await waitFor(() => { + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ orderId: 'after-op' }), + ]), + ); + }); + + unsubscribe(); + }); + + it('resume() never drives pauseCount below zero (unmatched resume guard)', async () => { + const callback = jest.fn(); + const unsubscribe = testStreamManager.orders.subscribe({ + callback, + throttleMs: 0, + }); + + await waitFor(() => expect(mockOrdersSubscribe).toHaveBeenCalled()); + + act(() => { + orderCallback?.([SAMPLE_ORDER]); + }); + await waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); + callback.mockClear(); + + // Extra resume calls with no matching pause should be no-ops + act(() => { + testStreamManager.orders.resume(); + testStreamManager.orders.resume(); + testStreamManager.orders.resume(); + }); + + // Emission must still work — count is 0, not negative + act(() => { + orderCallback?.([{ ...SAMPLE_ORDER, orderId: 'after-unmatched' }]); + }); + await waitFor(() => { + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ orderId: 'after-unmatched' }), + ]), + ); + }); + + unsubscribe(); + }); + + it('each channel tracks its own pause count independently', () => { + // Pausing orders should not affect prices + const orderPauseSpy = jest.spyOn(testStreamManager.orders, 'pause'); + const pricesPauseSpy = jest.spyOn(testStreamManager.prices, 'pause'); + + testStreamManager.orders.pause(); + + expect(orderPauseSpy).toHaveBeenCalledTimes(1); + expect(pricesPauseSpy).not.toHaveBeenCalled(); + }); + }); + + describe('pauseAllChannels / resumeAllChannels', () => { + const CHANNEL_NAMES = [ + 'prices', + 'orders', + 'positions', + 'fills', + 'account', + 'oiCaps', + 'topOfBook', + 'candles', + ] as const; + + it('pauseAllChannels calls pause() on every channel', () => { + for (const channel of CHANNEL_NAMES) { + jest.spyOn(testStreamManager[channel], 'pause'); + } + + testStreamManager.pauseAllChannels(); + + for (const channel of CHANNEL_NAMES) { + expect(testStreamManager[channel].pause).toHaveBeenCalledTimes(1); + } + }); + + it('resumeAllChannels calls resume() on every channel', () => { + for (const channel of CHANNEL_NAMES) { + jest.spyOn(testStreamManager[channel], 'resume'); + } + + testStreamManager.resumeAllChannels(); + + for (const channel of CHANNEL_NAMES) { + expect(testStreamManager[channel].resume).toHaveBeenCalledTimes(1); + } + }); + + it('does not touch marketData (REST-based, not a stream channel)', () => { + const pauseSpy = jest.spyOn(testStreamManager.marketData, 'pause'); + const resumeSpy = jest.spyOn(testStreamManager.marketData, 'resume'); + + testStreamManager.pauseAllChannels(); + testStreamManager.resumeAllChannels(); + + expect(pauseSpy).not.toHaveBeenCalled(); + expect(resumeSpy).not.toHaveBeenCalled(); + }); + }); + describe('TopOfBookStreamChannel', () => { it('subscribes to top of book with includeOrderBook flag', () => { const callback = jest.fn(); @@ -3473,7 +3727,8 @@ describe('PerpsStreamManager', () => { const mockAccountState: AccountState = { totalBalance: '10000', - availableBalance: '5000', + spendableBalance: '5000', + withdrawableBalance: '5000', unrealizedPnl: '1000', marginUsed: '4000', returnOnEquity: '0.1', diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index 80d2f1b2916..71e8e745fd8 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -83,8 +83,10 @@ abstract class StreamChannel { protected accountAddress: string | null = null; // Track WebSocket connection timing for first data measurement protected wsConnectionStartTime: number | null = null; - // Flag to pause emission during operations (keeps WebSocket alive) - protected isPaused = false; + // Reference count for pause requests. Emission is blocked whenever > 0, + // allowing independent callers (tab visibility, controller operations) to + // pause/resume without clobbering each other. + protected pauseCount = 0; // Retry counter for deferred connect() calls protected connectRetryCount = 0; // Timer handle for deferConnect so it can be cancelled on disconnect @@ -92,8 +94,8 @@ abstract class StreamChannel { private static readonly MAX_CONNECT_RETRIES = 150; // 30s at 200ms protected notifySubscribers(updates: T) { - // Block emission if paused (WebSocket continues receiving updates) - if (this.isPaused) { + // Block emission while any pause is held (WebSocket continues receiving updates) + if (this.pauseCount > 0) { return; } @@ -314,15 +316,16 @@ abstract class StreamChannel { * Used during batch operations to prevent UI re-renders from stale data */ public pause(): void { - this.isPaused = true; + this.pauseCount += 1; } /** - * Resume emission of updates to subscribers - * Subscribers will receive the next update from the WebSocket + * Resume emission of updates to subscribers. + * Each pause() call must be matched by exactly one resume(). Emission + * resumes only when all callers have released their pause. */ public resume(): void { - this.isPaused = false; + this.pauseCount = Math.max(0, this.pauseCount - 1); } protected getCachedData(): T | null { @@ -1734,6 +1737,37 @@ export class PerpsStreamManager { }); } + /** + * Pause all stream channels — stops emitting updates to subscribers while + * keeping WebSocket subscriptions alive and cache warm. Call when the Perps + * UI is not visible to avoid unnecessary processing. + */ + public pauseAllChannels(): void { + this.prices.pause(); + this.orders.pause(); + this.positions.pause(); + this.fills.pause(); + this.account.pause(); + this.oiCaps.pause(); + this.topOfBook.pause(); + this.candles.pause(); + } + + /** + * Resume all stream channels after a pause. Subscribers will receive the + * next update pushed by the WebSocket. + */ + public resumeAllChannels(): void { + this.prices.resume(); + this.orders.resume(); + this.positions.resume(); + this.fills.resume(); + this.account.resume(); + this.oiCaps.resume(); + this.topOfBook.resume(); + this.candles.resume(); + } + /** * Force reconnection of all stream channels after WebSocket reconnection * Disconnects all channels and reconnects those with active subscribers diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index c7a322f2ab1..fe3c4f4082f 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -47,7 +47,11 @@ import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; /* eslint-disable-next-line */ import { NavigationContext } from '@react-navigation/core'; import { CONFIRMATION_HEADER_CONFIG } from '../constants/perpsConfig'; -import { clearStackNavigatorOptions } from '../../../../constants/navigation/clearStackNavigatorOptions'; +import { + clearNativeStackNavigatorOptions, + clearStackNavigatorOptions, + transparentModalScreenOptions, +} from '../../../../constants/navigation/clearStackNavigatorOptions'; const Stack = createNativeStackNavigator(); const ModalStack = createStackNavigator(); @@ -73,7 +77,7 @@ function getRedesignedConfirmationsHeaderOptions({ title: '', headerBackVisible: false, contentStyle: { backgroundColor: 'transparent' }, - presentation: 'transparentModal', + ...transparentModalScreenOptions, }; } @@ -353,9 +357,9 @@ const PerpsScreenStack = () => { name={Routes.PERPS.TPSL} component={PerpsTPSLView} options={{ + ...transparentModalScreenOptions, title: strings('perps.tpsl.title'), headerShown: false, - presentation: 'transparentModal', }} /> @@ -411,12 +415,8 @@ const PerpsScreenStack = () => { name={Routes.PERPS.MODALS.CLOSE_POSITION_MODALS} component={PerpsClosePositionBottomSheetStack} options={{ - headerShown: false, - contentStyle: { - backgroundColor: 'transparent', - }, - animation: 'none', - presentation: 'transparentModal', + ...clearNativeStackNavigatorOptions, + ...transparentModalScreenOptions, }} /> @@ -425,12 +425,8 @@ const PerpsScreenStack = () => { name={Routes.PERPS.MODALS.ROOT} component={PerpsModalStack} options={{ - headerShown: false, - contentStyle: { - backgroundColor: 'transparent', - }, - animation: 'none', - presentation: 'transparentModal', + ...clearNativeStackNavigatorOptions, + ...transparentModalScreenOptions, }} /> @@ -442,7 +438,7 @@ const PerpsScreenStack = () => { component={PayWithModal} options={{ headerShown: false, - presentation: 'transparentModal', + ...transparentModalScreenOptions, }} /> diff --git a/app/components/UI/Perps/selectors/perpsController/index.test.ts b/app/components/UI/Perps/selectors/perpsController/index.test.ts index 2661bfebad4..c28adccaedb 100644 --- a/app/components/UI/Perps/selectors/perpsController/index.test.ts +++ b/app/components/UI/Perps/selectors/perpsController/index.test.ts @@ -93,7 +93,8 @@ describe('PerpsController Selectors', () => { it('returns account state from PerpsController', () => { // Arrange const mockAccountState: AccountState = { - availableBalance: '3000', + spendableBalance: '3000', + withdrawableBalance: '3000', marginUsed: '1000', unrealizedPnl: '50', returnOnEquity: '10.0', @@ -138,7 +139,8 @@ describe('PerpsController Selectors', () => { it('handles zero balance account state', () => { // Arrange const mockAccountState: AccountState = { - availableBalance: '0', + spendableBalance: '0', + withdrawableBalance: '0', marginUsed: '0', unrealizedPnl: '0', returnOnEquity: '0', @@ -154,14 +156,15 @@ describe('PerpsController Selectors', () => { // Assert expect(result).toEqual(mockAccountState); - expect(result?.availableBalance).toBe('0'); + expect(result?.spendableBalance).toBe('0'); expect(result?.totalBalance).toBe('0'); }); it('handles account with positive PnL', () => { // Arrange const positivePnlState: AccountState = { - availableBalance: '5000', + spendableBalance: '5000', + withdrawableBalance: '5000', marginUsed: '1000', unrealizedPnl: '500', returnOnEquity: '100.0', @@ -183,7 +186,8 @@ describe('PerpsController Selectors', () => { it('handles account with negative PnL', () => { // Arrange const negativePnlState: AccountState = { - availableBalance: '3000', + spendableBalance: '3000', + withdrawableBalance: '3000', marginUsed: '2000', unrealizedPnl: '-500', returnOnEquity: '-25.0', @@ -540,7 +544,8 @@ describe('PerpsController Selectors', () => { const mockState = createMockState({ activeProvider: 'testProvider', accountState: { - availableBalance: '1000', + spendableBalance: '1000', + withdrawableBalance: '1000', totalBalance: '1000', marginUsed: '0', unrealizedPnl: '0', @@ -582,7 +587,8 @@ describe('PerpsController Selectors', () => { const complexState = createMockState({ activeProvider: 'nestedProvider', accountState: { - availableBalance: '5000', + spendableBalance: '5000', + withdrawableBalance: '5000', totalBalance: '6000', marginUsed: '1000', unrealizedPnl: '100', @@ -609,7 +615,8 @@ describe('PerpsController Selectors', () => { // Then - they extract only the relevant data expect(provider).toBe('nestedProvider'); expect(account).toEqual({ - availableBalance: '5000', + spendableBalance: '5000', + withdrawableBalance: '5000', totalBalance: '6000', marginUsed: '1000', unrealizedPnl: '100', diff --git a/app/components/UI/Perps/types/navigation.ts b/app/components/UI/Perps/types/navigation.ts index c8c98c56985..58ee90e2599 100644 --- a/app/components/UI/Perps/types/navigation.ts +++ b/app/components/UI/Perps/types/navigation.ts @@ -5,6 +5,7 @@ import { type OrderType, type PerpsMarketData, type TPSLTrackingData, + type SortOptionId, } from '@metamask/perps-controller'; import { PerpsTransaction } from './transactionHistory'; import type { DataMonitorParams } from '../hooks/usePerpsDataMonitor'; @@ -90,6 +91,7 @@ export interface PerpsNavigationParamList extends ParamListBase { | 'commodities' | 'forex' | 'new'; + defaultSortOptionId?: SortOptionId; fromHome?: boolean; button_clicked?: string; button_location?: string; diff --git a/app/components/UI/Perps/utils/e2eBridgePerps.deeplink.test.ts b/app/components/UI/Perps/utils/e2eBridgePerps.deeplink.test.ts index b7053d990cf..b8064f20ffc 100644 --- a/app/components/UI/Perps/utils/e2eBridgePerps.deeplink.test.ts +++ b/app/components/UI/Perps/utils/e2eBridgePerps.deeplink.test.ts @@ -33,7 +33,7 @@ jest.mock( PerpsE2EMockService: { getInstance: () => ({ reset: jest.fn(), - getMockAccountState: () => ({ availableBalance: '10000.00' }), + getMockAccountState: () => ({ spendableBalance: '10000.00' }), getMockPositions: () => [], getMockMarkets: () => [], mockPushPrice, diff --git a/app/components/UI/Perps/utils/e2eBridgePerps.liquidation.test.ts b/app/components/UI/Perps/utils/e2eBridgePerps.liquidation.test.ts index 6a18ecab7f3..048fd4fc558 100644 --- a/app/components/UI/Perps/utils/e2eBridgePerps.liquidation.test.ts +++ b/app/components/UI/Perps/utils/e2eBridgePerps.liquidation.test.ts @@ -12,7 +12,8 @@ jest.mock('react-native', () => { const mockGetInstance = () => ({ reset: jest.fn(), getMockAccountState: () => ({ - availableBalance: '8000.00', + spendableBalance: '8000.00', + withdrawableBalance: '8000.00', marginUsed: '2000.00', unrealizedPnl: '0.00', returnOnEquity: '0', diff --git a/app/components/UI/Perps/utils/e2eBridgePerps.test.ts b/app/components/UI/Perps/utils/e2eBridgePerps.test.ts index a2ee4ce08e5..2fcb2b94833 100644 --- a/app/components/UI/Perps/utils/e2eBridgePerps.test.ts +++ b/app/components/UI/Perps/utils/e2eBridgePerps.test.ts @@ -41,12 +41,13 @@ describe('e2eBridgePerps (no UI)', () => { const account = await ( controller.getAccountState as () => Promise<{ - availableBalance: string; + spendableBalance: string; + withdrawableBalance: string; }> )(); expect(account).toBeTruthy(); - expect(account.availableBalance).toBeDefined(); + expect(account.spendableBalance).toBeDefined(); }); it('exposes a mock stream manager in E2E', () => { @@ -124,7 +125,7 @@ describe('e2eBridgePerps - isE2E switch', () => { const mockReset = jest.fn(); const mockService = { reset: mockReset, - getMockAccountState: () => ({ availableBalance: '1000' }), + getMockAccountState: () => ({ spendableBalance: '1000' }), getMockPositions: () => [], getMockMarkets: () => [], }; diff --git a/app/components/UI/Perps/utils/e2eBridgePerps.ts b/app/components/UI/Perps/utils/e2eBridgePerps.ts index dad574ad35d..6522ea8fd93 100644 --- a/app/components/UI/Perps/utils/e2eBridgePerps.ts +++ b/app/components/UI/Perps/utils/e2eBridgePerps.ts @@ -195,7 +195,7 @@ function autoConfigureE2EBridge(): void { DevLogger.log('E2E Bridge auto-configured successfully'); DevLogger.log('Mock state:', { - accountBalance: mockService.getMockAccountState().availableBalance, + accountBalance: mockService.getMockAccountState().spendableBalance, positionsCount: mockService.getMockPositions().length, marketsCount: mockService.getMockMarkets().length, }); diff --git a/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts b/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts index 404b386961c..9a704cddcc9 100644 --- a/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts +++ b/app/components/UI/Perps/utils/hyperLiquidAdapter.test.ts @@ -1074,8 +1074,8 @@ describe('hyperLiquidAdapter', () => { const result = adaptAccountStateFromSDK(perpsState); expect(result).toEqual({ - availableBalance: '700.25', - availableToTradeBalance: '700.25', // withdrawable + free spot (no spot provided) + spendableBalance: '700.25', + withdrawableBalance: '700.25', marginUsed: '300.25', unrealizedPnl: '24.5', // 50.0 + (-25.5) returnOnEquity: '7.991673605328893', // Calculated from weighted return and margin diff --git a/app/components/UI/Perps/utils/orderCalculations.test.ts b/app/components/UI/Perps/utils/orderCalculations.test.ts index fcec7b02062..47afe5d14ac 100644 --- a/app/components/UI/Perps/utils/orderCalculations.test.ts +++ b/app/components/UI/Perps/utils/orderCalculations.test.ts @@ -260,7 +260,7 @@ describe('orderCalculations', () => { it('should return 0 when available balance is 0', () => { // Arrange const params = { - availableBalance: 0, + spendableBalance: 0, assetPrice: 50000, assetSzDecimals: 6, leverage: 10, @@ -276,7 +276,7 @@ describe('orderCalculations', () => { it('should return 0 when asset price is invalid', () => { // Arrange const params = { - availableBalance: 1000, + spendableBalance: 1000, assetPrice: 0, assetSzDecimals: 6, leverage: 10, @@ -292,7 +292,7 @@ describe('orderCalculations', () => { it('should return 0 when szDecimals is undefined', () => { // Arrange const params = { - availableBalance: 1000, + spendableBalance: 1000, assetPrice: 50000, assetSzDecimals: undefined as unknown as number, leverage: 10, @@ -308,7 +308,7 @@ describe('orderCalculations', () => { it('should calculate max allowed amount with leverage', () => { // Arrange const params = { - availableBalance: 100, + spendableBalance: 100, assetPrice: 50000, assetSzDecimals: 6, leverage: 10, @@ -325,7 +325,7 @@ describe('orderCalculations', () => { it('should handle high leverage scenarios', () => { // Arrange const params = { - availableBalance: 50, + spendableBalance: 50, assetPrice: 30000, assetSzDecimals: 4, leverage: 100, @@ -342,7 +342,7 @@ describe('orderCalculations', () => { it('should account for position size rounding', () => { // Arrange const params = { - availableBalance: 10, + spendableBalance: 10, assetPrice: 50000, assetSzDecimals: 6, leverage: 5, @@ -359,7 +359,7 @@ describe('orderCalculations', () => { it('should apply margin buffer so result is below theoretical max', () => { // Arrange - case where theoretical max is 1000 (100 * 10) const params = { - availableBalance: 100, + spendableBalance: 100, assetPrice: 50000, assetSzDecimals: 6, leverage: 10, @@ -367,7 +367,7 @@ describe('orderCalculations', () => { // Act const result = getMaxAllowedAmount(params); - const theoreticalMax = params.availableBalance * params.leverage; + const theoreticalMax = params.spendableBalance * params.leverage; // Assert - buffer (0.5%) reduces max to avoid "Insufficient margin" rejections expect(result).toBeGreaterThan(0); diff --git a/app/components/UI/PhishingModal/PhishingModal.testIds.ts b/app/components/UI/PhishingModal/PhishingModal.testIds.ts new file mode 100644 index 00000000000..3346e5bb952 --- /dev/null +++ b/app/components/UI/PhishingModal/PhishingModal.testIds.ts @@ -0,0 +1,3 @@ +export const PhishingModalSelectorsIDs = { + DETECTION_TITLE: 'ethereum-detection-title', +}; diff --git a/app/components/UI/PhishingModal/index.js b/app/components/UI/PhishingModal/index.js index 9cf64191756..0e44773a893 100644 --- a/app/components/UI/PhishingModal/index.js +++ b/app/components/UI/PhishingModal/index.js @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - Platform, Linking, TouchableOpacity, } from 'react-native'; @@ -13,8 +12,7 @@ import { fontStyles } from '../../../styles/common'; import { strings } from '../../../../locales/i18n'; import URL from 'url-parse'; import { ThemeContext, mockTheme } from '../../../util/theme'; -import generateTestId from '../../../../wdio/utils/generateTestId'; -import { ETHEREUM_DETECTION_TITLE } from '../../../../wdio/screen-objects/testIDs/BrowserScreen/ExternalWebsites.testIds'; +import { PhishingModalSelectorsIDs } from './PhishingModal.testIds'; import Button from '../../../component-library/components/Buttons/Button/Button'; import { ButtonVariants, @@ -146,7 +144,7 @@ export default class PhishingModal extends PureComponent { {strings('phishing.site_might_be_harmful')} diff --git a/app/components/UI/Predict/Predict.testIds.ts b/app/components/UI/Predict/Predict.testIds.ts index ff8380c7136..e5c2cef25b3 100644 --- a/app/components/UI/Predict/Predict.testIds.ts +++ b/app/components/UI/Predict/Predict.testIds.ts @@ -31,6 +31,7 @@ export const PredictMarketListSelectorsIDs = { CRYPTO_TAB: 'predict-market-list-crypto-tab', POLITICS_TAB: 'predict-market-list-politics-tab', BACK_BUTTON: 'back-button', + TRENDING_MARKET_CARD: 'predict-market-list-trending-card-', // Empty state EMPTY_STATE: 'predict-market-list-empty-state', } as const; diff --git a/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx b/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx index 7ef643e9f59..2c85e7bb433 100644 --- a/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx +++ b/app/components/UI/Predict/components/PredictActivity/PredictActivity.tsx @@ -3,9 +3,6 @@ import { TouchableOpacity } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, - BoxAlignItems, - BoxFlexDirection, - BoxJustifyContent, Icon, IconName, Text, @@ -76,46 +73,40 @@ const PredictActivity: React.FC = ({ item }) => { }; return ( - - - - - {item.icon ? ( - - ) : ( - - )} - - + + + {item.icon ? ( + + ) : ( + + )} + - - - {activityTitleByType[item.type]} - - - {item.marketTitle} - - + + + {activityTitleByType[item.type]} + + + {item.marketTitle} + + - - - {signedAmount} + + + {signedAmount} + + {item.percentChange !== undefined ? ( + + {formatPercentage(item.percentChange)} - {item.percentChange !== undefined ? ( - - {formatPercentage(item.percentChange)} - - ) : null} - + ) : null} ); diff --git a/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.tsx b/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.tsx index f1cb601ad68..e6e4e92df44 100644 --- a/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.tsx +++ b/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.tsx @@ -27,9 +27,9 @@ import { BoxFlexDirection, BoxAlignItems, BoxJustifyContent, + HeaderStandard, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import UsdcIcon from './usdc.svg'; import { PredictActivityDetailsSelectorsIDs } from '../../Predict.testIds'; interface PredictActivityDetailProps {} @@ -413,7 +413,7 @@ const PredictActivityDetails: React.FC = () => { testID={PredictActivityDetailsSelectorsIDs.CONTAINER} > - = ({ )} - ); } @@ -586,14 +581,14 @@ const PredictDetailsChart: React.FC = ({ }; return ( - + <> {renderGraph()} - + ); }; diff --git a/app/components/UI/Predict/components/PredictDetailsChart/components/TimeframeSelector.tsx b/app/components/UI/Predict/components/PredictDetailsChart/components/TimeframeSelector.tsx index d88bbfd54bb..3677d955221 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/components/TimeframeSelector.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/components/TimeframeSelector.tsx @@ -5,7 +5,6 @@ import { Box, BoxFlexDirection, BoxAlignItems, - BoxJustifyContent, } from '@metamask/design-system-react-native'; import Text, { TextColor, @@ -29,39 +28,33 @@ const TimeframeSelector: React.FC = ({ - - {timeframes.map((timeframe) => ( - onTimeframeChange(timeframe)} - style={({ pressed }) => - tw.style( - 'flex-1 py-2 rounded-lg', - selectedTimeframe === timeframe ? 'bg-muted' : 'bg-default', - pressed && 'bg-pressed', - ) + {timeframes.map((timeframe) => ( + onTimeframeChange(timeframe)} + style={({ pressed }) => + tw.style( + 'flex-1 py-2 rounded-lg', + selectedTimeframe === timeframe ? 'bg-muted' : 'bg-default', + pressed && 'bg-pressed', + ) + } + > + - - {timeframe.toUpperCase()} - - - ))} - + {timeframe.toUpperCase()} + + + ))} ); }; diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx index 1fef385dea2..7dcc0b4c590 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx @@ -80,6 +80,7 @@ const PredictGameDetailsContent: React.FC = ({ marketId: market.id, childMarketIds: market.childMarketIds, claimable: false, + livePriceUpdates: true, }); const { data: claimablePositions = [] } = usePredictPositions({ marketId: market.id, diff --git a/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedCarousel.test.tsx b/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedCarousel.test.tsx index 530e0b3a004..6f5cf17cdc9 100644 --- a/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedCarousel.test.tsx +++ b/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedCarousel.test.tsx @@ -36,6 +36,7 @@ jest.mock('@metamask/design-system-react-native', () => { ), BoxFlexDirection: { Row: 'row' }, BoxAlignItems: { Center: 'center' }, + BoxBorderColor: { BorderDefault: 'border-default' }, Text: ({ children, testID, @@ -70,28 +71,77 @@ jest.mock('@metamask/design-system-react-native', () => { }; }); -const mockSectionFn = jest.fn(); +const mockUsePredictionsFeed = jest.fn(); jest.mock( - '../../../../Views/TrendingView/components/Sections/Section', - () => - function MockSection(props: { sectionId: string }) { - mockSectionFn(props); + '../../../../Views/TrendingView/feeds/predictions/usePredictionsFeed', + () => ({ + usePredictionsFeed: (...args: unknown[]) => mockUsePredictionsFeed(...args), + }), +); +jest.mock( + '../../../../Views/TrendingView/feeds/predictions/PredictionRowItem', + () => ({ + PredictionCarouselRowItem: ({ + market, + }: { + market: { id: string; title: string }; + }) => { const ReactNative = jest.requireActual('react-native'); return ( - - Section: {props.sectionId} + + {market.title} ); }, + }), ); - -jest.mock('../../../../Views/TrendingView/sections.config', () => ({ - SECTIONS_CONFIG: { - predictions: { - id: 'predictions', +jest.mock( + '../../../../Views/TrendingView/feeds/predictions/PredictionsSkeleton', + () => ({ + __esModule: true, + default: () => { + const ReactNative = jest.requireActual('react-native'); + return ; }, + }), +); + +jest.mock( + '../../../../Views/TrendingView/components/HorizontalCarousel', + () => { + const ReactNative = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + data, + isLoading, + renderItem, + Skeleton, + }: { + data: { id: string }[]; + isLoading: boolean; + renderItem: (info: { + item: { id: string }; + index: number; + target: 'Cell'; + }) => React.ReactNode; + Skeleton: React.ComponentType; + }) => ( + + {isLoading ? ( + + ) : ( + data.map((item, index) => ( + + {renderItem({ item, index, target: 'Cell' })} + + )) + )} + + ), + }; }, -})); +); jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => { @@ -131,6 +181,14 @@ describe('PredictHomeFeaturedCarousel', () => { mockUseNavigation.mockReturnValue( mockNavigation as unknown as ReturnType, ); + mockUsePredictionsFeed.mockReturnValue({ + data: [ + { id: 'm1', title: 'Market 1' }, + { id: 'm2', title: 'Market 2' }, + ], + isLoading: false, + refetch: jest.fn(), + }); }); afterEach(() => { @@ -152,11 +210,24 @@ describe('PredictHomeFeaturedCarousel', () => { expect(screen.getByText('Trending')).toBeOnTheScreen(); }); - it('renders Section component with predictions sectionId', () => { + it('renders the horizontal carousel with predictions data', () => { + render(); + + expect(screen.getByTestId('mock-horizontal-carousel')).toBeOnTheScreen(); + expect(screen.getByTestId('prediction-row-m1')).toBeOnTheScreen(); + expect(screen.getByTestId('prediction-row-m2')).toBeOnTheScreen(); + }); + + it('shows skeleton while loading', () => { + mockUsePredictionsFeed.mockReturnValue({ + data: [], + isLoading: true, + refetch: jest.fn(), + }); + render(); - expect(screen.getByTestId('mock-section')).toBeOnTheScreen(); - expect(screen.getByText('Section: predictions')).toBeOnTheScreen(); + expect(screen.getByTestId('predictions-skeleton')).toBeOnTheScreen(); }); it('renders header with correct testID', () => { @@ -196,17 +267,12 @@ describe('PredictHomeFeaturedCarousel', () => { }); }); - describe('Section integration', () => { - it('passes correct props to Section component', () => { + describe('feed integration', () => { + it('subscribes to the trending predictions feed', () => { render(); - expect(mockSectionFn).toHaveBeenCalledWith( - expect.objectContaining({ - sectionId: 'predictions', - refreshConfig: { trigger: 0, silentRefresh: true }, - toggleSectionEmptyState: expect.any(Function), - toggleSectionLoadingState: expect.any(Function), - }), + expect(mockUsePredictionsFeed).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'trending' }), ); }); }); diff --git a/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedCarousel.tsx b/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedCarousel.tsx index 291ee5e029d..0902021b559 100644 --- a/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedCarousel.tsx +++ b/app/components/UI/Predict/components/PredictHome/PredictHomeFeaturedCarousel.tsx @@ -14,8 +14,12 @@ import { TextColor, TextVariant, } from '@metamask/design-system-react-native'; -import Section from '../../../../Views/TrendingView/components/Sections/Section'; -import { SECTIONS_CONFIG } from '../../../../Views/TrendingView/sections.config'; +import type { ListRenderItem } from '@shopify/flash-list'; +import HorizontalCarousel from '../../../../Views/TrendingView/components/HorizontalCarousel'; +import { usePredictionsFeed } from '../../../../Views/TrendingView/feeds/predictions/usePredictionsFeed'; +import { PredictionCarouselRowItem } from '../../../../Views/TrendingView/feeds/predictions/PredictionRowItem'; +import PredictionsSkeleton from '../../../../Views/TrendingView/feeds/predictions/PredictionsSkeleton'; +import type { PredictMarket as PredictMarketType } from '../../types'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { PredictEventValues } from '../../constants/eventNames'; @@ -31,15 +35,7 @@ const PredictHomeFeaturedCarousel: React.FC< > = ({ testID = PREDICT_HOME_FEATURED_CAROUSEL_TEST_IDS.CAROUSEL }) => { const tw = useTailwind(); const navigation = useNavigation(); - const section = SECTIONS_CONFIG.predictions; - - const handleToggleEmptyState = useCallback((_isEmpty: boolean) => { - // TODO: Toggle empty state - }, []); - - const handleToggleLoadingState = useCallback((_isLoading: boolean) => { - // TODO: Toggle loading state - }, []); + const predictions = usePredictionsFeed({ variant: 'trending' }); const handleHeaderPress = useCallback(() => { navigation.navigate(Routes.PREDICT.ROOT, { @@ -50,6 +46,16 @@ const PredictHomeFeaturedCarousel: React.FC< }); }, [navigation]); + const renderItem: ListRenderItem = useCallback( + ({ item }) => ( + + ), + [], + ); + return ( -
+ data={predictions.data} + isLoading={predictions.isLoading} + renderItem={renderItem} + Skeleton={PredictionsSkeleton} + idPrefix="predict-home-featured" /> diff --git a/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx b/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx index 5753c9818ec..9a2c259a40e 100644 --- a/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx +++ b/app/components/UI/Predict/components/PredictHome/PredictHomePositions.tsx @@ -42,7 +42,7 @@ const PredictHomePositions = forwardRef< refetch, isLoading: isActiveLoading, error: activeError, - } = usePredictPositions({ claimable: false }); + } = usePredictPositions({ claimable: false, livePriceUpdates: true }); const { data: claimablePositions = [], diff --git a/app/components/UI/Predict/components/PredictMarket/PredictMarket.tsx b/app/components/UI/Predict/components/PredictMarket/PredictMarket.tsx index f14340b2839..b81fc740555 100644 --- a/app/components/UI/Predict/components/PredictMarket/PredictMarket.tsx +++ b/app/components/UI/Predict/components/PredictMarket/PredictMarket.tsx @@ -12,6 +12,10 @@ interface PredictMarketProps { testID?: string; entryPoint?: PredictEntryPoint; isCarousel?: boolean; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; + /** Called when the user taps a buy button (before betslip opens). */ + onBuyButtonPress?: (marketId: string) => void; } const PredictMarket: React.FC = ({ @@ -19,6 +23,8 @@ const PredictMarket: React.FC = ({ testID, entryPoint: propEntryPoint, isCarousel = false, + onCardPress, + onBuyButtonPress, }) => { const contextEntryPoint = usePredictEntryPoint(); const entryPoint = @@ -32,6 +38,8 @@ const PredictMarket: React.FC = ({ testID={testID} entryPoint={entryPoint} isCarousel={isCarousel} + onCardPress={onCardPress} + onBuyButtonPress={onBuyButtonPress} /> ); } @@ -43,6 +51,8 @@ const PredictMarket: React.FC = ({ testID={testID} entryPoint={entryPoint} isCarousel={isCarousel} + onCardPress={onCardPress} + onBuyButtonPress={onBuyButtonPress} /> ); } @@ -53,6 +63,8 @@ const PredictMarket: React.FC = ({ testID={testID} entryPoint={entryPoint} isCarousel={isCarousel} + onCardPress={onCardPress} + onBuyButtonPress={onBuyButtonPress} /> ); }; diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx index 6e29aa63367..3dab46dbe3b 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx @@ -50,6 +50,10 @@ interface PredictMarketMultipleProps { testID?: string; entryPoint?: PredictEntryPoint; isCarousel?: boolean; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; + /** Called when the user taps a buy button (before betslip opens). */ + onBuyButtonPress?: (marketId: string) => void; } const PredictMarketMultiple: React.FC = ({ @@ -57,6 +61,8 @@ const PredictMarketMultiple: React.FC = ({ testID, entryPoint: propEntryPoint, isCarousel = false, + onCardPress, + onBuyButtonPress, }) => { const contextEntryPoint = usePredictEntryPoint(); const baseEntryPoint = @@ -137,6 +143,7 @@ const PredictMarketMultiple: React.FC = ({ outcome: PredictOutcome, outcomeToken: PredictOutcomeToken, ) => { + onBuyButtonPress?.(market.id); executeGuardedAction( () => { openBuySheet({ @@ -161,6 +168,7 @@ const PredictMarketMultiple: React.FC = ({ { + onCardPress?.(); navigation.navigate(Routes.PREDICT.ROOT, { screen: Routes.PREDICT.MARKET_DETAILS, params: { diff --git a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.styles.ts b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.styles.ts index fdff4f06ad7..f6a7cd1c520 100644 --- a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.styles.ts +++ b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.styles.ts @@ -15,12 +15,6 @@ const styleSheet = (params: { theme: Theme }) => { padding: 16, marginBottom: 16, }, - marketHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - width: '100%', - }, marketFooter: { flexDirection: 'row', justifyContent: 'flex-end', diff --git a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx index a865aef54f5..a341bbc9024 100644 --- a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx +++ b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx @@ -111,81 +111,74 @@ const PredictMarketOutcome: React.FC = ({ return ( - - - - {getImageUrl() ? ( - - ) : ( - - )} - - + + + {getImageUrl() ? ( + + ) : ( + + )} + + + + {getTitle()} + + + ${getVolumeDisplay()} {strings('predict.volume_abbreviated')} + + + {isClosed && outcomeToken ? ( + - {getTitle()} - - - ${getVolumeDisplay()} {strings('predict.volume_abbreviated')} + {outcomeToken.price === 1 + ? strings('predict.outcome_winner') + : strings('predict.outcome_loser')} - - - {isClosed && outcomeToken ? ( - - - {outcomeToken.price === 1 - ? strings('predict.outcome_winner') - : strings('predict.outcome_loser')} - - {outcomeToken.price === 1 && ( - - )} - - ) : ( - - {getYesPercentage()} - + {outcomeToken.price === 1 && ( + )} - - + ) : ( + + {getYesPercentage()} + + )} + {!isClosed && ( + + + + + + + + ); +}; + +export default CampaignWinningView; diff --git a/app/components/UI/Rewards/Views/CampaignsView.test.tsx b/app/components/UI/Rewards/Views/CampaignsView.test.tsx index 0bca5c0bfef..8baa68c8091 100644 --- a/app/components/UI/Rewards/Views/CampaignsView.test.tsx +++ b/app/components/UI/Rewards/Views/CampaignsView.test.tsx @@ -6,6 +6,8 @@ import { CampaignType, } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; const mockGoBack = jest.fn(); @@ -13,11 +15,6 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ goBack: mockGoBack }), })); -jest.mock('@metamask/design-system-react-native', () => { - const actual = jest.requireActual('@metamask/design-system-react-native'); - return { ...actual }; -}); - jest.mock('@metamask/design-system-twrnc-preset', () => ({ useTailwind: () => ({ style: (...args: unknown[]) => args }), })); @@ -30,11 +27,18 @@ const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< jest.mock('../hooks/useOndoOutcomeToast', () => ({ useOndoOutcomeToast: jest.fn(), })); -import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; const mockUseOndoOutcomeToast = useOndoOutcomeToast as jest.MockedFunction< typeof useOndoOutcomeToast >; +jest.mock('../hooks/usePerpsTradingCampaignEndedOutcomeToast', () => ({ + usePerpsTradingCampaignEndedOutcomeToast: jest.fn(), +})); +const mockUsePerpsTradingCampaignEndedOutcomeToast = + usePerpsTradingCampaignEndedOutcomeToast as jest.MockedFunction< + typeof usePerpsTradingCampaignEndedOutcomeToast + >; + jest.mock('../components/Campaigns/CampaignsGroup', () => { const ReactActual = jest.requireActual('react'); const { View, Text } = jest.requireActual('react-native'); @@ -93,27 +97,6 @@ jest.mock('../components/RewardsErrorBanner', () => { }; }); -jest.mock( - '../../../../component-library/components-temp/HeaderCompactStandard', - () => { - const ReactActual = jest.requireActual('react'); - const { View, Text, Pressable } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ title, onBack }: { title: string; onBack: () => void }) => - ReactActual.createElement( - View, - { testID: 'header' }, - ReactActual.createElement(Text, null, title), - ReactActual.createElement(Pressable, { - onPress: onBack, - testID: 'header-back-button', - }), - ), - }; - }, -); - jest.mock('../../../Views/ErrorBoundary', () => { const ReactActual = jest.requireActual('react'); return { @@ -173,7 +156,6 @@ describe('CampaignsView', () => { beforeEach(() => { jest.clearAllMocks(); mockUseRewardCampaigns.mockReturnValue(hookDefaults); - mockUseOndoOutcomeToast.mockReturnValue(undefined); }); it('renders the header with the correct title', () => { @@ -185,6 +167,15 @@ describe('CampaignsView', () => { expect(getByText('Campaigns')).toBeOnTheScreen(); }); + it('mounts campaign outcome toast hooks on render', () => { + render(); + + expect(mockUseOndoOutcomeToast).toHaveBeenCalledTimes(1); + expect(mockUsePerpsTradingCampaignEndedOutcomeToast).toHaveBeenCalledTimes( + 1, + ); + }); + it('navigates back when the back button is pressed', () => { const { getByTestId } = render(); @@ -374,11 +365,4 @@ describe('CampaignsView', () => { expect(queryByText('Refreshing...')).toBeNull(); }); }); - - describe('hook integration', () => { - it('calls useOndoOutcomeToast on render', () => { - render(); - expect(mockUseOndoOutcomeToast).toHaveBeenCalledTimes(1); - }); - }); }); diff --git a/app/components/UI/Rewards/Views/CampaignsView.tsx b/app/components/UI/Rewards/Views/CampaignsView.tsx index b4b89d1ec1d..c47181cdbc2 100644 --- a/app/components/UI/Rewards/Views/CampaignsView.tsx +++ b/app/components/UI/Rewards/Views/CampaignsView.tsx @@ -9,18 +9,19 @@ import { TextVariant, BoxFlexDirection, BoxAlignItems, + HeaderStandard, Skeleton, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; import ErrorBoundary from '../../../Views/ErrorBoundary'; -import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; -import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; import RewardsErrorBanner from '../components/RewardsErrorBanner'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; import CampaignsGroup from '../components/Campaigns/CampaignsGroup'; import { strings } from '../../../../../locales/i18n'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; /** * CampaignsView displays all campaigns organized by status: @@ -31,9 +32,10 @@ import { strings } from '../../../../../locales/i18n'; const CampaignsView: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); - useOndoOutcomeToast(); const { categorizedCampaigns, isLoading, hasError, fetchCampaigns } = useRewardCampaigns(); + useOndoOutcomeToast(); + usePerpsTradingCampaignEndedOutcomeToast(); useTrackRewardsPageView({ page_type: 'campaigns_overview' }); @@ -108,7 +110,7 @@ const CampaignsView: React.FC = () => { style={tw.style('flex-1 bg-default')} testID={REWARDS_VIEW_SELECTORS.CAMPAIGNS_VIEW} > - navigation.goBack()} backButtonProps={{ testID: 'header-back-button' }} diff --git a/app/components/UI/Rewards/Views/MusdCalculatorView.test.tsx b/app/components/UI/Rewards/Views/MusdCalculatorView.test.tsx index afccd990c34..26bc16781ec 100644 --- a/app/components/UI/Rewards/Views/MusdCalculatorView.test.tsx +++ b/app/components/UI/Rewards/Views/MusdCalculatorView.test.tsx @@ -29,26 +29,6 @@ jest.mock('../../../Views/ErrorBoundary', () => ({ default: ({ children }: { children: React.ReactNode }) => children, })); -jest.mock( - '../../../../component-library/components-temp/HeaderCompactStandard', - () => { - const ReactActual = jest.requireActual('react'); - const { View, Pressable } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ onBack }: { title: string; onBack: () => void }) => - ReactActual.createElement( - View, - { testID: 'header' }, - ReactActual.createElement(Pressable, { - onPress: onBack, - testID: 'header-back-button', - }), - ), - }; - }, -); - jest.mock('../components/Tabs/MusdCalculatorTab/MusdCalculatorTab', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); diff --git a/app/components/UI/Rewards/Views/MusdCalculatorView.tsx b/app/components/UI/Rewards/Views/MusdCalculatorView.tsx index 1fa1f09275e..562b6c5301b 100644 --- a/app/components/UI/Rewards/Views/MusdCalculatorView.tsx +++ b/app/components/UI/Rewards/Views/MusdCalculatorView.tsx @@ -1,9 +1,9 @@ import React from 'react'; +import { HeaderStandard } from '@metamask/design-system-react-native'; import { useNavigation } from '@react-navigation/native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import ErrorBoundary from '../../../Views/ErrorBoundary'; -import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import MusdCalculatorTab from '../components/Tabs/MusdCalculatorTab/MusdCalculatorTab'; import { strings } from '../../../../../locales/i18n'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; @@ -20,9 +20,10 @@ const MusdCalculatorView: React.FC = () => { edges={{ top: 'additive' }} style={tw.style('flex-1 bg-default')} > - navigation.goBack()} + backButtonProps={{ testID: 'header-back-button' }} /> diff --git a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx index f51095950e9..3ffe4a385b8 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.test.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { render, fireEvent, act } from '@testing-library/react-native'; import OndoCampaignDetailsView, { CAMPAIGN_DETAILS_TEST_IDS, + resetOndoCampaignDetailsSessionAutoNavigationForTests, } from './OndoCampaignDetailsView'; -import { CAMPAIGN_STATS_SUMMARY_TEST_IDS } from '../components/Campaigns/CampaignStatsSummary'; +import { ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS } from '../components/Campaigns/OndoCampaignStatsSummary'; import { ONDO_PRIZE_POOL_TEST_IDS } from '../components/Campaigns/OndoPrizePool'; import { CAMPAIGN_CTA_TEST_IDS } from '../components/Campaigns/CampaignOptInCta'; import { CAMPAIGN_ENDED_STATS_TEST_IDS } from '../components/Campaigns/CampaignEndedStats'; @@ -19,7 +20,12 @@ import { useGetOndoPortfolioPosition } from '../hooks/useGetOndoPortfolioPositio import { useGetOndoCampaignDeposits } from '../hooks/useGetOndoCampaignDeposits'; import { useOndoCampaignParticipantOutcome } from '../hooks/useOndoCampaignParticipantOutcome'; import Routes from '../../../../constants/navigation/Routes'; - +import { useSelector } from 'react-redux'; +import { selectReferralCode } from '../../../../reducers/rewards/selectors'; +import { + selectIsMetamaskNotificationsEnabled, + selectIsMetaMaskPushNotificationsEnabled, +} from '../../../../selectors/notifications'; const mockGoBack = jest.fn(); const mockNavigate = jest.fn(); const mockRouteState: { params: { campaignId?: string } } = { @@ -41,11 +47,12 @@ jest.mock('@metamask/design-system-react-native', () => { const actual = jest.requireActual('@metamask/design-system-react-native'); const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); - // Skeleton is absent from the installed design-system version; stub it so - // the loading-state render doesn't throw "Element type is invalid". const Skeleton = (props: Record) => ReactActual.createElement(View, { testID: 'skeleton', ...props }); - return { ...actual, Skeleton }; + return { + ...actual, + Skeleton, + }; }); jest.mock('@metamask/design-system-twrnc-preset', () => ({ @@ -58,42 +65,6 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ }, })); -jest.mock( - '../../../../component-library/components-temp/HeaderCompactStandard', - () => { - const ReactActual = jest.requireActual('react'); - const { View, Text, Pressable } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - title, - onBack, - endButtonIconProps, - }: { - title: string; - onBack: () => void; - endButtonIconProps?: { testID?: string; onPress?: () => void }[]; - }) => - ReactActual.createElement( - View, - { testID: 'header' }, - ReactActual.createElement(Text, null, title), - ReactActual.createElement(Pressable, { - onPress: onBack, - testID: 'header-back-button', - }), - ...(endButtonIconProps ?? []).map((btn, i) => - ReactActual.createElement(Pressable, { - key: i, - onPress: btn.onPress, - testID: btn.testID ?? `end-button-${i}`, - }), - ), - ), - }; - }, -); - jest.mock('../../../Views/ErrorBoundary', () => { const ReactActual = jest.requireActual('react'); return { @@ -153,18 +124,17 @@ jest.mock('../components/Campaigns/CampaignEndedStats', () => { }; }); -const mockCampaignStatsSummary = jest.fn(); -jest.mock('../components/Campaigns/CampaignStatsSummary', () => { +const mockOndoCampaignStatsSummary = jest.fn(); +jest.mock('../components/Campaigns/OndoCampaignStatsSummary', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); - const { CAMPAIGN_STATS_SUMMARY_TEST_IDS: actualTestIds } = jest.requireActual( - '../components/Campaigns/CampaignStatsSummary', - ); + const { ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS: actualTestIds } = + jest.requireActual('../components/Campaigns/OndoCampaignStatsSummary'); return { __esModule: true, - CAMPAIGN_STATS_SUMMARY_TEST_IDS: actualTestIds, + ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS: actualTestIds, default: (props: Record) => { - mockCampaignStatsSummary(props); + mockOndoCampaignStatsSummary(props); return ReactActual.createElement(View, { testID: actualTestIds.CONTAINER, }); @@ -172,18 +142,6 @@ jest.mock('../components/Campaigns/CampaignStatsSummary', () => { }; }); -jest.mock('../hooks/useRewardsToast', () => ({ - __esModule: true, - default: () => ({ - showToast: jest.fn(), - RewardsToastOptions: { - success: jest.fn(), - error: jest.fn(), - entriesClosed: jest.fn(() => ({ variant: 'icon' })), - }, - }), -})); - jest.mock('../hooks/useCampaignGeoRestriction', () => ({ __esModule: true, default: () => ({ isGeoRestricted: false, isGeoLoading: false }), @@ -316,7 +274,7 @@ jest.mock('../components/Campaigns/OndoPrizePool', () => { }); jest.mock('react-redux', () => ({ - useSelector: jest.fn(() => null), + useSelector: jest.fn(), })); const mockIsTokenTradingOpen = jest.fn(() => true); @@ -512,6 +470,7 @@ jest.mock('../../../../../locales/i18n', () => ({ 'rewards.campaign_details.competition_closed_description': 'Entries are now closed', 'rewards.ondo_campaign_portfolio.view_activity': 'View activity', + 'rewards.notifications_nudge.turn_on_button': 'Turn on', }; return translations[key] || key; }, @@ -555,8 +514,21 @@ const hookDefaults = { describe('OndoCampaignDetailsView', () => { beforeEach(() => { jest.clearAllMocks(); + resetOndoCampaignDetailsSessionAutoNavigationForTests(); + (useSelector as jest.Mock).mockImplementation((selector: unknown) => { + if (selector === selectReferralCode) { + return null; + } + if (selector === selectIsMetamaskNotificationsEnabled) { + return true; + } + if (selector === selectIsMetaMaskPushNotificationsEnabled) { + return true; + } + return null; + }); mockIsTokenTradingOpen.mockReturnValue(true); - mockCampaignStatsSummary.mockReset(); + mockOndoCampaignStatsSummary.mockReset(); mockUseRewardCampaigns.mockReturnValue(hookDefaults); mockUseGetCampaignParticipantStatus.mockReturnValue({ status: null, @@ -763,7 +735,7 @@ describe('OndoCampaignDetailsView', () => { expect(queryByTestId('campaign-how-it-works')).toBeNull(); }); - it('renders CampaignStatsSummary when user has portfolio positions', () => { + it('renders OndoCampaignStatsSummary when user has portfolio positions', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [createTestCampaign()], @@ -783,11 +755,11 @@ describe('OndoCampaignDetailsView', () => { }); const { getByTestId } = render(); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeDefined(); }); - it('does not render CampaignStatsSummary when participant has no positions', () => { + it('does not render OndoCampaignStatsSummary when participant has no positions', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [createTestCampaign()], @@ -800,7 +772,7 @@ describe('OndoCampaignDetailsView', () => { }); const { queryByTestId } = render(); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeNull(); }); }); @@ -940,7 +912,7 @@ describe('OndoCampaignDetailsView', () => { }); describe('stats summary and leaderboard', () => { - it('shows CampaignStatsSummary when participant is opted in with positions', () => { + it('shows OndoCampaignStatsSummary when participant is opted in with positions', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [createTestCampaign()], @@ -960,18 +932,18 @@ describe('OndoCampaignDetailsView', () => { }); const { getByTestId } = render(); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeDefined(); }); - it('does not show CampaignStatsSummary when not opted in and campaign is active', () => { + it('does not show OndoCampaignStatsSummary when not opted in and campaign is active', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [createTestCampaign()], }); const { queryByTestId } = render(); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeNull(); }); @@ -995,7 +967,7 @@ describe('OndoCampaignDetailsView', () => { ); expect(getByTestId('ondo-leaderboard')).toBeDefined(); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeNull(); }); @@ -1048,7 +1020,7 @@ describe('OndoCampaignDetailsView', () => { campaigns: [createTestCampaign()], }); const { getByTestId } = render(); - fireEvent.press(getByTestId('header-back-button')); + fireEvent.press(getByTestId('campaign-details-back-button')); expect(mockGoBack).toHaveBeenCalledTimes(1); }); @@ -1166,7 +1138,7 @@ describe('OndoCampaignDetailsView', () => { }); }); - describe('ineligible state — isIneligible prop passed to CampaignStatsSummary', () => { + describe('ineligible state — isIneligible prop passed to OndoCampaignStatsSummary', () => { const setupWithPositions = () => { mockUseGetCampaignParticipantStatus.mockReturnValue({ status: { optedIn: true, participantCount: 1 }, @@ -1201,7 +1173,7 @@ describe('OndoCampaignDetailsView', () => { }); setupWithPositions(); render(); - expect(mockCampaignStatsSummary).toHaveBeenCalledWith( + expect(mockOndoCampaignStatsSummary).toHaveBeenCalledWith( expect.objectContaining({ isIneligible: true }), ); }); @@ -1224,7 +1196,7 @@ describe('OndoCampaignDetailsView', () => { }); setupWithPositions(); render(); - expect(mockCampaignStatsSummary).toHaveBeenCalledWith( + expect(mockOndoCampaignStatsSummary).toHaveBeenCalledWith( expect.objectContaining({ isIneligible: false }), ); }); @@ -1266,7 +1238,7 @@ describe('OndoCampaignDetailsView', () => { refetch: jest.fn(), }); render(); - expect(mockCampaignStatsSummary).toHaveBeenCalledWith( + expect(mockOndoCampaignStatsSummary).toHaveBeenCalledWith( expect.objectContaining({ isIneligible: false }), ); }); @@ -1439,10 +1411,10 @@ describe('OndoCampaignDetailsView', () => { ); }); - it('passes winner outcome props to CampaignStatsSummary when campaign is complete', () => { + it('passes winner outcome props to OndoCampaignStatsSummary when campaign is complete', () => { setupWinner(); render(); - expect(mockCampaignStatsSummary).toHaveBeenCalledWith( + expect(mockOndoCampaignStatsSummary).toHaveBeenCalledWith( expect.objectContaining({ isCampaignComplete: true, outcomeStatus: 'pending', @@ -1451,7 +1423,7 @@ describe('OndoCampaignDetailsView', () => { ); }); - it('passes no outcome status to CampaignStatsSummary when user has no outcome', () => { + it('passes no outcome status to OndoCampaignStatsSummary when user has no outcome', () => { mockUseRewardCampaigns.mockReturnValue({ ...hookDefaults, campaigns: [ @@ -1473,7 +1445,7 @@ describe('OndoCampaignDetailsView', () => { }); render(); expect( - mockCampaignStatsSummary.mock.calls.at(-1)?.[0]?.outcomeStatus, + mockOndoCampaignStatsSummary.mock.calls.at(-1)?.[0]?.outcomeStatus, ).toBeUndefined(); }); @@ -1482,7 +1454,7 @@ describe('OndoCampaignDetailsView', () => { mockNavigate.mockClear(); render(); const onWinnerPress = - mockCampaignStatsSummary.mock.calls.at(-1)?.[0]?.onWinnerPress; + mockOndoCampaignStatsSummary.mock.calls.at(-1)?.[0]?.onWinnerPress; expect(typeof onWinnerPress).toBe('function'); onWinnerPress(); expect(mockNavigate).toHaveBeenCalledWith( diff --git a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx index 86dcef335d4..71cc64d0855 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignDetailsView.tsx @@ -20,7 +20,7 @@ import { BoxAlignItems, BoxFlexDirection, BoxJustifyContent, - FontWeight, + HeaderStandard, Icon, IconColor, IconName, @@ -28,12 +28,10 @@ import { Skeleton, Text, TextButton, - TextColor, TextVariant, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; -import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import CampaignStatus from '../components/Campaigns/CampaignStatus'; import CampaignHowItWorks from '../components/Campaigns/CampaignHowItWorks'; @@ -42,8 +40,8 @@ import OndoPortfolio from '../components/Campaigns/OndoPortfolio'; import OndoAccountPickerSheet from '../components/Campaigns/OndoAccountPickerSheet'; import OndoCampaignCTA from '../components/Campaigns/OndoCampaignCTA'; import OndoNotEligibleSheet from '../components/Campaigns/OndoNotEligibleSheet'; -import CampaignStatsSummary from '../components/Campaigns/CampaignStatsSummary'; import CampaignEndedStats from '../components/Campaigns/CampaignEndedStats'; +import OndoCampaignStatsSummary from '../components/Campaigns/OndoCampaignStatsSummary'; import OndoPrizePool from '../components/Campaigns/OndoPrizePool'; import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; import RewardsErrorBanner from '../components/RewardsErrorBanner'; @@ -61,7 +59,11 @@ import { CampaignType, OndoCampaignHowItWorks, } from '../../../../core/Engine/controllers/rewards-controller/types'; -import { getTierMinNetDeposit } from '../components/Campaigns/OndoLeaderboard.utils'; +import { + buildLeaderboardUserPosition, + getCampaignTierNames, + getTierMinNetDeposit, +} from '../components/Campaigns/OndoLeaderboard.utils'; import { isCampaignIneligible } from '../utils/ondoCampaignConstants'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; @@ -77,7 +79,6 @@ export const CAMPAIGN_DETAILS_TEST_IDS = { const sessionUpcomingRedirectCampaignIds = new Set(); const sessionWinningViewAutoNavCampaignIds = new Set(); - export function resetOndoCampaignDetailsSessionAutoNavigationForTests(): void { sessionUpcomingRedirectCampaignIds.clear(); sessionWinningViewAutoNavCampaignIds.clear(); @@ -223,20 +224,10 @@ const OndoCampaignDetailsView: React.FC = () => { defaultTier: leaderboardPosition?.projectedTier, }); - const tierNames = useMemo( - () => campaign?.details?.tiers?.map((t) => t.name) ?? [], - [campaign], - ); + const tierNames = useMemo(() => getCampaignTierNames(campaign), [campaign]); const leaderboardUserPosition = useMemo( - () => - leaderboardPosition - ? { - projectedTier: leaderboardPosition.projectedTier, - rank: leaderboardPosition.rank, - neighbors: leaderboardPosition.neighbors ?? [], - } - : null, + () => buildLeaderboardUserPosition(leaderboardPosition), [leaderboardPosition], ); @@ -305,7 +296,7 @@ const OndoCampaignDetailsView: React.FC = () => { style={tw.style('flex-1 bg-default')} testID={CAMPAIGN_DETAILS_TEST_IDS.CONTAINER} > - navigation.goBack()} backButtonProps={{ testID: 'campaign-details-back-button' }} @@ -414,7 +405,7 @@ const OndoCampaignDetailsView: React.FC = () => { /> - { {strings('rewards.ondo_campaign_leaderboard.title')} diff --git a/app/components/UI/Rewards/Views/OndoCampaignPortfolioView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignPortfolioView.test.tsx index 40abb02f4de..b1ef57681cd 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignPortfolioView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignPortfolioView.test.tsx @@ -21,32 +21,10 @@ jest.mock('@react-navigation/native', () => ({ useRoute: () => ({ params: { campaignId: 'campaign-1' } }), })); -jest.mock('@metamask/design-system-react-native', () => { - const actual = jest.requireActual('@metamask/design-system-react-native'); - return { ...actual }; -}); - jest.mock('@metamask/design-system-twrnc-preset', () => ({ useTailwind: () => ({ style: (...args: unknown[]) => args }), })); -jest.mock( - '../../../../component-library/components-temp/HeaderCompactStandard', - () => { - const ReactActual = jest.requireActual('react'); - const { View, Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ title }: { title: string }) => - ReactActual.createElement( - View, - { testID: 'header' }, - ReactActual.createElement(Text, null, title), - ), - }; - }, -); - jest.mock('../../../Views/ErrorBoundary', () => { const ReactActual = jest.requireActual('react'); return { diff --git a/app/components/UI/Rewards/Views/OndoCampaignPortfolioView.tsx b/app/components/UI/Rewards/Views/OndoCampaignPortfolioView.tsx index 7f5d17774f7..031ded151e1 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignPortfolioView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignPortfolioView.tsx @@ -10,6 +10,7 @@ import { import { Box, + HeaderStandard, IconName, Skeleton, Text, @@ -18,7 +19,6 @@ import { } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; -import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import OndoActivityRow from '../components/Campaigns/OndoActivityRow'; import RewardsErrorBanner from '../components/RewardsErrorBanner'; @@ -182,7 +182,7 @@ const OndoCampaignPortfolioView: React.FC = () => { style={tw.style('flex-1 bg-default')} testID={CAMPAIGN_PORTFOLIO_TEST_IDS.CONTAINER} > - navigation.goBack()} diff --git a/app/components/UI/Rewards/Views/OndoCampaignRwaSelectorView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignRwaSelectorView.test.tsx index 90700c27a10..6722d074598 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignRwaSelectorView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignRwaSelectorView.test.tsx @@ -71,57 +71,6 @@ jest.mock('@metamask/design-system-twrnc-preset', () => { }; }); -jest.mock( - '../../../../component-library/components-temp/HeaderCompactStandard', - () => { - const ReactActual = jest.requireActual('react'); - const { View, Pressable } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - title, - onBack, - onClose, - backButtonProps, - endButtonIconProps, - testID, - }: { - title: React.ReactNode; - onBack?: () => void; - onClose?: () => void; - backButtonProps?: { onPress?: () => void }; - endButtonIconProps?: { - onPress?: () => void; - testID?: string; - }[]; - testID?: string; - }) => - ReactActual.createElement( - View, - { testID: testID ?? 'header' }, - ReactActual.createElement(Pressable, { - onPress: backButtonProps?.onPress ?? onBack ?? onClose, - testID: 'header-back-button', - }), - typeof title === 'string' - ? ReactActual.createElement( - jest.requireActual('react-native').Text, - { testID: 'header-title' }, - title, - ) - : title, - ...(endButtonIconProps ?? []).map((iconProps, index) => - ReactActual.createElement(Pressable, { - key: `end-icon-${index}`, - onPress: iconProps.onPress, - testID: iconProps.testID ?? 'search-toggle', - }), - ), - ), - }; - }, -); - jest.mock('../../../Views/ErrorBoundary', () => { const ReactActual = jest.requireActual('react'); return { @@ -427,7 +376,7 @@ describe('OndoCampaignRwaSelectorView', () => { it('navigates back when back button is pressed', () => { const { getByTestId } = render(); - fireEvent.press(getByTestId('header-back-button')); + fireEvent.press(getByTestId('ondo-rwa-selector-header-back-button')); expect(mockGoBack).toHaveBeenCalledTimes(1); }); diff --git a/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx index e64e908b2cc..f8e1fa665fd 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx @@ -72,50 +72,22 @@ jest.mock('react-native-safe-area-context', () => { jest.mock('@metamask/design-system-react-native', () => { const actual = jest.requireActual('@metamask/design-system-react-native'); - return { ...actual }; + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + ...actual, + Skeleton: ({ children }: { children?: React.ReactNode }) => + ReactActual.createElement(View, { testID: 'skeleton' }, children), + }; }); jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => ({ style: (...args: unknown[]) => args }), -})); - -jest.mock( - '../../../../component-library/components-temp/HeaderCompactStandard', - () => { - const ReactActual = jest.requireActual('react'); - const { View, Text, Pressable } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - title, - onBack, - backButtonProps, - endButtonIconProps, - }: { - title: string; - onBack: () => void; - backButtonProps?: { testID?: string }; - endButtonIconProps?: { testID?: string; onPress?: () => void }[]; - }) => - ReactActual.createElement( - View, - { testID: 'header' }, - ReactActual.createElement(Text, null, title), - ReactActual.createElement(Pressable, { - onPress: onBack, - testID: backButtonProps?.testID ?? 'header-back-button', - }), - ...(endButtonIconProps ?? []).map((btn, index) => - ReactActual.createElement(Pressable, { - key: `end-${String(index)}`, - onPress: btn.onPress, - testID: btn.testID ?? `header-end-button-${String(index)}`, - }), - ), - ), - }; + useTailwind: () => { + const tw = (...args: unknown[]) => args; + tw.style = (...args: unknown[]) => args; + return tw; }, -); +})); jest.mock('../../../Views/ErrorBoundary', () => { const ReactActual = jest.requireActual('react'); @@ -186,6 +158,9 @@ jest.mock('../utils/formatUtils', () => ({ minimumFractionDigits: 2, maximumFractionDigits: 2, })}`, + formatRewardsTimeOnly: () => '12:00 PM', + getPortfolioReturnColor: (pnl?: string) => + pnl && parseFloat(pnl) < 0 ? 'errorDefault' : 'textDefault', })); // Mock Engine to prevent @metamask/social-controllers resolution chain @@ -432,7 +407,9 @@ describe('OndoCampaignStatsView', () => { hasError: false, }); const { getByText } = render(); - const title = getByText('rewards.ondo_outcome_banner.winner_pending.title'); + const title = getByText( + 'rewards.campaign_outcome_banner.winner_pending.title', + ); fireEvent.press(title); expect(mockNavigate).toHaveBeenCalledWith( Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, @@ -462,7 +439,7 @@ describe('OndoCampaignStatsView', () => { }); const { queryByText } = render(); expect( - queryByText('rewards.ondo_outcome_banner.winner_pending.title'), + queryByText('rewards.campaign_outcome_banner.winner_pending.title'), ).toBeNull(); }); @@ -1011,7 +988,9 @@ describe('OndoCampaignStatsView', () => { hasError: false, }); const { getByText } = render(); - const title = getByText('rewards.ondo_outcome_banner.winner_pending.title'); + const title = getByText( + 'rewards.campaign_outcome_banner.winner_pending.title', + ); fireEvent.press(title); expect(mockNavigate).toHaveBeenCalledWith( Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, @@ -1041,3 +1020,50 @@ describe('OndoCampaignStatsView', () => { ).toBeDefined(); }); }); + +describe('OndoCampaignStatsView — last updated', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: { optedIn: true, participantCount: 1 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: null, + isLoading: false, + hasError: false, + hasFetched: false, + refetch: jest.fn(), + }); + mockUseGetOndoLeaderboard.mockReturnValue(leaderboardDefaults); + mockUseOndoCampaignParticipantOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, + }); + mockRewardsState.campaigns = [createTestCampaign()]; + }); + + it('shows last updated timestamp when position has computedAt', () => { + mockUseGetOndoLeaderboardPosition.mockReturnValue({ + ...positionDefaults, + position: makeQualifiedPosition({ + computedAt: '2024-03-20T12:00:00.000Z', + }), + }); + const { getByText } = render(); + expect( + getByText(/rewards\.ondo_campaign_leaderboard\.updated_at/), + ).toBeDefined(); + }); + + it('does not show last updated timestamp when position is null', () => { + mockUseGetOndoLeaderboardPosition.mockReturnValue(positionDefaults); + const { queryByText } = render(); + expect( + queryByText(/rewards\.ondo_campaign_leaderboard\.updated_at/), + ).toBeNull(); + }); +}); diff --git a/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx b/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx index 877d213e111..520d10dfafd 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx @@ -14,28 +14,22 @@ import { TextColor, TextVariant, } from '@metamask/design-system-react-native'; -import { OndoGmCampaignOutcomeBanner } from '../components/Campaigns/OndoCampaignOutcomeBanners'; -import { getCampaignMechanicsButtonProps } from '../utils/campaignHeaderUtils'; +import { CampaignOutcomeBanner } from '../components/Campaigns/CampaignOutcomeBanners'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; -import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import ErrorBoundary from '../../../Views/ErrorBoundary'; +import CampaignViewHeader from '../components/Campaigns/CampaignViewHeader'; import { StatCell, - CAMPAIGN_STATS_SUMMARY_TEST_IDS, -} from '../components/Campaigns/CampaignStatsSummary'; + ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS, +} from '../components/Campaigns/OndoCampaignStatsSummary'; import LeaderboardPositionHeader from '../components/Campaigns/LeaderboardPositionHeader'; import RewardsErrorBanner from '../components/RewardsErrorBanner'; -import { - formatTierDisplayName, - getTierMinNetDeposit, -} from '../components/Campaigns/OndoLeaderboard.utils'; +import { getTierMinNetDeposit } from '../components/Campaigns/OndoLeaderboard.utils'; import { strings } from '../../../../../locales/i18n'; -import { formatPercentChange, formatUsd } from '../utils/formatUtils'; -import { - ONDO_GM_REQUIRED_QUALIFIED_DAYS, - isCampaignIneligible, -} from '../utils/ondoCampaignConstants'; +import { formatUsd, formatRewardsTimeOnly } from '../utils/formatUtils'; +import { ONDO_GM_REQUIRED_QUALIFIED_DAYS } from '../utils/ondoCampaignConstants'; +import { useOndoLeaderboardPositionDisplay } from '../hooks/useOndoLeaderboardPositionDisplay'; import { useOndoCampaignParticipantOutcome } from '../hooks/useOndoCampaignParticipantOutcome'; import { useGetOndoLeaderboardPosition } from '../hooks/useGetOndoLeaderboardPosition'; import { useGetOndoPortfolioPosition } from '../hooks/useGetOndoPortfolioPosition'; @@ -53,6 +47,7 @@ type OndoCampaignStatsRouteParams = { export const ONDO_CAMPAIGN_STATS_VIEW_TEST_IDS = { CONTAINER: 'ondo-campaign-stats-view-container', + LAST_COMPUTED: 'ondo-campaign-stats-view-last-computed', } as const; const CheckIcon: React.FC = () => ( @@ -109,25 +104,21 @@ const OndoCampaignStatsView: React.FC = () => { const leaderboardError = hasLeaderboardPositionError && !leaderboardPosition; const portfolioError = hasPortfolioError && !portfolioData; - const isPending = - leaderboardPosition != null && !leaderboardPosition.qualified; - const isQualified = - leaderboardPosition != null && leaderboardPosition.qualified; - - const isIneligible = useMemo( - () => isCampaignIneligible(campaign, leaderboardPosition?.qualified), - [campaign, leaderboardPosition], - ); - - const returnValue = portfolioData?.summary - ? formatPercentChange(portfolioData.summary.portfolioPnlPercent) - : '-'; - - const returnColor = portfolioData?.summary - ? parseFloat(portfolioData.summary.portfolioPnlPercent) < 0 - ? TextColor.ErrorDefault - : TextColor.SuccessDefault - : TextColor.TextDefault; + const { + isCampaignComplete, + isPending, + isQualified, + isIneligible, + rankValue, + tierValue, + returnValue: returnValueRaw, + returnColor, + } = useOndoLeaderboardPositionDisplay({ + campaign, + position: leaderboardPosition, + portfolioPnlPercent: portfolioData?.summary?.portfolioPnlPercent, + }); + const returnValue = returnValueRaw ?? '-'; const marketValue = portfolioData?.summary ? formatUsd(portfolioData.summary.totalCurrentValue) @@ -145,16 +136,6 @@ const OndoCampaignStatsView: React.FC = () => { ? formatUsd(portfolioData.summary.totalCashedOut) : '-'; - const rankValue = - isIneligible || !leaderboardPosition - ? '-' - : String(leaderboardPosition.rank).padStart(2, '0'); - - const tierValue = - isIneligible || !leaderboardPosition - ? '-' - : formatTierDisplayName(leaderboardPosition.projectedTier); - const daysHeldValue = leaderboardPosition ? `${Math.min( leaderboardPosition.qualifiedDays, @@ -187,9 +168,6 @@ const OndoCampaignStatsView: React.FC = () => { daysRemaining > 0 && tierMinDeposit != null; - const isCampaignComplete = - campaign != null && getCampaignStatus(campaign) === 'complete'; - const { outcome: participantOutcome } = useOndoCampaignParticipantOutcome( isCampaignComplete && isOptedIn ? campaignId : undefined, ); @@ -208,20 +186,12 @@ const OndoCampaignStatsView: React.FC = () => { style={tw.style('flex-1 bg-default')} testID={ONDO_CAMPAIGN_STATS_VIEW_TEST_IDS.CONTAINER} > - navigation.goBack()} - backButtonProps={{ testID: 'ondo-campaign-stats-back-button' }} - endButtonIconProps={getCampaignMechanicsButtonProps( - campaign != null, - () => - navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, { - campaignId, - }), - 'campaign-stats-mechanics-button', - )} - includesTopInset + backButtonTestID="ondo-campaign-stats-back-button" + mechanicsButtonTestID="campaign-stats-mechanics-button" + hasCampaign={campaign != null} + campaignId={campaignId} /> { {/* ── Outcome banner (campaign ended) ── */} {isCampaignComplete && participantOutcome && ( - { {!isCampaignComplete && isIneligible && ( { )} + {/* ── Last updated ── */} + {leaderboardPosition?.computedAt && ( + + {strings('rewards.ondo_campaign_leaderboard.updated_at', { + time: formatRewardsTimeOnly( + new Date(leaderboardPosition.computedAt), + ), + })} + + )} + {/* ── Error banner ── */} {(leaderboardError || portfolioError) && ( ({ - __esModule: true, - default: 1, -})); - -const mockGoBack = jest.fn(); - -const mockNavigate = jest.fn(); - -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }), - useRoute: () => ({ - params: { campaignId: 'campaign-ondo-1', campaignName: 'Ondo Campaign' }, - }), -})); - -jest.mock('@metamask/design-system-twrnc-preset', () => { - const tw = (...args: unknown[]) => args; - tw.style = (...args: unknown[]) => args; - return { useTailwind: () => tw }; -}); - -jest.mock('react-native-safe-area-context', () => { - const actual = jest.requireActual('react-native-safe-area-context'); - return { - ...actual, - useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }), - }; -}); - -jest.mock('../hooks/useOndoCampaignParticipantOutcome', () => ({ - useOndoCampaignParticipantOutcome: jest.fn(), -})); - -const mockUseOndoCampaignParticipantOutcome = - useOndoCampaignParticipantOutcome as jest.MockedFunction< - typeof useOndoCampaignParticipantOutcome - >; - -jest.mock('../../../Views/ErrorBoundary', () => { +jest.mock('./CampaignWinningView', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return { __esModule: true, - default: ({ children }: { children: React.ReactNode }) => - ReactActual.createElement(View, null, children), + default: jest.fn(({ testID }: { testID: string }) => + ReactActual.createElement(View, { testID }), + ), }; }); -jest.mock('../hooks/useTrackRewardsPageView', () => ({ - __esModule: true, - default: jest.fn(), -})); - -jest.mock('../../../../core/Analytics', () => ({ - MetaMetricsEvents: { - REWARDS_PAGE_BUTTON_CLICKED: 'REWARDS_PAGE_BUTTON_CLICKED', - }, -})); - -jest.mock('../utils', () => ({ - RewardsMetricsButtons: { - COPY_REFERRAL_CODE: 'copy_referral_code', - }, -})); - -const mockTrackEvent = jest.fn(); -const mockBuild = jest.fn(() => ({})); -jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ - useAnalytics: () => ({ - trackEvent: mockTrackEvent, - createEventBuilder: () => ({ - addProperties: () => ({ build: mockBuild }), - }), - }), +jest.mock('../hooks/useOndoCampaignParticipantOutcome', () => ({ + useOndoCampaignParticipantOutcome: jest.fn(), })); -const mockPosition = { - projectedTier: 'MID', - rank: 3, - totalInTier: 100, - rateOfReturn: 0.2823, - currentUsdValue: 2000, - totalUsdDeposited: 1000, - netDeposit: 900, - qualifiedDays: 10, - qualified: true, - neighbors: [], - computedAt: '2024-01-01T00:00:00.000Z', -}; - jest.mock('../hooks/useGetOndoLeaderboardPosition', () => ({ useGetOndoLeaderboardPosition: jest.fn(), })); -const mockUseGetOndoLeaderboardPosition = - useGetOndoLeaderboardPosition as jest.MockedFunction< - typeof useGetOndoLeaderboardPosition - >; +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: jest.fn(), navigate: jest.fn() }), + useRoute: () => ({ + params: { campaignId: 'campaign-ondo-1', campaignName: 'Ondo Campaign' }, + }), +})); -jest.mock('../components/ReferralDetails/CopyableField', () => { - const ReactActual = jest.requireActual('react'); - const { View, Text, Pressable } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - label, - value, - onCopy, - }: { - label: string; - value?: string | null; - onCopy?: () => void; - }) => - ReactActual.createElement( - View, - { testID: 'copyable-field' }, - ReactActual.createElement(Text, null, label), - ReactActual.createElement( - Text, - { testID: 'copyable-value' }, - value ?? '', - ), - ReactActual.createElement(Pressable, { - testID: 'copyable-trigger', - onPress: onCopy, - }), - ), - }; +jest.mock('@metamask/design-system-twrnc-preset', () => { + const tw = (...args: unknown[]) => args; + tw.style = (...args: unknown[]) => args; + return { useTailwind: () => tw }; }); -jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn( - (key: string, params?: { place?: string; code?: string }) => { - const map: Record = { - 'rewards.ondo_campaign_winning.you_won': 'You won', - 'rewards.ondo_campaign_winning.email_instructions': - 'Email ondocampaign@consensys.net with your code to claim your prize.', - 'rewards.ondo_campaign_winning.open_mail': 'Open mail', - 'rewards.ondo_campaign_winning.skip_for_now': 'Skip for now', - 'rewards.ondo_campaign_winning.mail_subject': - 'Ondo campaign prize claim', - 'rewards.ondo_campaign_winning.mail_body': `My winning code: ${params?.code ?? ''}`, - 'rewards.ondo_campaign_winning.winning_code': 'Winning code', - 'rewards.ondo_campaign_winning.close_a11y': 'Close', - 'rewards.ondo_campaign_winning.error_title': - 'Could not load your winning code', - 'rewards.ondo_campaign_winning.error_description': - 'Something went wrong while fetching your code. Please try again later or contact support.', - 'rewards.ondo_campaign_winning.error_retry': 'Try again', - }; - if (key === 'rewards.ondo_campaign_winning.rank_label' && params?.place) { - return `${params.place} place`; - } - return map[key] ?? key; - }, - ), -})); +const mockUseOutcome = useOndoCampaignParticipantOutcome as jest.MockedFunction< + typeof useOndoCampaignParticipantOutcome +>; +const mockUsePosition = useGetOndoLeaderboardPosition as jest.MockedFunction< + typeof useGetOndoLeaderboardPosition +>; +const mockCampaignWinningView = CampaignWinningView as jest.MockedFunction< + typeof CampaignWinningView +>; describe('OndoCampaignWinningView', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: mockPosition, - isLoading: false, - hasError: false, - hasFetched: true, - refetch: jest.fn(), - }); - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ + mockUseOutcome.mockReturnValue({ outcome: { subscriptionId: 'sub-1', outcomeStatus: 'pending', - winnerVerificationCode: 'LVL346', + winnerVerificationCode: 'ONDO-WIN-99', }, isLoading: false, hasError: false, }); + mockUsePosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); }); - it('renders the main container', () => { + it('renders the container with the Ondo testID', () => { const { getByTestId } = render(); expect( getByTestId(ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER), ).toBeTruthy(); }); - it('shows you won, rank place, and rate from leaderboard position', () => { - const { getByText } = render(); - expect(getByText('You won')).toBeTruthy(); - expect(getByText('3rd place')).toBeTruthy(); - expect(getByText('+28.23%')).toBeTruthy(); - }); - - it('calls goBack when Skip for now is pressed', () => { - const { getByText } = render(); - fireEvent.press(getByText('Skip for now')); - expect(mockGoBack).toHaveBeenCalledTimes(1); - }); - - it('calls goBack when close is pressed', () => { - const { getByLabelText } = render(); - fireEvent.press(getByLabelText('Close')); - expect(mockGoBack).toHaveBeenCalledTimes(1); - }); - - it('copies referral code and tracks analytics when copy is triggered', () => { - const setStringSpy = jest.spyOn(Clipboard, 'setString'); - const { getByTestId } = render(); - fireEvent.press(getByTestId('copyable-trigger')); - expect(setStringSpy).toHaveBeenCalledWith('LVL346'); - expect(mockTrackEvent).toHaveBeenCalled(); - }); - - it('opens mailto when Open mail is pressed', async () => { - const openSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - expect(openSpy).toHaveBeenCalled(); - const url = openSpy.mock.calls[0][0] as string; - expect(url).toContain('mailto:ondocampaign@consensys.net'); - expect(url).toContain(encodeURIComponent('LVL346')); - openSpy.mockRestore(); - }); - - describe('auto-redirect when user is not a winner', () => { - it('navigates to details view when outcome loaded but has no winner code', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: { - subscriptionId: 'sub-1', - outcomeStatus: 'pending', - winnerVerificationCode: null, - }, - isLoading: false, - hasError: false, - }); - render(); - expect(mockNavigate).toHaveBeenCalledWith( - Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, - { campaignId: 'campaign-ondo-1' }, - ); - }); - - it('does not navigate while outcome is still loading', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: null, - isLoading: true, - hasError: false, - }); - render(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not navigate when outcome is null after load', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: null, - isLoading: false, - hasError: false, - }); - render(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - }); - - describe('loading states', () => { - it('shows CopyableField once winning code has loaded', () => { - const { getByTestId } = render(); - expect(getByTestId('copyable-field')).toBeTruthy(); - }); - - it('shows the primary CTA in loading state while outcome is loading', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: null, - isLoading: true, - hasError: false, - }); - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - expect(openSpy).not.toHaveBeenCalled(); - openSpy.mockRestore(); - }); - - it('does not show the primary CTA in loading state once code has loaded', () => { - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - expect(openSpy).toHaveBeenCalledTimes(1); - openSpy.mockRestore(); - }); - - it('hides rank and rate text while position is loading', () => { - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: null, - isLoading: true, - hasError: false, - hasFetched: false, - refetch: jest.fn(), - }); - const { queryByText } = render(); - expect(queryByText('3rd place')).toBeNull(); - expect(queryByText('+28.23%')).toBeNull(); - }); - }); - - describe('error states', () => { - it('hides the rank/rate section entirely when position fails to load', () => { - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: null, + it('passes correct Ondo-specific props to CampaignWinningView', () => { + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + testID: ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER, + prizeEmail: 'ondocampaign@consensys.net', + campaignName: 'Ondo Campaign', + campaignId: 'campaign-ondo-1', + analyticsPageType: 'ondo_campaign_winning', + winningCode: 'ONDO-WIN-99', + hasOutcomeLoaded: true, isLoading: false, - hasError: true, - hasFetched: true, - refetch: jest.fn(), - }); - const { queryByText } = render(); - expect(queryByText('3rd place')).toBeNull(); - expect(queryByText('+28.23%')).toBeNull(); - }); - - it('hides the rank/rate section when position is null and not loading', () => { - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: null, - isLoading: false, - hasError: false, - hasFetched: true, - refetch: jest.fn(), - }); - const { queryByText } = render(); - expect(queryByText('3rd place')).toBeNull(); - expect(queryByText('+28.23%')).toBeNull(); - }); + rankDisplay: null, + resultDisplay: null, + isRankLoading: false, + isResultLoading: false, + fallbackRoute: { + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: 'campaign-ondo-1' }, + }, + }), + {}, + ); }); - it('does not throw when mailto openURL rejects', async () => { - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockRejectedValue(new Error('no mail app')); - const { getByText } = render(); - await act(async () => { - fireEvent.press(getByText('Open mail')); + it('passes winningCode as null when outcome has no code', () => { + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + isLoading: false, + hasError: false, }); - expect(openSpy).toHaveBeenCalled(); - openSpy.mockRestore(); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: true, + }), + {}, + ); }); - describe('mailto URL construction', () => { - it('appends the winning code to the mail subject', async () => { - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - const url = openSpy.mock.calls[0][0] as string; - expect(url).toContain( - encodeURIComponent('Ondo campaign prize claim - LVL346'), - ); - openSpy.mockRestore(); - }); - - it('uses base subject without code when winningCode is null', async () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: { - subscriptionId: 'sub-1', - outcomeStatus: 'pending', - winnerVerificationCode: null, - }, - isLoading: false, - hasError: false, - }); - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - const url = openSpy.mock.calls[0][0] as string; - expect(url).toContain(encodeURIComponent('Ondo campaign prize claim')); - expect(url).not.toContain(' - '); - openSpy.mockRestore(); + it('does not mark outcome as loaded until the outcome exists', () => { + mockUseOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: false, + }), + {}, + ); }); - it('does not copy to clipboard when winning code is null', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ + it('passes rank and result display when position is available', () => { + mockUseOutcome.mockReturnValue({ outcome: { subscriptionId: 'sub-1', outcomeStatus: 'pending', - winnerVerificationCode: null, + winnerVerificationCode: 'ONDO-WIN-99', + tierRank: 3, }, isLoading: false, hasError: false, }); - const setStringSpy = jest.spyOn(Clipboard, 'setString'); - const { getByTestId } = render(); - fireEvent.press(getByTestId('copyable-trigger')); - expect(setStringSpy).not.toHaveBeenCalled(); + mockUsePosition.mockReturnValue({ + position: { rank: 9, rateOfReturn: 0.1234 } as never, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + rankDisplay: '3rd', + resultDisplay: '+12.34%', + isRankLoading: false, + isResultLoading: false, + }), + {}, + ); }); }); diff --git a/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx b/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx index 59b3434babe..7e40d530493 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx @@ -1,45 +1,13 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; -import { Image, Linking, ScrollView, StyleSheet } from 'react-native'; -import Clipboard from '@react-native-clipboard/clipboard'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { - SafeAreaView, - useSafeAreaInsets, -} from 'react-native-safe-area-context'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { - Box, - BoxFlexDirection, - Button, - ButtonSize, - ButtonVariant, - ButtonIcon, - ButtonIconSize, - IconName, - Skeleton, - Text, - TextColor, - TextVariant, -} from '@metamask/design-system-react-native'; -import ErrorBoundary from '../../../Views/ErrorBoundary'; -import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; +import React, { useMemo } from 'react'; +import { useRoute, RouteProp } from '@react-navigation/native'; import { useOndoCampaignParticipantOutcome } from '../hooks/useOndoCampaignParticipantOutcome'; -import Routes from '../../../../constants/navigation/Routes'; -import { strings } from '../../../../../locales/i18n'; -import CopyableField from '../components/ReferralDetails/CopyableField'; import { formatOrdinalRank, formatPercentChange } from '../utils/formatUtils'; -import { RewardsMetricsButtons } from '../utils'; -import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; -import { MetaMetricsEvents } from '../../../../core/Analytics'; import { useGetOndoLeaderboardPosition } from '../hooks/useGetOndoLeaderboardPosition'; -import campaignWinningHero from '../../../../images/rewards/campaign_winning.png'; +import CampaignWinningView from './CampaignWinningView'; +import Routes from '../../../../constants/navigation/Routes'; const PRIZE_EMAIL = 'ondocampaign@consensys.net'; -const styles = StyleSheet.create({ - heroBox: { aspectRatio: 1 }, -}); - // ParamListBase requires an index signature, which interfaces don't support // eslint-disable-next-line @typescript-eslint/consistent-type-definitions type OndoCampaignWinningRouteParams = { @@ -51,15 +19,11 @@ export const ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS = { } as const; const OndoCampaignWinningView: React.FC = () => { - const tw = useTailwind(); - const insets = useSafeAreaInsets(); - const navigation = useNavigation(); - const { trackEvent, createEventBuilder } = useAnalytics(); const route = useRoute< RouteProp >(); - const { campaignId } = route.params; + const { campaignId, campaignName = '' } = route.params; const { position, isLoading: positionLoading } = useGetOndoLeaderboardPosition(campaignId); @@ -68,186 +32,41 @@ const OndoCampaignWinningView: React.FC = () => { useOndoCampaignParticipantOutcome(campaignId); const winningCode = outcome?.winnerVerificationCode ?? null; - useEffect(() => { - if (!isOutcomeLoading && outcome && !winningCode) { - navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, { - campaignId, - }); - } - }, [isOutcomeLoading, outcome, winningCode, campaignId, navigation]); - - useTrackRewardsPageView({ - page_type: 'ondo_campaign_winning', - campaign_id: campaignId, - }); - - const onDismiss = () => navigation.goBack(); - - const handleCopyWinningCode = useCallback(() => { - if (winningCode) { - Clipboard.setString(winningCode); - trackEvent( - createEventBuilder(MetaMetricsEvents.REWARDS_PAGE_BUTTON_CLICKED) - .addProperties({ - button_type: RewardsMetricsButtons.COPY_REFERRAL_CODE, - }) - .build(), - ); - } - }, [winningCode, trackEvent, createEventBuilder]); - - const handleOpenMail = useCallback(async () => { - const baseSubject = strings('rewards.ondo_campaign_winning.mail_subject'); - const subject = winningCode - ? `${baseSubject} - ${winningCode}` - : baseSubject; - const body = strings('rewards.ondo_campaign_winning.mail_body', { - code: winningCode || '—', - }); - const url = `mailto:${PRIZE_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; - try { - await Linking.openURL(url); - } catch { - // no-op: device may not have a mail handler - } - }, [winningCode]); - const rankDisplay = useMemo(() => { - if (!position) return null; - return strings('rewards.ondo_campaign_winning.rank_label', { - place: formatOrdinalRank(position.rank), - }); - }, [position]); + if (!outcome?.tierRank) return null; + return formatOrdinalRank(outcome.tierRank); + }, [outcome]); - const rateDisplay = useMemo(() => { + const resultDisplay = useMemo(() => { if (!position) return null; return formatPercentChange(position.rateOfReturn); }, [position]); - return ( - - - - - - - - - - - - - {strings('rewards.ondo_campaign_winning.you_won')} - - - {(positionLoading || position) && ( - - {rankDisplay !== null ? ( - - {rankDisplay} - - ) : ( - - )} - - {rateDisplay !== null ? ( - - {rateDisplay} - - ) : ( - - )} - - )} - - - {strings('rewards.ondo_campaign_winning.email_instructions')} - - - - - - - - - + const fallbackRoute = useMemo( + () => ({ + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId }, + }), + [campaignId], + ); - - - - - + return ( + ); }; diff --git a/app/components/UI/Rewards/Views/OndoLeaderboardView.test.tsx b/app/components/UI/Rewards/Views/OndoLeaderboardView.test.tsx index 98ff10ef4b7..00e2ed571e8 100644 --- a/app/components/UI/Rewards/Views/OndoLeaderboardView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoLeaderboardView.test.tsx @@ -11,49 +11,31 @@ import { useGetOndoCampaignDeposits } from '../hooks/useGetOndoCampaignDeposits' import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ goBack: mockGoBack }), + useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }), useRoute: () => ({ params: { campaignId: 'campaign-ondo-123' } }), })); jest.mock('@metamask/design-system-react-native', () => { const actual = jest.requireActual('@metamask/design-system-react-native'); - return { ...actual }; + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + ...actual, + Skeleton: ({ children }: { children?: React.ReactNode }) => + ReactActual.createElement(View, { testID: 'skeleton' }, children), + }; }); jest.mock('@metamask/design-system-twrnc-preset', () => ({ - useTailwind: () => ({ style: (...args: unknown[]) => args }), -})); - -jest.mock( - '../../../../component-library/components-temp/HeaderCompactStandard', - () => { - const ReactActual = jest.requireActual('react'); - const { View, Text, Pressable } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - title, - onBack, - backButtonProps, - }: { - title: string; - onBack: () => void; - backButtonProps?: { testID?: string }; - }) => - ReactActual.createElement( - View, - { testID: 'header' }, - ReactActual.createElement(Text, null, title), - ReactActual.createElement(Pressable, { - onPress: onBack, - testID: backButtonProps?.testID ?? 'header-back-button', - }), - ), - }; + useTailwind: () => { + const tw = (...args: unknown[]) => args; + tw.style = (...args: unknown[]) => args; + return tw; }, -); +})); jest.mock('react-redux', () => ({ useSelector: jest.fn(), @@ -76,6 +58,9 @@ const mockOndoLeaderboard = jest.fn(); jest.mock('../components/Campaigns/OndoLeaderboard', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); + const { CAMPAIGN_LEADERBOARD_TEST_IDS } = jest.requireActual< + typeof import('../components/Campaigns/OndoLeaderboard') + >('../components/Campaigns/OndoLeaderboard'); return { __esModule: true, default: (props: Record) => { @@ -84,9 +69,15 @@ jest.mock('../components/Campaigns/OndoLeaderboard', () => { testID: 'campaign-leaderboard', }); }, + CAMPAIGN_LEADERBOARD_TEST_IDS, }; }); +jest.mock('../utils/formatUtils', () => ({ + ...jest.requireActual('../utils/formatUtils'), + formatRewardsTimeOnly: () => '12:00 PM', +})); + jest.mock('../components/Campaigns/OndoLeaderboard.utils', () => ({ formatTierDisplayName: (tier: string) => tier, getTierMinNetDeposit: jest.fn( @@ -97,8 +88,42 @@ jest.mock('../components/Campaigns/OndoLeaderboard.utils', () => ({ tiers?.find((t: { name: string }) => t.name === name)?.minNetDeposit ?? null, ), + getCampaignTierNames: ( + campaign: + | { details?: { tiers?: { name: string }[] } | null } + | null + | undefined, + ) => campaign?.details?.tiers?.map((t: { name: string }) => t.name) ?? [], + buildLeaderboardUserPosition: ( + position: { + projectedTier: string; + rank: number; + neighbors: unknown[]; + } | null, + ) => + position + ? { + projectedTier: position.projectedTier, + rank: position.rank, + neighbors: position.neighbors, + } + : null, +})); + +// Mock Engine to prevent @metamask/assets-controller resolution chain +jest.mock('../../../../core/Engine/Engine', () => ({ + __esModule: true, + default: { + context: {}, + controllerMessenger: { + subscribe: jest.fn(), + unsubscribe: jest.fn(), + }, + }, })); +jest.mock('../../../hooks/useAnalytics/useAnalytics'); + jest.mock('../hooks/useGetOndoLeaderboard'); jest.mock('../hooks/useGetOndoLeaderboardPosition'); jest.mock('../hooks/useGetOndoPortfolioPosition'); @@ -217,6 +242,86 @@ describe('OndoLeaderboardView', () => { expect(getByTestId('campaign-leaderboard')).toBeDefined(); }); + it('passes hideTierHeader=true to OndoLeaderboard', () => { + render(); + expect(mockOndoLeaderboard).toHaveBeenCalledWith( + expect.objectContaining({ hideTierHeader: true }), + ); + }); + + it('renders the tier selector row when selectedTier is set', () => { + const { getByTestId } = render(); + expect( + getByTestId(ONDO_LEADERBOARD_VIEW_TEST_IDS.TIER_SELECTOR), + ).toBeDefined(); + }); + + it('does not render tier selector when selectedTier is null', () => { + mockUseGetOndoLeaderboard.mockReturnValue({ + ...hookDefaults, + selectedTier: null, + }); + const { queryByTestId } = render(); + expect( + queryByTestId(ONDO_LEADERBOARD_VIEW_TEST_IDS.TIER_SELECTOR), + ).toBeNull(); + }); + + it('shows the last updated timestamp when computedAt is present', () => { + const { getByText } = render(); + expect( + getByText('rewards.ondo_campaign_leaderboard.updated_at'), + ).toBeDefined(); + }); + + it('does not show the last updated timestamp when computedAt is null', () => { + mockUseGetOndoLeaderboard.mockReturnValue({ + ...hookDefaults, + computedAt: null, + }); + const { queryByText } = render(); + expect( + queryByText('rewards.ondo_campaign_leaderboard.updated_at'), + ).toBeNull(); + }); + + it('opens the tier selector modal when the tier button is pressed with multiple tiers', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId(ONDO_LEADERBOARD_VIEW_TEST_IDS.TIER_SELECTOR)); + expect(mockNavigate).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + selectedValue: 'STARTER', + }), + ); + }); + + it('does not open the tier selector modal when only one tier exists', () => { + mockUseGetOndoLeaderboard.mockReturnValue({ + ...hookDefaults, + selectedTier: 'STARTER', + }); + mockUseSelector.mockImplementation((selector: (s: unknown) => unknown) => + selector({ + rewards: { + referralCode: null, + campaigns: [ + { + ...mockCampaign, + details: { + ...mockCampaign.details, + tiers: [{ name: 'STARTER', minNetDeposit: 500 }], + }, + }, + ], + }, + }), + ); + const { getByTestId } = render(); + fireEvent.press(getByTestId(ONDO_LEADERBOARD_VIEW_TEST_IDS.TIER_SELECTOR)); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it('renders Your position section with rank and tier when position exists', () => { mockUseGetOndoLeaderboardPosition.mockReturnValue({ ...positionDefaults, @@ -360,6 +465,91 @@ describe('OndoLeaderboardView', () => { ); }); + const qualifiedPosition = { + rank: 5, + projectedTier: 'STARTER', + qualified: true, + qualifiedDays: 10, + totalInTier: 100, + rateOfReturn: 0.1, + currentUsdValue: 12500, + totalUsdDeposited: 10000, + netDeposit: 8500, + neighbors: [], + computedAt: '2024-01-01T00:00:00Z', + }; + + it('computes returnValue when portfolioData has a summary', () => { + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: { optedIn: true, participantCount: 1 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetOndoPortfolioPosition.mockReturnValue({ + portfolio: { + positions: [], + computedAt: '2024-01-01T00:00:00Z', + summary: { + portfolioPnlPercent: '0.05', + totalCurrentValue: '12500', + totalBookValue: '12000', + totalUsdDeposited: '10000', + netDeposit: '10000', + totalCashedOut: '0', + portfolioPnl: '500', + }, + }, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + const { getByTestId } = render(); + expect(getByTestId(ONDO_LEADERBOARD_VIEW_TEST_IDS.CONTAINER)).toBeDefined(); + }); + + it('uses empty entries and zero totalParticipants when selectedTierData is null', () => { + mockUseGetOndoLeaderboard.mockReturnValue({ + ...hookDefaults, + selectedTierData: null, + }); + render(); + expect(mockOndoLeaderboard).toHaveBeenCalledWith( + expect.objectContaining({ entries: [], totalParticipants: 0 }), + ); + }); + + it('computes prizePoolValue when deposits are loaded', () => { + mockUseGetOndoLeaderboardPosition.mockReturnValue({ + ...positionDefaults, + position: qualifiedPosition, + }); + mockUseGetOndoCampaignDeposits.mockReturnValue({ + deposits: { totalUsdDeposited: '5000' }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + const { getByTestId } = render(); + expect(getByTestId(ONDO_LEADERBOARD_VIEW_TEST_IDS.CONTAINER)).toBeDefined(); + }); + + it('shows loading state for prize pool when deposits are being fetched', () => { + mockUseGetOndoLeaderboardPosition.mockReturnValue({ + ...positionDefaults, + position: qualifiedPosition, + }); + mockUseGetOndoCampaignDeposits.mockReturnValue({ + deposits: null, + isLoading: true, + hasError: false, + refetch: jest.fn(), + }); + const { getByTestId } = render(); + expect(getByTestId(ONDO_LEADERBOARD_VIEW_TEST_IDS.CONTAINER)).toBeDefined(); + }); + describe('isCampaignComplete behavior', () => { const completeCampaign = { ...mockCampaign, diff --git a/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx b/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx index cb5b065c5d1..fd965e88005 100644 --- a/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx +++ b/app/components/UI/Rewards/Views/OndoLeaderboardView.tsx @@ -1,8 +1,16 @@ -import React, { useMemo } from 'react'; -import { ScrollView } from 'react-native'; +import React, { useCallback, useMemo } from 'react'; +import { Pressable, ScrollView } from 'react-native'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + FontWeight, + Icon, + IconColor, + IconName, + IconSize, Text, TextColor, TextVariant, @@ -10,20 +18,24 @@ import { import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useSelector } from 'react-redux'; -import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; import ErrorBoundary from '../../../Views/ErrorBoundary'; +import CampaignViewHeader from '../components/Campaigns/CampaignViewHeader'; import OndoLeaderboard from '../components/Campaigns/OndoLeaderboard'; import LeaderboardPositionHeader from '../components/Campaigns/LeaderboardPositionHeader'; -import { formatTierDisplayName } from '../components/Campaigns/OndoLeaderboard.utils'; +import { + buildLeaderboardUserPosition, + formatTierDisplayName, + getCampaignTierNames, +} from '../components/Campaigns/OndoLeaderboard.utils'; +import { formatRewardsTimeOnly, formatUsd } from '../utils/formatUtils'; import { useGetOndoLeaderboard } from '../hooks/useGetOndoLeaderboard'; import { useGetOndoLeaderboardPosition } from '../hooks/useGetOndoLeaderboardPosition'; import { useGetOndoPortfolioPosition } from '../hooks/useGetOndoPortfolioPosition'; import { useGetOndoCampaignDeposits } from '../hooks/useGetOndoCampaignDeposits'; import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; -import { getCurrentPrize } from '../components/Campaigns/OndoPrizePool'; -import { formatPercentChange, formatUsd } from '../utils/formatUtils'; -import { isCampaignIneligible } from '../utils/ondoCampaignConstants'; -import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; +import { useOndoLeaderboardPositionDisplay } from '../hooks/useOndoLeaderboardPositionDisplay'; import { strings } from '../../../../../locales/i18n'; import Routes from '../../../../constants/navigation/Routes'; import { @@ -31,7 +43,8 @@ import { selectCampaignById, } from '../../../../reducers/rewards/selectors'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; -import { getCampaignMechanicsButtonProps } from '../utils/campaignHeaderUtils'; +import { computePrizePoolProgress } from '../utils/prizePoolUtils'; +import { BREAKPOINTS } from '../components/Campaigns/OndoPrizePool'; // ParamListBase requires an index signature, which interfaces don't support // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -41,6 +54,7 @@ type OndoLeaderboardRouteParams = { export const ONDO_LEADERBOARD_VIEW_TEST_IDS = { CONTAINER: 'ondo-leaderboard-view-container', + TIER_SELECTOR: 'ondo-leaderboard-view-tier-selector', } as const; const OndoLeaderboardView: React.FC = () => { @@ -55,6 +69,7 @@ const OndoLeaderboardView: React.FC = () => { [campaignId], ); const campaign = useSelector(selectCampaign); + const { trackEvent, createEventBuilder } = useAnalytics(); useTrackRewardsPageView({ page_type: 'ondo_campaign_leaderboard', @@ -74,29 +89,29 @@ const OndoLeaderboardView: React.FC = () => { const { deposits, isLoading: isDepositsLoading } = useGetOndoCampaignDeposits(campaignId); - const isCampaignComplete = - campaign != null && getCampaignStatus(campaign) === 'complete'; - - const isPending = position != null && !position.qualified; - const isQualified = position != null && position.qualified; - - const isIneligible = useMemo( - () => isCampaignIneligible(campaign, position?.qualified), - [campaign, position], - ); - - const returnValue = portfolioData?.summary - ? formatPercentChange(portfolioData.summary.portfolioPnlPercent) - : undefined; - - const returnColor = portfolioData?.summary - ? parseFloat(portfolioData.summary.portfolioPnlPercent) < 0 - ? TextColor.ErrorDefault - : TextColor.SuccessDefault - : TextColor.TextDefault; + const { + isCampaignComplete, + isPending, + isQualified, + isIneligible, + rankValue, + tierValue, + returnValue, + returnColor, + } = useOndoLeaderboardPositionDisplay({ + campaign, + position, + portfolioPnlPercent: portfolioData?.summary?.portfolioPnlPercent, + }); const prizePoolValue = deposits?.totalUsdDeposited - ? formatUsd(getCurrentPrize(parseFloat(deposits.totalUsdDeposited))) + ? formatUsd( + computePrizePoolProgress( + BREAKPOINTS, + parseFloat(deposits.totalUsdDeposited), + (m) => m.deposit, + ).currentPrize, + ) : undefined; const { @@ -104,38 +119,55 @@ const OndoLeaderboardView: React.FC = () => { selectedTier, selectedTierData, setSelectedTier, + computedAt, isLoading: isLeaderboardLoading, hasError: hasLeaderboardError, isLeaderboardNotYetComputed, + computedAt: leaderboardComputedAt, refetch: refetchLeaderboard, } = useGetOndoLeaderboard(campaignId, { defaultTier: position?.projectedTier, }); - const tierNames = useMemo( - () => campaign?.details?.tiers?.map((t) => t.name) ?? [], - [campaign], - ); + const tierNames = useMemo(() => getCampaignTierNames(campaign), [campaign]); - const leaderboardUserPosition = useMemo( + const tierOptions = useMemo( () => - position - ? { - projectedTier: position.projectedTier, - rank: position.rank, - neighbors: position.neighbors ?? [], - } - : null, - [position], + tierNames.map((name) => ({ + key: name, + value: name, + label: formatTierDisplayName(name), + })), + [tierNames], ); - const rankValue = - isIneligible || !position ? '-' : String(position.rank).padStart(2, '0'); + const openTierSelector = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.REWARDS_PAGE_BUTTON_CLICKED) + .addProperties({ + button_type: 'ondo_campaign_leaderboard_tier_select', + }) + .build(), + ); + navigation.navigate(Routes.MODAL.REWARDS_SELECT_SHEET, { + title: strings('rewards.ondo_campaign_leaderboard.select_tier'), + options: tierOptions, + selectedValue: selectedTier, + onSelect: setSelectedTier, + }); + }, [ + navigation, + tierOptions, + selectedTier, + setSelectedTier, + trackEvent, + createEventBuilder, + ]); - const tierValue = - isIneligible || !position - ? '-' - : formatTierDisplayName(position.projectedTier); + const leaderboardUserPosition = useMemo( + () => buildLeaderboardUserPosition(position), + [position], + ); return ( @@ -144,20 +176,12 @@ const OndoLeaderboardView: React.FC = () => { style={tw.style('flex-1 bg-default')} testID={ONDO_LEADERBOARD_VIEW_TEST_IDS.CONTAINER} > - navigation.goBack()} - backButtonProps={{ testID: 'ondo-leaderboard-back-button' }} - endButtonIconProps={getCampaignMechanicsButtonProps( - campaign != null, - () => - navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, { - campaignId, - }), - 'leaderboard-mechanics-button', - )} - includesTopInset + backButtonTestID="ondo-leaderboard-back-button" + mechanicsButtonTestID="leaderboard-mechanics-button" + hasCampaign={campaign != null} + campaignId={campaignId} /> { > {/* User position */} {position && ( - <> - - - - - + + + )} + + {/* Divider */} + + + {/* Tier selector + last updated row */} + {selectedTier && ( + + 1 ? openTierSelector : undefined} + testID={ONDO_LEADERBOARD_VIEW_TEST_IDS.TIER_SELECTOR} + > + + + {formatTierDisplayName(selectedTier)} + + {tierNames.length > 1 && ( + + )} + + + {computedAt && ( + + {strings('rewards.ondo_campaign_leaderboard.updated_at', { + time: formatRewardsTimeOnly(new Date(computedAt)), + })} + + )} + + )} + {/* Full leaderboard */} - + diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx new file mode 100644 index 00000000000..5ab6a0e70aa --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx @@ -0,0 +1,863 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsTradingCampaignDetailsView, { + PERPS_CAMPAIGN_DETAILS_TEST_IDS, + resetPerpsTradingCampaignDetailsSessionAutoNavigationForTests, +} from './PerpsTradingCampaignDetailsView'; +import { + type CampaignDto, + CampaignType, + type PerpsTradingCampaignLeaderboardEntry, + type PerpsTradingCampaignLeaderboardPositionDto, + type PerpsTradingCampaignParticipantOutcomeDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetPerpsTradingCampaignVolume } from '../hooks/useGetPerpsTradingCampaignVolume'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; +import Routes from '../../../../constants/navigation/Routes'; + +const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); + +const mockRouteState: { params: { campaignId?: string } } = { + params: { campaignId: 'perps-campaign-1' }, +}; + +jest.mock('@react-navigation/native', () => ({ + useFocusEffect: (callback: () => void) => callback(), + useNavigation: () => ({ + goBack: mockGoBack, + navigate: mockNavigate, + addListener: jest.fn(() => jest.fn()), + isFocused: () => true, + }), + useRoute: () => mockRouteState, +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const Skeleton = (props: Record) => + ReactActual.createElement(View, { testID: 'skeleton', ...props }); + return { ...actual, Skeleton }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => { + const tw = () => ({}); + tw.style = (..._args: unknown[]) => ({}); + return tw; + }, +})); + +jest.mock( + '../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onBack, + endButtonIconProps, + }: { + title: string; + onBack: () => void; + endButtonIconProps?: { testID?: string; onPress?: () => void }[]; + }) => + ReactActual.createElement( + View, + { testID: 'header' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Pressable, { + onPress: onBack, + testID: 'perps-details-back-button', + }), + ...(endButtonIconProps ?? []).map((btn, i) => + ReactActual.createElement(Pressable, { + key: i, + onPress: btn.onPress, + testID: btn.testID ?? `end-button-${i}`, + }), + ), + ), + }; + }, +); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('react-native-safe-area-context', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + SafeAreaView: ({ + children, + ...props + }: { + children: React.ReactNode; + testID?: string; + edges?: unknown; + style?: unknown; + }) => ReactActual.createElement(View, props, children), + }; +}); + +jest.mock('../components/Campaigns/CampaignStatus', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ campaign }: { campaign: { name: string } }) => + ReactActual.createElement( + View, + { testID: 'campaign-status' }, + ReactActual.createElement(Text, null, campaign.name), + ), + }; +}); + +jest.mock('../components/Campaigns/CampaignHowItWorks', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'campaign-how-it-works' }), + }; +}); + +jest.mock('../components/Campaigns/CampaignOutcomeBanners', () => { + const ReactActual = jest.requireActual('react'); + const { Pressable, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + CampaignOutcomeBanner: ({ + outcomeStatus, + winnerVerificationCode, + onWinnerPress, + }: { + outcomeStatus: string; + winnerVerificationCode: string | null | undefined; + onWinnerPress: () => void; + }) => + ReactActual.createElement( + Pressable, + { + testID: `campaign-outcome-banner-${outcomeStatus}-${winnerVerificationCode ?? 'null'}`, + onPress: onWinnerPress, + }, + ReactActual.createElement(Text, null, 'Campaign outcome'), + ), + }; +}); + +jest.mock('../components/Campaigns/PerpsCampaignStatsSummary', () => { + const ReactActual = jest.requireActual('react'); + const { Pressable, Text, View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + outcomeStatus, + winnerVerificationCode, + onWinnerPress, + }: { + outcomeStatus?: string; + winnerVerificationCode?: string | null; + onWinnerPress?: () => void; + }) => + ReactActual.createElement( + View, + { + testID: 'perps-campaign-stats-summary-container', + }, + outcomeStatus && + onWinnerPress && + ReactActual.createElement( + Pressable, + { + testID: `campaign-outcome-banner-${outcomeStatus}-${winnerVerificationCode ?? 'null'}`, + onPress: onWinnerPress, + }, + ReactActual.createElement(Text, null, 'Campaign outcome'), + ), + ), + }; +}); + +jest.mock('../components/Campaigns/PerpsTradingCampaignPrizePool', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'perps-prize-pool' }), + }; +}); + +jest.mock('../components/Campaigns/PerpsTradingCampaignEndedStats', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { + testID: 'perps-campaign-ended-stats', + }), + }; +}); + +jest.mock('../components/Campaigns/PerpsTradingCampaignLeaderboard', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS: { + TOTAL_PARTICIPANTS: 'perps-campaign-leaderboard-total-participants', + }, + default: () => + ReactActual.createElement(View, { testID: 'perps-leaderboard' }), + }; +}); + +jest.mock('../components/Campaigns/PerpsTradingCampaignCTA', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const { getCampaignStatus } = jest.requireActual( + '../components/Campaigns/CampaignTile.utils', + ) as typeof import('../components/Campaigns/CampaignTile.utils'); + return { + __esModule: true, + default: ({ campaign }: { campaign: CampaignDto }) => + getCampaignStatus(campaign) === 'complete' + ? null + : ReactActual.createElement(View, { testID: 'perps-trading-cta' }), + }; +}); + +jest.mock('../components/RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onConfirm, + confirmButtonLabel, + }: { + title: string; + description: string; + onConfirm?: () => void; + confirmButtonLabel?: string; + }) => + ReactActual.createElement( + View, + { testID: 'campaigns-load-error-banner' }, + ReactActual.createElement(Text, null, title), + confirmButtonLabel && + ReactActual.createElement( + Pressable, + { onPress: onConfirm, testID: 'campaigns-error-retry' }, + ReactActual.createElement(Text, null, confirmButtonLabel), + ), + ), + }; +}); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../hooks/useRewardCampaigns'); +const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< + typeof useRewardCampaigns +>; + +jest.mock('../hooks/useGetCampaignParticipantStatus'); +const mockUseGetCampaignParticipantStatus = + useGetCampaignParticipantStatus as jest.MockedFunction< + typeof useGetCampaignParticipantStatus + >; + +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboard'); +const mockUseGetPerpsTradingCampaignLeaderboard = + useGetPerpsTradingCampaignLeaderboard as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboard + >; + +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition'); +const mockUseGetPerpsTradingCampaignLeaderboardPosition = + useGetPerpsTradingCampaignLeaderboardPosition as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboardPosition + >; + +jest.mock('../hooks/useGetPerpsTradingCampaignVolume'); +const mockUseGetPerpsTradingCampaignVolume = + useGetPerpsTradingCampaignVolume as jest.MockedFunction< + typeof useGetPerpsTradingCampaignVolume + >; + +jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome'); +const mockUsePerpsTradingCampaignParticipantOutcome = + usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction< + typeof usePerpsTradingCampaignParticipantOutcome + >; + +import { useSelector } from 'react-redux'; +import { selectReferralCode } from '../../../../reducers/rewards/selectors'; + +const mockUseSelector = useSelector as jest.MockedFunction; + +const mockFetchCampaigns = jest.fn(); + +function buildPerpsCampaign(overrides: Partial = {}): CampaignDto { + return { + id: 'perps-campaign-1', + type: CampaignType.PERPS_TRADING, + name: 'Perps Trading', + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2026-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: true, + showUpcomingDate: false, + ...overrides, + }; +} + +function toMockLeaderboardPosition( + position: { rank: number; neighbors: unknown[] } | null, +): PerpsTradingCampaignLeaderboardPositionDto | null { + if (!position) { + return null; + } + return { + rank: position.rank, + pnl: 0, + notionalVolume: 0, + marginDeployed: 0, + qualified: true, + neighbors: position.neighbors as PerpsTradingCampaignLeaderboardEntry[], + computedAt: '2025-08-15T12:00:00.000Z', + }; +} + +const defaultLeaderboardHook = { + leaderboard: { + campaignId: 'perps-campaign-1', + entries: [], + totalParticipants: 0, + computedAt: '2025-08-15T12:00:00.000Z', + }, + isLoading: false, + hasError: false, + isLeaderboardNotYetComputed: false, + refetch: jest.fn(), +}; + +const defaultVolumeHook = { + volume: { + totalUsdVolume: '1000000', + }, + isLoading: false, + hasError: false, + refetch: jest.fn(), +}; + +function setupHooks( + overrides: { + campaigns?: CampaignDto[]; + isCampaignsLoading?: boolean; + hasCampaignsError?: boolean; + participant?: { optedIn: boolean }; + position?: { rank: number; neighbors: unknown[] } | null; + isPositionLoading?: boolean; + totalParticipants?: number; + outcome?: PerpsTradingCampaignParticipantOutcomeDto | null; + } = {}, +) { + const { + campaigns = [buildPerpsCampaign()], + isCampaignsLoading = false, + hasCampaignsError = false, + participant = { optedIn: false }, + position = null, + isPositionLoading = false, + totalParticipants: totalParticipantsOverride, + outcome = null, + } = overrides; + + mockUseRewardCampaigns.mockReturnValue({ + campaigns, + isLoading: isCampaignsLoading, + hasError: hasCampaignsError, + fetchCampaigns: mockFetchCampaigns, + categorizedCampaigns: { active: [], upcoming: [], previous: [] }, + hasLoaded: true, + } as ReturnType); + + mockUseGetCampaignParticipantStatus.mockReturnValue({ + status: { + optedIn: participant.optedIn, + participantCount: 0, + }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + } as ReturnType); + + const leaderboard = { + ...defaultLeaderboardHook.leaderboard, + ...(totalParticipantsOverride !== undefined + ? { totalParticipants: totalParticipantsOverride } + : {}), + }; + + mockUseGetPerpsTradingCampaignLeaderboard.mockReturnValue({ + ...defaultLeaderboardHook, + leaderboard, + } as ReturnType); + + mockUseGetPerpsTradingCampaignLeaderboardPosition.mockReturnValue({ + position: toMockLeaderboardPosition(position), + isLoading: isPositionLoading, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + } as ReturnType); + + mockUseGetPerpsTradingCampaignVolume.mockReturnValue({ + ...defaultVolumeHook, + } as ReturnType); + + mockUsePerpsTradingCampaignParticipantOutcome.mockReturnValue({ + outcome, + isLoading: false, + hasError: false, + } as ReturnType); +} + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string, params?: { count?: string }) => { + if ( + key === 'rewards.perps_trading_campaign.leaderboard_total_participants' && + params?.count !== undefined + ) { + return `${params.count} participants`; + } + const map: Record = { + 'rewards.perps_trading_campaign.title': 'Perps Trading', + 'rewards.perps_trading_campaign.stats_title': 'Stats', + 'rewards.perps_trading_campaign.prize_pool_title': 'Prize pool', + 'rewards.perps_trading_campaign.leaderboard_title': 'Leaderboard', + 'rewards.campaigns_view.error_title': 'Error', + 'rewards.campaigns_view.error_description': 'Try again', + 'rewards.campaigns_view.retry_button': 'Retry', + }; + return map[key] ?? key; + }, +})); + +describe('PerpsTradingCampaignDetailsView', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-08-15T12:00:00.000Z')); + jest.clearAllMocks(); + resetPerpsTradingCampaignDetailsSessionAutoNavigationForTests(); + mockRouteState.params = { campaignId: 'perps-campaign-1' }; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectReferralCode) { + return 'ref-code'; + } + return undefined; + }); + setupHooks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders skeletons while campaigns load and no campaign resolved', () => { + setupHooks({ campaigns: [], isCampaignsLoading: true }); + const { getAllByTestId, queryByTestId } = render( + , + ); + + expect(getAllByTestId('skeleton').length).toBeGreaterThanOrEqual(2); + expect(queryByTestId('campaign-status')).toBeNull(); + }); + + it('renders campaigns error banner and retries fetchCampaigns', () => { + setupHooks({ + campaigns: [], + isCampaignsLoading: false, + hasCampaignsError: true, + }); + const { getByTestId } = render(); + + fireEvent.press(getByTestId('campaigns-error-retry')); + expect(mockFetchCampaigns).toHaveBeenCalledTimes(1); + }); + + it('renders header, campaign status, prize pool, leaderboard, and CTA for active opted-in campaign', () => { + setupHooks({ participant: { optedIn: true } }); + + const { getByTestId } = render(); + + expect( + getByTestId(PERPS_CAMPAIGN_DETAILS_TEST_IDS.CONTAINER), + ).toBeDefined(); + expect(getByTestId('header')).toBeDefined(); + expect(getByTestId('campaign-status')).toBeDefined(); + expect(getByTestId('perps-prize-pool')).toBeDefined(); + expect(getByTestId('perps-leaderboard')).toBeDefined(); + expect(getByTestId('perps-trading-cta')).toBeDefined(); + }); + + it('shows the prize pool section for active non-opted-in users', () => { + const { getByTestId } = render(); + expect(getByTestId('perps-prize-pool')).toBeDefined(); + }); + + it('hides How it works when the user is opted in and has a leaderboard position', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + details: { + howItWorks: { + title: 'How it works', + description: 'Test description', + steps: [], + }, + }, + }), + ], + participant: { optedIn: true }, + position: { rank: 5, neighbors: [] }, + }); + + const { queryByTestId } = render(); + expect(queryByTestId('campaign-how-it-works')).toBeNull(); + }); + + it('shows How it works when opted in, no position, and position not loading', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + details: { + howItWorks: { + title: 'How it works', + description: 'Test description', + steps: [], + }, + }, + }), + ], + participant: { optedIn: true }, + position: null, + }); + + const { getByTestId } = render(); + expect(getByTestId('campaign-how-it-works')).toBeDefined(); + }); + + it('hides How it works while the leaderboard position is still loading for an opted-in user', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + details: { + howItWorks: { + title: 'How it works', + description: 'Test description', + steps: [], + }, + }, + }), + ], + participant: { optedIn: true }, + position: null, + isPositionLoading: true, + }); + + const { queryByTestId } = render(); + expect(queryByTestId('campaign-how-it-works')).toBeNull(); + }); + + it('shows How it works when active, user is not opted in, and details include howItWorks', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + details: { + howItWorks: { + title: 'How it works', + description: 'Test description', + steps: [], + }, + }, + }), + ], + }); + + const { getByTestId } = render(); + expect(getByTestId('campaign-how-it-works')).toBeDefined(); + }); + + it('shows stats header when user has a leaderboard position', () => { + setupHooks({ + participant: { optedIn: true }, + position: { rank: 3, neighbors: [] }, + }); + + const { getByTestId } = render(); + expect(getByTestId('perps-campaign-stats-summary-container')).toBeDefined(); + }); + + it('navigates to stats when stats header row is pressed and user has a position', () => { + setupHooks({ + participant: { optedIn: true }, + position: { rank: 2, neighbors: [] }, + }); + + const { getByText } = render(); + + fireEvent.press(getByText('Stats')); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_STATS, + { campaignId: 'perps-campaign-1' }, + ); + }); + + it('navigates to full leaderboard and mechanics help', () => { + const { getByText, getByTestId } = render( + , + ); + + fireEvent.press(getByText('Leaderboard')); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_LEADERBOARD, + { campaignId: 'perps-campaign-1' }, + ); + + fireEvent.press(getByTestId('perps-details-mechanics-button')); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_CAMPAIGN_MECHANICS, + { campaignId: 'perps-campaign-1' }, + ); + }); + + it('complete campaign for non-opted-in user shows leaderboard, prize pool, and ended stats and hides CTA', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + }); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('perps-leaderboard')).toBeDefined(); + expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull(); + expect(getByTestId('perps-prize-pool')).toBeDefined(); + expect(getByTestId('perps-campaign-ended-stats')).toBeDefined(); + expect(queryByTestId('perps-trading-cta')).toBeNull(); + }); + + it('complete campaign for opted-in user (no leaderboard position) shows ended stats and prize pool', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: null, + }); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('perps-campaign-ended-stats')).toBeDefined(); + expect(getByTestId('perps-prize-pool')).toBeDefined(); + expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull(); + }); + + it('shows outcome banner for completed opted-in participants and navigates winners to winning view', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: { rank: 3, neighbors: [] }, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'pending', + winnerVerificationCode: 'PERPS-WINNER-123', + rank: 3, + }, + }); + + const { getByTestId } = render(); + + fireEvent.press( + getByTestId('campaign-outcome-banner-pending-PERPS-WINNER-123'), + ); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + { + campaignId: 'perps-campaign-1', + campaignName: 'Perps Trading', + }, + ); + }); + + it('auto-navigates once to winning view for a completed pending winner outcome', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: { rank: 3, neighbors: [] }, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'pending', + winnerVerificationCode: 'PERPS-WINNER-123', + rank: 3, + }, + }); + + const { rerender } = render(); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + { + campaignId: 'perps-campaign-1', + campaignName: 'Perps Trading', + }, + ); + + mockNavigate.mockClear(); + rerender(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not auto-navigate for finalized outcomes', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: { rank: 3, neighbors: [] }, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'finalized', + winnerVerificationCode: 'PERPS-WINNER-123', + rank: 3, + }, + }); + + render(); + + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + expect.any(Object), + ); + }); + + it('shows outcome banner inside the ended stats section for opted-in users with no leaderboard position', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: null, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + }); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull(); + expect(getByTestId('perps-campaign-ended-stats')).toBeDefined(); + expect(getByTestId('campaign-outcome-banner-finalized-null')).toBeDefined(); + }); + + it('displays total participant count when the leaderboard reports participants', () => { + setupHooks({ totalParticipants: 1500 }); + + const { getByText, getByTestId } = render( + , + ); + + expect( + getByTestId('perps-campaign-leaderboard-total-participants'), + ).toBeDefined(); + expect(getByText('1,500 participants')).toBeDefined(); + }); + + it('hides the prize pool section for upcoming campaigns', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2026-01-01T00:00:00.000Z', + endDate: '2027-12-31T23:59:59.999Z', + }), + ], + }); + + const { queryByTestId } = render(); + expect(queryByTestId('perps-prize-pool')).toBeNull(); + }); + + it('resolves campaign by PERPS_TRADING type when route has no campaignId', () => { + mockRouteState.params = {}; + setupHooks({ + campaigns: [buildPerpsCampaign({ id: 'resolved-by-type' })], + }); + + const { getByTestId } = render(); + expect(getByTestId('campaign-status')).toBeDefined(); + + fireEvent.press(getByTestId('perps-details-mechanics-button')); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_CAMPAIGN_MECHANICS, + { campaignId: 'resolved-by-type' }, + ); + }); +}); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx new file mode 100644 index 00000000000..14032c9d1db --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx @@ -0,0 +1,458 @@ +import React, { useCallback, useMemo } from 'react'; +import { Pressable, ScrollView } from 'react-native'; +import { + useFocusEffect, + useNavigation, + useRoute, + RouteProp, +} from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Icon, + IconColor, + IconName, + IconSize, + Skeleton, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import CampaignStatus from '../components/Campaigns/CampaignStatus'; +import CampaignHowItWorks from '../components/Campaigns/CampaignHowItWorks'; +import PerpsTradingCampaignLeaderboard, { + PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS, +} from '../components/Campaigns/PerpsTradingCampaignLeaderboard'; +import PerpsTradingCampaignPrizePool from '../components/Campaigns/PerpsTradingCampaignPrizePool'; +import PerpsTradingCampaignCTA from '../components/Campaigns/PerpsTradingCampaignCTA'; +import PerpsCampaignStatsSummary from '../components/Campaigns/PerpsCampaignStatsSummary'; +import PerpsTradingCampaignEndedStats from '../components/Campaigns/PerpsTradingCampaignEndedStats'; +import { CampaignOutcomeBanner } from '../components/Campaigns/CampaignOutcomeBanners'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetPerpsTradingCampaignVolume } from '../hooks/useGetPerpsTradingCampaignVolume'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; +import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; +import { + CampaignType, + OndoCampaignHowItWorks, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { selectReferralCode } from '../../../../reducers/rewards/selectors'; +import { getCampaignMechanicsButtonProps } from '../utils/campaignHeaderUtils'; +import RewardsErrorBanner from '../components/RewardsErrorBanner'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type PerpsTradingCampaignDetailsRouteParams = { + RewardsPerpsTradingCampaignDetails: { campaignId?: string }; +}; + +export const PERPS_CAMPAIGN_DETAILS_TEST_IDS = { + CONTAINER: 'perps-campaign-details-container', +} as const; + +const sessionWinningViewAutoNavCampaignIds = new Set(); +export function resetPerpsTradingCampaignDetailsSessionAutoNavigationForTests(): void { + sessionWinningViewAutoNavCampaignIds.clear(); +} + +const PerpsTradingCampaignDetailsView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const route = + useRoute< + RouteProp< + PerpsTradingCampaignDetailsRouteParams, + 'RewardsPerpsTradingCampaignDetails' + > + >(); + const routeCampaignId = route.params?.campaignId; + const referralCode = useSelector(selectReferralCode); + + const { + campaigns, + isLoading: isCampaignsLoading, + hasError: hasCampaignsError, + fetchCampaigns, + } = useRewardCampaigns(); + + const campaign = useMemo( + () => + campaigns.find((c) => + routeCampaignId + ? c.id === routeCampaignId + : c.type === CampaignType.PERPS_TRADING, + ) ?? null, + [campaigns, routeCampaignId], + ); + + const effectiveCampaignId = routeCampaignId ?? campaign?.id ?? ''; + + const { + status: participantStatusData, + isLoading: isParticipantStatusLoading, + } = useGetCampaignParticipantStatus(effectiveCampaignId || undefined); + + const isOptedIn = participantStatusData?.optedIn === true; + const campaignStatus = campaign ? getCampaignStatus(campaign) : null; + const isActive = campaignStatus === 'active'; + const isComplete = campaignStatus === 'complete'; + + const { + leaderboard, + isLoading: isLeaderboardLoading, + hasError: hasLeaderboardError, + isLeaderboardNotYetComputed, + refetch: refetchLeaderboard, + } = useGetPerpsTradingCampaignLeaderboard(effectiveCampaignId || undefined); + + const { position, isLoading: isPositionLoading } = + useGetPerpsTradingCampaignLeaderboardPosition( + isOptedIn ? effectiveCampaignId || undefined : undefined, + ); + const { outcome: participantOutcome } = + usePerpsTradingCampaignParticipantOutcome( + isComplete && isOptedIn ? effectiveCampaignId || undefined : undefined, + ); + + const { + volume, + isLoading: isVolumeLoading, + hasError: hasVolumeError, + refetch: refetchVolume, + } = useGetPerpsTradingCampaignVolume(effectiveCampaignId || undefined); + + const leaderboardUserPosition = useMemo( + () => + position + ? { rank: position.rank, neighbors: position.neighbors ?? [] } + : null, + [position], + ); + + const hasPosition = Boolean(leaderboardUserPosition); + const totalParticipants = leaderboard?.totalParticipants ?? 0; + + const { + showHowItWorksSection, + showStatsSummarySection, + showPrizePoolSection, + showLeaderboardSection, + showCampaignEndedStats, + } = useMemo(() => { + if (!campaign) { + return { + showHowItWorksSection: false, + showStatsSummarySection: false, + showPrizePoolSection: false, + showLeaderboardSection: false, + showCampaignEndedStats: false, + }; + } + + const showEndedStats = + isComplete && !isParticipantStatusLoading && (!isOptedIn || !hasPosition); + + return { + showHowItWorksSection: + Boolean(campaign.details?.howItWorks) && + isActive && + (!isOptedIn || (!hasPosition && !isPositionLoading)), + showStatsSummarySection: hasPosition, + showPrizePoolSection: isActive || isComplete, + showLeaderboardSection: true, + showCampaignEndedStats: showEndedStats, + }; + }, [ + campaign, + isActive, + isComplete, + isOptedIn, + isParticipantStatusLoading, + hasPosition, + isPositionLoading, + ]); + + const navigateToLeaderboard = useCallback(() => { + if (!effectiveCampaignId) return; + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_LEADERBOARD, { + campaignId: effectiveCampaignId, + }); + }, [navigation, effectiveCampaignId]); + + const navigateToStats = useCallback(() => { + if (!effectiveCampaignId) return; + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_STATS, { + campaignId: effectiveCampaignId, + }); + }, [navigation, effectiveCampaignId]); + + const navigateToWinningView = useCallback(() => { + if (!effectiveCampaignId) return; + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, { + campaignId: effectiveCampaignId, + campaignName: campaign?.name ?? '', + }); + }, [navigation, effectiveCampaignId, campaign]); + + useFocusEffect( + useCallback(() => { + if ( + !sessionWinningViewAutoNavCampaignIds.has(effectiveCampaignId) && + campaign && + isComplete && + participantOutcome?.winnerVerificationCode && + participantOutcome?.outcomeStatus === 'pending' && + effectiveCampaignId + ) { + sessionWinningViewAutoNavCampaignIds.add(effectiveCampaignId); + navigateToWinningView(); + } + }, [ + campaign, + effectiveCampaignId, + isComplete, + navigateToWinningView, + participantOutcome, + ]), + ); + + const navigateToMechanics = useCallback(() => { + if (!effectiveCampaignId) return; + navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, { + campaignId: effectiveCampaignId, + }); + }, [navigation, effectiveCampaignId]); + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'perps-details-back-button' }} + endButtonIconProps={getCampaignMechanicsButtonProps( + campaign != null, + navigateToMechanics, + 'perps-details-mechanics-button', + )} + includesTopInset + /> + + + {isCampaignsLoading && !campaign && ( + + + + + )} + + {!isCampaignsLoading && hasCampaignsError && !campaign && ( + + + + )} + + {campaign && ( + <> + + + {showHowItWorksSection && ( + + + + )} + + {showCampaignEndedStats && ( + + + {isOptedIn && participantOutcome?.outcomeStatus != null && ( + + )} + + )} + + {showStatsSummarySection && ( + + + + + {strings('rewards.perps_trading_campaign.stats_title')} + + + + + + + )} + + {showPrizePoolSection && ( + <> + + + + {strings( + 'rewards.perps_trading_campaign.prize_pool_title', + )} + + + + + )} + + {showLeaderboardSection && ( + <> + + + + + + {strings( + 'rewards.perps_trading_campaign.leaderboard_title', + )} + + + + + + {totalParticipants > 0 && ( + + {strings( + 'rewards.perps_trading_campaign.leaderboard_total_participants', + { count: totalParticipants.toLocaleString() }, + )} + + )} + + + + + )} + + )} + + + {/* Bottom CTA */} + {campaign && ( + + )} + + + ); +}; + +export default PerpsTradingCampaignDetailsView; diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.test.tsx new file mode 100644 index 00000000000..b4a0ce8bccd --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.test.tsx @@ -0,0 +1,305 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import PerpsTradingCampaignLeaderboardView, { + PERPS_CAMPAIGN_LEADERBOARD_VIEW_TEST_IDS, +} from './PerpsTradingCampaignLeaderboardView'; +import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { + CampaignType, + type PerpsTradingCampaignLeaderboardPositionDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; + +const mockGoBack = jest.fn(); +const mockPerpsLeaderboard = jest.fn(); +const mockPerpsStatsHeader = jest.fn(); + +const CAMPAIGN_ID = 'perps-lb-campaign-1'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack, navigate: jest.fn() }), + useRoute: () => ({ + params: { campaignId: CAMPAIGN_ID }, + }), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => { + const tw = () => ({}); + tw.style = (..._args: unknown[]) => ({}); + return tw; + }, +})); + +jest.mock( + '../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onBack, + backButtonProps, + }: { + title: string; + onBack: () => void; + backButtonProps?: { testID?: string }; + }) => + ReactActual.createElement( + View, + { testID: 'perps-lb-header' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Pressable, { + onPress: onBack, + testID: backButtonProps?.testID ?? 'perps-lb-back', + }), + ), + }; + }, +); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('react-native-safe-area-context', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + SafeAreaView: ({ + children, + ...props + }: { + children: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, props, children), + }; +}); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(() => jest.fn()), +})); + +jest.mock('../components/Campaigns/PerpsTradingCampaignLeaderboard', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: Record) => { + mockPerpsLeaderboard(props); + return ReactActual.createElement(View, { + testID: 'perps-leaderboard-mock', + }); + }, + }; +}); + +jest.mock('../components/Campaigns/PerpsTradingCampaignStatsHeader', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: Record) => { + mockPerpsStatsHeader(props); + return ReactActual.createElement(View, { + testID: 'perps-lb-stats-header-mock', + }); + }, + }; +}); + +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboard'); +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition'); +jest.mock('../hooks/useGetCampaignParticipantStatus'); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseGetLeaderboard = + useGetPerpsTradingCampaignLeaderboard as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboard + >; +const mockUseGetPosition = + useGetPerpsTradingCampaignLeaderboardPosition as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboardPosition + >; +const mockUseGetParticipant = + useGetCampaignParticipantStatus as jest.MockedFunction< + typeof useGetCampaignParticipantStatus + >; + +const basePosition: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 4, + pnl: 1000, + notionalVolume: 10_000, + marginDeployed: 2000, + qualified: true, + neighbors: [], + computedAt: '2025-01-01T00:00:00.000Z', +}; + +const leaderboardHookDefaults = { + leaderboard: { + campaignId: CAMPAIGN_ID, + computedAt: '2025-01-01T00:00:00.000Z', + entries: [{ rank: 1, referralCode: 'A', pnl: 1, qualified: true }], + totalParticipants: 50, + }, + isLoading: false, + hasError: false, + isLeaderboardNotYetComputed: false, + refetch: jest.fn(), +}; + +const mockCampaign = { + id: CAMPAIGN_ID, + type: CampaignType.PERPS_TRADING, + name: 'Perps Test', + startDate: '2024-01-01T00:00:00Z', + endDate: '2099-12-31T23:59:59Z', + termsAndConditions: null, + excludedRegions: [], + featured: false, + details: { howItWorks: { title: '', description: '', steps: [] } }, +}; + +const mockState = { + rewards: { + referralCode: 'REFCODE99', + campaigns: [mockCampaign], + }, +}; + +describe('PerpsTradingCampaignLeaderboardView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockImplementation((selector: (s: unknown) => unknown) => + selector(mockState), + ); + mockUseGetParticipant.mockReturnValue({ + status: { optedIn: false, participantCount: 0 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetLeaderboard.mockReturnValue(leaderboardHookDefaults); + mockUseGetPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + }); + + it('renders with the correct container testID', () => { + const { getByTestId } = render(); + expect( + getByTestId(PERPS_CAMPAIGN_LEADERBOARD_VIEW_TEST_IDS.CONTAINER), + ).toBeDefined(); + }); + + it('navigates back when the back button is pressed', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('perps-leaderboard-back-button')); + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('fetches leaderboard with route campaignId', () => { + render(); + expect(mockUseGetLeaderboard).toHaveBeenCalledWith(CAMPAIGN_ID); + }); + + it('does not render the stats header when the user is not opted in', () => { + const { queryByTestId } = render(); + expect(queryByTestId('perps-lb-stats-header-mock')).toBeNull(); + }); + + it('passes undefined to position hook when not opted in', () => { + render(); + expect(mockUseGetPosition).toHaveBeenCalledWith(undefined); + }); + + it('renders the stats header and passes campaignId to position hook when opted in', () => { + mockUseGetParticipant.mockReturnValue({ + status: { optedIn: true, participantCount: 10 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetPosition.mockReturnValue({ + position: basePosition, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + + const { getByTestId } = render(); + expect(getByTestId('perps-lb-stats-header-mock')).toBeDefined(); + expect(mockUseGetPosition).toHaveBeenCalledWith(CAMPAIGN_ID); + expect(mockPerpsStatsHeader).toHaveBeenCalledWith( + expect.objectContaining({ + position: basePosition, + isLoading: false, + }), + ); + }); + + it('passes leaderboard data and user position to PerpsTradingCampaignLeaderboard', () => { + mockUseGetParticipant.mockReturnValue({ + status: { optedIn: true, participantCount: 10 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetPosition.mockReturnValue({ + position: basePosition, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + + render(); + expect(mockPerpsLeaderboard).toHaveBeenCalledWith( + expect.objectContaining({ + entries: leaderboardHookDefaults.leaderboard?.entries, + isLoading: leaderboardHookDefaults.isLoading, + hasError: leaderboardHookDefaults.hasError, + isLeaderboardNotYetComputed: + leaderboardHookDefaults.isLeaderboardNotYetComputed, + currentUserReferralCode: 'REFCODE99', + userPosition: { + rank: basePosition.rank, + neighbors: basePosition.neighbors, + }, + campaignId: CAMPAIGN_ID, + onRetry: leaderboardHookDefaults.refetch, + isCampaignComplete: false, + }), + ); + }); + + it('renders the leaderboard section', () => { + const { getByTestId } = render(); + expect(getByTestId('perps-leaderboard-mock')).toBeDefined(); + }); +}); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx new file mode 100644 index 00000000000..b601329431d --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx @@ -0,0 +1,143 @@ +import React, { useMemo } from 'react'; +import { ScrollView } from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { Box, TextVariant } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useSelector } from 'react-redux'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import PerpsTradingCampaignLeaderboard from '../components/Campaigns/PerpsTradingCampaignLeaderboard'; +import PerpsTradingCampaignStatsHeader from '../components/Campaigns/PerpsTradingCampaignStatsHeader'; +import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; +import { + selectReferralCode, + selectCampaignById, +} from '../../../../reducers/rewards/selectors'; +import { getCampaignMechanicsButtonProps } from '../utils/campaignHeaderUtils'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type PerpsTradingCampaignLeaderboardRouteParams = { + RewardsPerpsTradingCampaignLeaderboard: { campaignId: string }; +}; + +export const PERPS_CAMPAIGN_LEADERBOARD_VIEW_TEST_IDS = { + CONTAINER: 'perps-campaign-leaderboard-view-container', +} as const; + +const PerpsTradingCampaignLeaderboardView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const route = + useRoute< + RouteProp< + PerpsTradingCampaignLeaderboardRouteParams, + 'RewardsPerpsTradingCampaignLeaderboard' + > + >(); + const { campaignId } = route.params; + const referralCode = useSelector(selectReferralCode); + + const selectCampaign = useMemo( + () => selectCampaignById(campaignId), + [campaignId], + ); + const campaign = useSelector(selectCampaign); + + const { status: participantStatus } = + useGetCampaignParticipantStatus(campaignId); + const isOptedIn = participantStatus?.optedIn === true; + + const { position, isLoading: isPositionLoading } = + useGetPerpsTradingCampaignLeaderboardPosition( + isOptedIn ? campaignId : undefined, + ); + + const { + leaderboard, + isLoading: isLeaderboardLoading, + hasError: hasLeaderboardError, + isLeaderboardNotYetComputed, + refetch: refetchLeaderboard, + } = useGetPerpsTradingCampaignLeaderboard(campaignId); + + const leaderboardUserPosition = useMemo( + () => + position + ? { rank: position.rank, neighbors: position.neighbors ?? [] } + : null, + [position], + ); + + const isCampaignComplete = + campaign != null && getCampaignStatus(campaign) === 'complete'; + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'perps-leaderboard-back-button' }} + endButtonIconProps={getCampaignMechanicsButtonProps( + campaign != null, + () => + navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, { + campaignId, + }), + 'perps-leaderboard-mechanics-button', + )} + includesTopInset + /> + + {/* User position header */} + {isOptedIn && ( + <> + + + + + + )} + + {/* Full leaderboard */} + + + + + + + ); +}; + +export default PerpsTradingCampaignLeaderboardView; diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx new file mode 100644 index 00000000000..64fbed6e9ad --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx @@ -0,0 +1,433 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import PerpsTradingCampaignStatsView, { + PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS, +} from './PerpsTradingCampaignStatsView'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; +import { + CampaignType, + type PerpsTradingCampaignLeaderboardPositionDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import Routes from '../../../../constants/navigation/Routes'; + +const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); +const mockPerpsStatsHeader = jest.fn(); + +const CAMPAIGN_ID = 'perps-stats-campaign-1'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }), + useRoute: () => ({ + params: { campaignId: CAMPAIGN_ID }, + }), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const Skeleton = (props: Record) => + ReactActual.createElement(View, { testID: 'skeleton', ...props }); + return { ...actual, Skeleton }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => { + const tw = () => ({}); + tw.style = (..._args: unknown[]) => ({}); + return tw; + }, +})); + +jest.mock( + '../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onBack, + backButtonProps, + endButtonIconProps, + }: { + title: string; + onBack: () => void; + backButtonProps?: { testID?: string }; + endButtonIconProps?: { testID?: string; onPress?: () => void }[]; + }) => + ReactActual.createElement( + View, + { testID: 'perps-stats-header' }, + ReactActual.createElement(Text, null, title), + ReactActual.createElement(Pressable, { + onPress: onBack, + testID: backButtonProps?.testID ?? 'perps-stats-back', + }), + ...(endButtonIconProps ?? []).map((btn, i) => + ReactActual.createElement(Pressable, { + key: i, + onPress: btn.onPress, + testID: btn.testID, + }), + ), + ), + }; + }, +); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('react-native-safe-area-context', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + SafeAreaView: ({ + children, + ...props + }: { + children: React.ReactNode; + testID?: string; + }) => ReactActual.createElement(View, props, children), + }; +}); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(() => jest.fn()), +})); + +jest.mock('../components/Campaigns/PerpsTradingCampaignStatsHeader', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: Record) => { + mockPerpsStatsHeader(props); + return ReactActual.createElement(View, { + testID: 'perps-stats-header-mock', + }); + }, + }; +}); + +jest.mock('../utils/formatUtils', () => ({ + formatSignedUsd: (value: number) => `SIGNED_USD_${String(value)}`, + formatUsd: (value: number) => `USD_${String(value)}`, + formatRewardsTimeOnly: () => 'TIME_STUB', +})); + +jest.mock('../components/RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ testID }: { testID?: string }) => + ReactActual.createElement(View, { + testID: testID ?? 'rewards-error-banner', + }), + }; +}); + +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition'); +jest.mock('../hooks/useGetCampaignParticipantStatus'); +jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome', () => ({ + usePerpsTradingCampaignParticipantOutcome: jest.fn(() => ({ + outcome: null, + isLoading: false, + hasError: false, + })), +})); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseGetPosition = + useGetPerpsTradingCampaignLeaderboardPosition as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboardPosition + >; +const mockUseGetParticipant = + useGetCampaignParticipantStatus as jest.MockedFunction< + typeof useGetCampaignParticipantStatus + >; +const mockUsePerpsTradingCampaignParticipantOutcome = + usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction< + typeof usePerpsTradingCampaignParticipantOutcome + >; + +const basePosition: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 4, + pnl: 1500.25, + notionalVolume: 30_000, + marginDeployed: 2000, + qualified: true, + neighbors: [], + computedAt: '2025-01-01T00:00:00.000Z', +}; + +const mockCampaign = { + id: CAMPAIGN_ID, + type: CampaignType.PERPS_TRADING, + name: 'Perps Stats Test', + startDate: '2024-01-01T00:00:00Z', + endDate: '2099-12-31T23:59:59Z', + termsAndConditions: null, + excludedRegions: [], + featured: false, + details: { howItWorks: { title: '', description: '', steps: [] } }, +}; + +const mockState = { + rewards: { + campaigns: [mockCampaign], + }, +}; + +describe('PerpsTradingCampaignStatsView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockImplementation((selector: (s: unknown) => unknown) => + selector(mockState), + ); + mockUseGetParticipant.mockReturnValue({ + status: { optedIn: true, participantCount: 5 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUsePerpsTradingCampaignParticipantOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, + }); + mockUseGetPosition.mockReturnValue({ + position: basePosition, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + }); + + it('renders with the correct container testID', () => { + const { getByTestId } = render(); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.CONTAINER), + ).toBeDefined(); + }); + + it('navigates back when the back button is pressed', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('perps-stats-back-button')); + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('navigates to campaign mechanics when the header mechanics button is pressed', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('perps-stats-mechanics-button')); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_CAMPAIGN_MECHANICS, + { campaignId: CAMPAIGN_ID }, + ); + }); + + it('passes position to stats header with PnL and computed-at hidden', () => { + render(); + expect(mockPerpsStatsHeader).toHaveBeenCalledWith( + expect.objectContaining({ + position: basePosition, + isLoading: false, + showPnl: false, + showComputedAt: false, + }), + ); + }); + + it('passes undefined to position hook when not opted in', () => { + mockUseGetParticipant.mockReturnValue({ + status: { optedIn: false, participantCount: 0 }, + isLoading: false, + hasError: false, + refetch: jest.fn(), + }); + mockUseGetPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + render(); + expect(mockUseGetPosition).toHaveBeenCalledWith(undefined); + }); + + it('renders performance section labels and stat testIDs when opted in with position', () => { + const { getByTestId, getByText } = render( + , + ); + expect( + getByText('rewards.perps_trading_campaign.performance_title'), + ).toBeDefined(); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_PNL), + ).toBeDefined(); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME), + ).toBeDefined(); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN), + ).toBeDefined(); + }); + + it('shows last-computed when position has a timestamp', () => { + const { getByTestId } = render(); + const el = getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.LAST_COMPUTED); + expect(el.props.children).toBe( + 'rewards.perps_trading_campaign.last_updated', + ); + }); + + it('hides last-computed when there is no position', () => { + mockUseGetPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + const { queryByTestId } = render(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.LAST_COMPUTED), + ).toBeNull(); + }); + + it("shows You're qualified card under performance when active and user is qualified", () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFIED_CARD), + ).toBeDefined(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFY_FOR_RANK_CARD), + ).toBeNull(); + }); + + it('hides volume and margin StatCells when campaign is complete (only PnL remains)', () => { + const completeCampaign = { + ...mockCampaign, + endDate: '2020-01-01T00:00:00Z', + }; + mockUseSelector.mockImplementation((selector: (s: unknown) => unknown) => + selector({ + rewards: { campaigns: [completeCampaign] }, + }), + ); + const { getByTestId, queryByTestId } = render( + , + ); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_PNL), + ).toBeDefined(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME), + ).toBeNull(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN), + ).toBeNull(); + }); + + it('hides qualification cards when campaign is complete and shows last-computed after performance when position exists', () => { + const completeCampaign = { + ...mockCampaign, + endDate: '2020-01-01T00:00:00Z', + }; + mockUseSelector.mockImplementation((selector: (s: unknown) => unknown) => + selector({ + rewards: { campaigns: [completeCampaign] }, + }), + ); + const { getByTestId, queryByTestId } = render( + , + ); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFIED_CARD), + ).toBeNull(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFY_FOR_RANK_CARD), + ).toBeNull(); + const last = getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.LAST_COMPUTED); + const qualified = queryByTestId( + PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFIED_CARD, + ); + expect(qualified).toBeNull(); + expect(last).toBeDefined(); + }); + + it('shows Qualify for rank card when pending and notional is below threshold', () => { + mockUseGetPosition.mockReturnValue({ + position: { + ...basePosition, + qualified: false, + notionalVolume: 5_000, + marginDeployed: 500, + }, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + const { getByTestId, queryByTestId } = render( + , + ); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFY_FOR_RANK_CARD), + ).toBeDefined(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.QUALIFIED_CARD), + ).toBeNull(); + }); + + it('shows error banner when hasError is true and no position data', () => { + mockUseGetPosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: true, + hasFetched: true, + refetch: jest.fn(), + }); + const { getByTestId } = render(); + expect(getByTestId('rewards-error-banner')).toBeDefined(); + }); + + it('hides error banner when hasError is false', () => { + const { queryByTestId } = render(); + expect(queryByTestId('rewards-error-banner')).toBeNull(); + }); + + it('hides error banner when there is an error but position data is already loaded', () => { + mockUseGetPosition.mockReturnValue({ + position: basePosition, + isLoading: false, + hasError: true, + hasFetched: true, + refetch: jest.fn(), + }); + const { queryByTestId } = render(); + expect(queryByTestId('rewards-error-banner')).toBeNull(); + }); +}); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx new file mode 100644 index 00000000000..73df3b431f5 --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx @@ -0,0 +1,306 @@ +import React, { useCallback, useMemo } from 'react'; +import { ScrollView } from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Icon, + IconName, + IconColor, + IconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useSelector } from 'react-redux'; +import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import RewardsErrorBanner from '../components/RewardsErrorBanner'; +import PerpsTradingCampaignStatsHeader from '../components/Campaigns/PerpsTradingCampaignStatsHeader'; +import { StatCell } from '../components/Campaigns/OndoCampaignStatsSummary'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; +import { selectCampaignById } from '../../../../reducers/rewards/selectors'; +import { getCampaignMechanicsButtonProps } from '../utils/campaignHeaderUtils'; +import { PERPS_QUALIFICATION_NOTIONAL_USD } from '../utils/perpsCampaignConstants'; +import { + formatRewardsTimeOnly, + formatSignedUsd, + formatUsd, +} from '../utils/formatUtils'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; +import { CampaignOutcomeBanner } from '../components/Campaigns/CampaignOutcomeBanners'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type PerpsTradingCampaignStatsRouteParams = { + RewardsPerpsTradingCampaignStats: { campaignId: string }; +}; + +export const PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS = { + CONTAINER: 'perps-campaign-stats-view-container', + PERFORMANCE_PNL: 'perps-campaign-stats-view-performance-pnl', + PERFORMANCE_VOLUME: 'perps-campaign-stats-view-performance-volume', + PERFORMANCE_MARGIN: 'perps-campaign-stats-view-performance-margin', + QUALIFIED_CARD: 'perps-campaign-stats-view-qualified-card', + QUALIFY_FOR_RANK_CARD: 'perps-campaign-stats-view-qualify-for-rank-card', + LAST_COMPUTED: 'perps-campaign-stats-view-last-computed', +} as const; + +const CheckIcon: React.FC = () => ( + +); + +const PerpsTradingCampaignStatsView: React.FC = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const route = + useRoute< + RouteProp< + PerpsTradingCampaignStatsRouteParams, + 'RewardsPerpsTradingCampaignStats' + > + >(); + const { campaignId } = route.params; + + const selectCampaign = useMemo( + () => selectCampaignById(campaignId), + [campaignId], + ); + const campaign = useSelector(selectCampaign); + + const { status: participantStatusData } = + useGetCampaignParticipantStatus(campaignId); + const isOptedIn = participantStatusData?.optedIn === true; + + const { position, isLoading, hasError, refetch } = + useGetPerpsTradingCampaignLeaderboardPosition( + isOptedIn ? campaignId : undefined, + ); + + const pnlValue = position ? formatSignedUsd(position.pnl) : '—'; + const pnlColor = position + ? position.pnl >= 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault + : TextColor.TextDefault; + + const volumeValue = position ? formatUsd(position.notionalVolume) : '—'; + const marginValue = position ? formatUsd(position.marginDeployed) : '—'; + const isQualified = position != null && position.qualified; + const isPending = position != null && !position.qualified; + + const isCampaignComplete = + campaign != null && getCampaignStatus(campaign) === 'complete'; + + const notionalGap = position + ? Math.max(0, PERPS_QUALIFICATION_NOTIONAL_USD - position.notionalVolume) + : 0; + + const showQualifiedCard = + !isCampaignComplete && isQualified && position != null; + + const showQualifyForRankCard = + !isCampaignComplete && isPending && position != null && notionalGap > 0; + + const positionError = hasError && !position; + + const { outcome: participantOutcome } = + usePerpsTradingCampaignParticipantOutcome( + isCampaignComplete && isOptedIn ? campaignId : undefined, + ); + + const navigateToWinningView = useCallback(() => { + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, { + campaignId, + campaignName: campaign?.name ?? '', + }); + }, [navigation, campaignId, campaign]); + + return ( + + + navigation.goBack()} + backButtonProps={{ testID: 'perps-stats-back-button' }} + endButtonIconProps={getCampaignMechanicsButtonProps( + campaign != null, + () => + navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, { + campaignId, + }), + 'perps-stats-mechanics-button', + )} + includesTopInset + /> + + + + + + + + {strings('rewards.perps_trading_campaign.performance_title')} + + + + + + + + {!isCampaignComplete && ( + + : undefined} + testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME} + /> + : undefined} + testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN} + /> + + )} + + {showQualifiedCard && ( + + + {strings( + 'rewards.perps_trading_campaign.stats_qualified_title', + )} + + + {strings( + 'rewards.perps_trading_campaign.stats_qualified_description', + )} + + + )} + + {showQualifyForRankCard && ( + + + + {strings( + 'rewards.perps_trading_campaign.stats_qualify_for_rank_title', + )} + + + + {strings( + 'rewards.perps_trading_campaign.stats_qualify_for_rank_description', + { + notionalRemaining: formatUsd(notionalGap), + }, + )} + + + )} + + {/* ── Last updated ── */} + {position?.computedAt && ( + + {strings('rewards.perps_trading_campaign.last_updated', { + time: formatRewardsTimeOnly(new Date(position.computedAt)), + })} + + )} + + {/* ── Outcome banner (campaign ended) ── */} + {isCampaignComplete && participantOutcome && ( + + )} + + {/* ── Error banner ── */} + {positionError && ( + + )} + + + + + ); +}; + +export default PerpsTradingCampaignStatsView; diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.test.tsx new file mode 100644 index 00000000000..13fe1dc93b3 --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.test.tsx @@ -0,0 +1,206 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import PerpsTradingCampaignWinningView, { + PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS, +} from './PerpsTradingCampaignWinningView'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import CampaignWinningView from './CampaignWinningView'; +import Routes from '../../../../constants/navigation/Routes'; + +jest.mock('./CampaignWinningView', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: jest.fn(({ testID }: { testID: string }) => + ReactActual.createElement(View, { testID }), + ), + }; +}); + +jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome', () => ({ + usePerpsTradingCampaignParticipantOutcome: jest.fn(), +})); + +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition', () => ({ + useGetPerpsTradingCampaignLeaderboardPosition: jest.fn(), +})); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: jest.fn(), navigate: jest.fn() }), + useRoute: () => ({ + params: { campaignId: 'campaign-perps-1', campaignName: 'Perps Campaign' }, + }), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => { + const tw = (...args: unknown[]) => args; + tw.style = (...args: unknown[]) => args; + return { useTailwind: () => tw }; +}); + +const mockUseOutcome = + usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction< + typeof usePerpsTradingCampaignParticipantOutcome + >; +const mockUsePosition = + useGetPerpsTradingCampaignLeaderboardPosition as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboardPosition + >; +const mockCampaignWinningView = CampaignWinningView as jest.MockedFunction< + typeof CampaignWinningView +>; + +describe('PerpsTradingCampaignWinningView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'pending', + winnerVerificationCode: 'PERPS-WIN-99', + rank: 3, + }, + isLoading: false, + hasError: false, + }); + mockUsePosition.mockReturnValue({ + position: { + rank: 3, + pnl: 1500.25, + notionalVolume: 30000, + marginDeployed: 1200, + qualified: true, + neighbors: [], + computedAt: '2025-08-15T12:00:00.000Z', + }, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + }); + + it('renders the container with the Perps testID', () => { + const { getByTestId } = render(); + expect( + getByTestId(PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER), + ).toBeTruthy(); + }); + + it('passes correct Perps-specific props to CampaignWinningView', () => { + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + testID: PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER, + prizeEmail: 'perpscampaign@consensys.net', + campaignName: 'Perps Campaign', + campaignId: 'campaign-perps-1', + analyticsPageType: 'perps_trading_campaign_winning', + winningCode: 'PERPS-WIN-99', + hasOutcomeLoaded: true, + isLoading: false, + rankDisplay: '3rd', + resultDisplay: '+$1,500.25', + isRankLoading: false, + isResultLoading: false, + fallbackRoute: { + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: 'campaign-perps-1' }, + }, + }), + {}, + ); + }); + + it('passes winningCode as null when outcome has no code', () => { + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'finalized', + winnerVerificationCode: null, + rank: 21, + }, + isLoading: false, + hasError: false, + }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: true, + }), + {}, + ); + }); + + it('does not mark outcome as loaded until the outcome exists', () => { + mockUseOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, + }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: false, + }), + {}, + ); + }); + + it('passes rankDisplay when rank is available', () => { + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + rankDisplay: '3rd', + isRankLoading: false, + isResultLoading: false, + }), + {}, + ); + }); + + it('passes rank from outcome and no result when position is unavailable', () => { + mockUsePosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + rankDisplay: '3rd', + resultDisplay: null, + isRankLoading: false, + isResultLoading: false, + }), + {}, + ); + }); + + it('does not pass rankDisplay when outcome has no rank', () => { + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + rank: null, + }, + isLoading: false, + hasError: false, + }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + rankDisplay: null, + isRankLoading: false, + }), + {}, + ); + }); +}); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx new file mode 100644 index 00000000000..f2a95fbd700 --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx @@ -0,0 +1,80 @@ +import React, { useMemo } from 'react'; +import { useRoute, RouteProp } from '@react-navigation/native'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; +import { formatOrdinalRank, formatSignedUsd } from '../utils/formatUtils'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import CampaignWinningView from './CampaignWinningView'; +import Routes from '../../../../constants/navigation/Routes'; + +const PRIZE_EMAIL = 'perpscampaign@consensys.net'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type PerpsTradingCampaignWinningRouteParams = { + RewardsPerpsTradingCampaignWinning: { + campaignId: string; + campaignName: string; + }; +}; + +export const PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS = { + CONTAINER: 'perps-trading-campaign-winning-view-container', +} as const; + +const PerpsTradingCampaignWinningView: React.FC = () => { + const route = + useRoute< + RouteProp< + PerpsTradingCampaignWinningRouteParams, + 'RewardsPerpsTradingCampaignWinning' + > + >(); + const { campaignId, campaignName } = route.params; + + const { outcome, isLoading: isOutcomeLoading } = + usePerpsTradingCampaignParticipantOutcome(campaignId); + const winningCode = outcome?.winnerVerificationCode ?? null; + + const { position, isLoading: positionLoading } = + useGetPerpsTradingCampaignLeaderboardPosition(campaignId); + + const rankDisplay = useMemo(() => { + if (!outcome?.rank) { + return null; + } + return formatOrdinalRank(outcome.rank); + }, [outcome]); + + const resultDisplay = useMemo(() => { + if (!position) return null; + return formatSignedUsd(position.pnl); + }, [position]); + + const fallbackRoute = useMemo( + () => ({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId }, + }), + [campaignId], + ); + + return ( + + ); +}; + +export default PerpsTradingCampaignWinningView; diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx index 1c5b6ff9fdb..27ccd54e53f 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx @@ -4,6 +4,8 @@ import { useSelector } from 'react-redux'; import RewardsDashboard from './RewardsDashboard'; import Routes from '../../../../constants/navigation/Routes'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; // Mock dependencies jest.mock('react-redux', () => ({ @@ -91,26 +93,6 @@ const mockAddProperties = jest.fn(() => ({ build: mockBuild })); jest.mock('../../../hooks/useAnalytics/useAnalytics'); -// Mock Toast component -jest.mock('../../../../component-library/components/Toast', () => { - const ReactActual = jest.requireActual('react'); - return { - __esModule: true, - default: ReactActual.forwardRef( - ( - _props: Record, - ref: React.Ref<{ showToast: jest.Mock }>, - ) => { - ReactActual.useImperativeHandle(ref, () => ({ - showToast: jest.fn(), - closeToast: jest.fn(), - })); - return ReactActual.createElement(ReactActual.Fragment, null, 'Toast'); - }, - ), - }; -}); - // Mock i18n jest.mock('../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { @@ -190,6 +172,10 @@ jest.mock('../hooks/useOndoOutcomeToast', () => ({ useOndoOutcomeToast: jest.fn(), })); +jest.mock('../hooks/usePerpsTradingCampaignEndedOutcomeToast', () => ({ + usePerpsTradingCampaignEndedOutcomeToast: jest.fn(), +})); + // Import mocked hooks import { useRewardOptinSummary } from '../hooks/useRewardOptinSummary'; import { useRewardDashboardModals } from '../hooks/useRewardDashboardModals'; @@ -206,6 +192,13 @@ const mockUseRewardDashboardModals = const mockUseBulkLinkState = useBulkLinkState as jest.MockedFunction< typeof useBulkLinkState >; +const mockUseOndoOutcomeToast = useOndoOutcomeToast as jest.MockedFunction< + typeof useOndoOutcomeToast +>; +const mockUsePerpsTradingCampaignEndedOutcomeToast = + usePerpsTradingCampaignEndedOutcomeToast as jest.MockedFunction< + typeof usePerpsTradingCampaignEndedOutcomeToast + >; describe('RewardsDashboard', () => { const mockShowUnlinkedAccountsModal = jest.fn(); @@ -340,6 +333,15 @@ describe('RewardsDashboard', () => { expect(getByText('Rewards')).toBeTruthy(); }); + it('mounts campaign outcome toast hooks on render', () => { + render(); + + expect(mockUseOndoOutcomeToast).toHaveBeenCalledTimes(1); + expect( + mockUsePerpsTradingCampaignEndedOutcomeToast, + ).toHaveBeenCalledTimes(1); + }); + it('renders all child components', () => { // Act const { getByTestId } = render(); diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx index bee49e20a23..3613bd3a6d5 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx @@ -21,9 +21,6 @@ import { RewardsDashboardModalType, } from '../hooks/useRewardDashboardModals'; import { useBulkLinkState } from '../hooks/useBulkLinkState'; -import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; -import Toast from '../../../../component-library/components/Toast'; -import { ToastRef } from '../../../../component-library/components/Toast/Toast.types'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; @@ -32,11 +29,12 @@ import CampaignsPreview from '../components/Campaigns/CampaignsPreview'; import EarnRewardsPreview from '../components/EarnRewards/EarnRewardsPreview'; import BenefitsPreview from '../components/Benefits/BenefitsPreview.tsx'; import { ScrollView } from 'react-native'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; const RewardsDashboard: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); - const toastRef = useRef(null); const subscriptionId = useSelector(selectRewardsSubscriptionId); const activeTab = useSelector(selectActiveTab); const { trackEvent, createEventBuilder } = useAnalytics(); @@ -44,6 +42,8 @@ const RewardsDashboard: React.FC = () => { useTrackRewardsPageView({ page_type: 'home' }); useOndoOutcomeToast(); + usePerpsTradingCampaignEndedOutcomeToast(); + const hideUnlinkedAccountsBanner = useSelector( selectHideUnlinkedAccountsBanner, ); @@ -217,7 +217,6 @@ const RewardsDashboard: React.FC = () => { - ); }; diff --git a/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx b/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx index 6b29ccff6e5..e932cfdbf2a 100644 --- a/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsReferralView.test.tsx @@ -22,11 +22,6 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ useTailwind: () => ({ style: (...args: unknown[]) => args }), })); -jest.mock('@metamask/design-system-react-native', () => { - const actual = jest.requireActual('@metamask/design-system-react-native'); - return { ...actual }; -}); - import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import { createMockUseAnalyticsHook, @@ -51,27 +46,6 @@ jest.mock('../../../../../locales/i18n', () => ({ }, })); -jest.mock( - '../../../../component-library/components-temp/HeaderCompactStandard', - () => { - const ReactActual = jest.requireActual('react'); - const { View, Text, Pressable } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ title, onBack }: { title: string; onBack: () => void }) => - ReactActual.createElement( - View, - { testID: 'header' }, - ReactActual.createElement(Text, null, title), - ReactActual.createElement(Pressable, { - onPress: onBack, - testID: 'header-back-button', - }), - ), - }; - }, -); - jest.mock('../../../Views/ErrorBoundary', () => ({ __esModule: true, default: ({ diff --git a/app/components/UI/Rewards/Views/RewardsReferralView.tsx b/app/components/UI/Rewards/Views/RewardsReferralView.tsx index 0149cdb08e4..705643b21fa 100644 --- a/app/components/UI/Rewards/Views/RewardsReferralView.tsx +++ b/app/components/UI/Rewards/Views/RewardsReferralView.tsx @@ -10,10 +10,10 @@ import { Button, ButtonSize, ButtonVariant, + HeaderStandard, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../locales/i18n'; import ErrorBoundary from '../../../Views/ErrorBoundary'; -import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import ReferralDetails from '../components/ReferralDetails/ReferralDetails'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; @@ -66,7 +66,7 @@ const ReferralRewardsView: React.FC = () => { edges={{ bottom: 'additive' }} style={tw.style('flex-1 bg-default')} > - navigation.goBack()} backButtonProps={{ testID: 'header-back-button' }} diff --git a/app/components/UI/Rewards/Views/RewardsSettingsView.test.tsx b/app/components/UI/Rewards/Views/RewardsSettingsView.test.tsx index 39c084f1089..1e48c2b1a52 100644 --- a/app/components/UI/Rewards/Views/RewardsSettingsView.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsSettingsView.test.tsx @@ -223,7 +223,7 @@ describe('RewardsSettingsView', () => { expect(getByTestId(REWARDS_SETTINGS_SAFE_AREA_TEST_ID)).toBeOnTheScreen(); }); - it('renders HeaderCompactStandard with settings title', () => { + it('renders HeaderStandard with settings title', () => { const { getByText } = renderWithNavigation(); expect(getByText('Settings')).toBeOnTheScreen(); diff --git a/app/components/UI/Rewards/Views/RewardsSettingsView.tsx b/app/components/UI/Rewards/Views/RewardsSettingsView.tsx index 0c7ca9a8ac4..9177947936a 100644 --- a/app/components/UI/Rewards/Views/RewardsSettingsView.tsx +++ b/app/components/UI/Rewards/Views/RewardsSettingsView.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigation } from '@react-navigation/native'; -import { Box } from '@metamask/design-system-react-native'; +import { Box, HeaderStandard } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; import { strings } from '../../../../../locales/i18n'; @@ -8,7 +8,6 @@ import ErrorBoundary from '../../../Views/ErrorBoundary'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; -import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import RewardSettingsAccountGroupList from '../components/Settings/RewardSettingsAccountGroupList'; import RewardsInfoBanner from '../components/RewardsInfoBanner'; import LinkedOffDeviceAccountsSheet from '../components/Settings/LinkedOffDeviceAccountsSheet'; @@ -52,7 +51,7 @@ const RewardsSettingsView: React.FC = () => { style={tw.style('flex-1 bg-default')} testID={REWARDS_SETTINGS_SAFE_AREA_TEST_ID} > - navigation.goBack()} backButtonProps={{ testID: 'header-back-button' }} diff --git a/app/components/UI/Rewards/Views/SeasonOneCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/SeasonOneCampaignDetailsView.test.tsx index b69d665482d..3ad94aa278b 100644 --- a/app/components/UI/Rewards/Views/SeasonOneCampaignDetailsView.test.tsx +++ b/app/components/UI/Rewards/Views/SeasonOneCampaignDetailsView.test.tsx @@ -14,36 +14,10 @@ jest.mock('@react-navigation/native', () => ({ useRoute: () => ({ params: { campaignId: 'campaign-1' } }), })); -jest.mock('@metamask/design-system-react-native', () => { - const actual = jest.requireActual('@metamask/design-system-react-native'); - return { ...actual }; -}); - jest.mock('@metamask/design-system-twrnc-preset', () => ({ useTailwind: () => ({ style: (...args: unknown[]) => args }), })); -jest.mock( - '../../../../component-library/components-temp/HeaderCompactStandard', - () => { - const ReactActual = jest.requireActual('react'); - const { View, Text, Pressable } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ title, onBack }: { title: string; onBack: () => void }) => - ReactActual.createElement( - View, - { testID: 'header' }, - ReactActual.createElement(Text, null, title), - ReactActual.createElement(Pressable, { - onPress: onBack, - testID: 'header-back-button', - }), - ), - }; - }, -); - jest.mock('../../../Views/ErrorBoundary', () => { const ReactActual = jest.requireActual('react'); return { @@ -279,7 +253,7 @@ describe('SeasonOneCampaignDetailsView', () => { campaigns: [createTestCampaign()], }); const { getByTestId } = render(); - fireEvent.press(getByTestId('header-back-button')); + fireEvent.press(getByTestId('season-one-campaign-details-back-button')); expect(mockGoBack).toHaveBeenCalledTimes(1); }); }); diff --git a/app/components/UI/Rewards/Views/SeasonOneCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/SeasonOneCampaignDetailsView.tsx index e771ad0b6d1..f7e71080e66 100644 --- a/app/components/UI/Rewards/Views/SeasonOneCampaignDetailsView.tsx +++ b/app/components/UI/Rewards/Views/SeasonOneCampaignDetailsView.tsx @@ -1,10 +1,13 @@ import React, { useMemo } from 'react'; +import { + HeaderStandard, + Box, + Skeleton, +} from '@metamask/design-system-react-native'; import { ScrollView } from 'react-native'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { Box, Skeleton } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; -import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import PreviousSeasonSummary from '../components/PreviousSeason/PreviousSeasonSummary'; import RewardsErrorBanner from '../components/RewardsErrorBanner'; @@ -52,7 +55,8 @@ const SeasonOneCampaignDetailsView: React.FC = () => { edges={{ bottom: 'additive' }} style={tw.style('flex-1 bg-default')} > - navigation.goBack()} backButtonProps={{ diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignEndedStats.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignEndedStats.tsx index 59b768e2f23..d9cfefa4068 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignEndedStats.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignEndedStats.tsx @@ -8,7 +8,7 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import type { CampaignLeaderboardDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { StatCell } from './CampaignStatsSummary'; +import { StatCell } from './OndoCampaignStatsSummary'; import RewardsErrorBanner from '../RewardsErrorBanner'; import { strings } from '../../../../../../locales/i18n'; import { formatCompactUsd, formatPercentChange } from '../../utils/formatUtils'; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.test.tsx new file mode 100644 index 00000000000..bcf271f5968 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.test.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { Skeleton } from '@metamask/design-system-react-native'; +import { + CampaignLeaderboardEntryRow, + CampaignLeaderboardNeighborSeparator, + CampaignLeaderboardSkeleton, + CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS, +} from './CampaignLeaderboard'; + +jest.mock('@metamask/design-system-react-native', () => { + const ReactActual = jest.requireActual('react'); + const actual = jest.requireActual('@metamask/design-system-react-native'); + const { View } = jest.requireActual('react-native'); + /** Avoid Skeleton Animated/act noise while keeping type identity for row counts. */ + function SkeletonMock(props: object) { + return ReactActual.createElement(View, props); + } + SkeletonMock.displayName = 'Skeleton'; + return { + ...actual, + Skeleton: SkeletonMock, + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../images/rewards/crown.svg', () => 'CrownIcon'); + +jest.mock('./OndoCampaignStatsSummary', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + PendingTag: ({ testID }: { testID?: string }) => + ReactActual.createElement(View, { testID }), + }; +}); + +const IDS = CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS; + +const baseEntry = { + rank: 7, + referralCode: 'USER01', + qualified: true, +}; + +describe('CampaignLeaderboardEntryRow', () => { + it('renders padded rank, referral code, and formatted metric', () => { + const formatPrimaryMetric = jest.fn(() => '+12.5%'); + const isPositivePrimaryMetric = jest.fn(() => true); + + const { getByText } = render( + , + ); + + expect(getByText('07')).toBeDefined(); + expect(getByText('USER01')).toBeDefined(); + expect(getByText('+12.5%')).toBeDefined(); + expect(formatPrimaryMetric).toHaveBeenCalledWith(baseEntry); + expect(isPositivePrimaryMetric).toHaveBeenCalledWith(baseEntry); + }); + + it('sets row testID from shared ENTRY_ROW and rank', () => { + const { getByTestId } = render( + 'm'} + isPositivePrimaryMetric={() => true} + />, + ); + + expect(getByTestId(`${IDS.ENTRY_ROW}-3`)).toBeDefined(); + }); + + it('shows pending tag when current user is unqualified and campaign is active', () => { + const { getByTestId } = render( + '$0.00'} + isPositivePrimaryMetric={() => false} + />, + ); + + expect(getByTestId(IDS.PENDING_TAG)).toBeDefined(); + }); + + it('hides pending tag when campaign is complete', () => { + const { queryByTestId } = render( + '$0.00'} + isPositivePrimaryMetric={() => false} + />, + ); + + expect(queryByTestId(IDS.PENDING_TAG)).toBeNull(); + }); + + it('hides pending tag when row is not the current user', () => { + const { queryByTestId } = render( + '$0.00'} + isPositivePrimaryMetric={() => false} + />, + ); + + expect(queryByTestId(IDS.PENDING_TAG)).toBeNull(); + }); +}); + +describe('CampaignLeaderboardSkeleton', () => { + it('uses shared LOADING testID', () => { + const { getByTestId } = render(); + + expect(getByTestId(IDS.LOADING)).toBeDefined(); + }); + + it('renders default skeleton row count (10 rows × 3 skeletons each)', () => { + const { UNSAFE_getAllByType } = render(); + + expect(UNSAFE_getAllByType(Skeleton)).toHaveLength(30); + }); + + it('respects skeletonRowCount', () => { + const { UNSAFE_getAllByType } = render( + , + ); + + expect(UNSAFE_getAllByType(Skeleton)).toHaveLength(12); + }); +}); + +describe('CampaignLeaderboardNeighborSeparator', () => { + it('uses shared NEIGHBOR_SEPARATOR testID and ellipsis label', () => { + const { getByTestId, getByText } = render( + , + ); + + expect(getByTestId(IDS.NEIGHBOR_SEPARATOR)).toBeDefined(); + expect(getByText('•••')).toBeDefined(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.tsx new file mode 100644 index 00000000000..45b9fa6eb2e --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignLeaderboard.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, + Text, + TextColor, + TextVariant, + FontWeight, + Skeleton, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import CrownIcon from '../../../../../images/rewards/crown.svg'; +import { PendingTag } from './OndoCampaignStatsSummary'; + +/** Shared testIDs for leaderboard rows, pending tag, separator, and skeleton (Ondo + Perps). */ +export const CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS = { + ENTRY_ROW: 'campaign-leaderboard-entry-row', + PENDING_TAG: 'campaign-leaderboard-pending-tag', + NEIGHBOR_SEPARATOR: 'campaign-leaderboard-neighbor-separator', + LOADING: 'campaign-leaderboard-loading', +} as const; + +/** Fields required to render a campaign leaderboard row (Ondo, Perps, etc.). */ +export interface CampaignLeaderboardRowEntry { + rank: number; + referralCode: string; + qualified: boolean; +} + +export interface CampaignLeaderboardEntryRowProps< + T extends CampaignLeaderboardRowEntry, +> { + entry: T; + isCurrentUser?: boolean; + showCrown?: boolean; + /** When true, hides the pending tag for the current user’s row (campaign ended). */ + isCampaignComplete?: boolean; + formatPrimaryMetric: (entry: T) => string; + isPositivePrimaryMetric: (entry: T) => boolean; +} + +export function CampaignLeaderboardEntryRow< + T extends CampaignLeaderboardRowEntry, +>({ + entry, + isCurrentUser = false, + showCrown = false, + isCampaignComplete = false, + formatPrimaryMetric, + isPositivePrimaryMetric, +}: CampaignLeaderboardEntryRowProps) { + const isPositive = isPositivePrimaryMetric(entry); + const textColor = isCurrentUser + ? isPositive + ? TextColor.SuccessDefault + : TextColor.ErrorDefault + : TextColor.TextDefault; + const isPending = !entry.qualified; + const rowBg = isCurrentUser + ? isPending + ? 'bg-muted' + : isPositive + ? 'bg-success-muted' + : 'bg-error-muted' + : ''; + + const showPendingTag = isCurrentUser && isPending && !isCampaignComplete; + + return ( + + + + {String(entry.rank).padStart(2, '0')} + + + + {entry.referralCode} + + {showCrown && entry.rank <= 5 && ( + + )} + + {showPendingTag && ( + + )} + + + {formatPrimaryMetric(entry)} + + + ); +} + +export interface CampaignLeaderboardSkeletonProps { + /** Number of placeholder rows (default 10; Perps uses 5). */ + skeletonRowCount?: number; +} + +const DEFAULT_SKELETON_ROW_COUNT = 10; + +export const CampaignLeaderboardSkeleton: React.FC< + CampaignLeaderboardSkeletonProps +> = ({ skeletonRowCount = DEFAULT_SKELETON_ROW_COUNT }) => { + const tw = useTailwind(); + const rows = Array.from( + { length: skeletonRowCount }, + (_, index) => index + 1, + ); + + return ( + + + {rows.map((i) => ( + + + + + + + + + + ))} + + + ); +}; + +export const CampaignLeaderboardNeighborSeparator: React.FC = () => ( + + + + ••• + + + +); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.test.tsx similarity index 67% rename from app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.test.tsx rename to app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.test.tsx index 0b623fd0e53..56d7c49a5ba 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.test.tsx @@ -5,8 +5,8 @@ import { WinnerFinalizedBanner, ParticipantFinalizedBanner, ParticipantPendingBanner, - OndoGmCampaignOutcomeBanner, -} from './OndoCampaignOutcomeBanners'; + CampaignOutcomeBanner, +} from './CampaignOutcomeBanners'; jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => key, @@ -28,13 +28,13 @@ jest.mock('../RewardsInfoBanner', () => { }); describe('WinnerPendingBanner', () => { - it('renders title and description', () => { + it('renders title and description using consolidated locale keys', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.winner_pending.title'), + getByText('rewards.campaign_outcome_banner.winner_pending.title'), ).toBeDefined(); expect( - getByText('rewards.ondo_outcome_banner.winner_pending.description'), + getByText('rewards.campaign_outcome_banner.winner_pending.description'), ).toBeDefined(); }); @@ -43,7 +43,7 @@ describe('WinnerPendingBanner', () => { , ); expect( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ).toBeDefined(); }); @@ -53,51 +53,53 @@ describe('WinnerPendingBanner', () => { , ); fireEvent.press( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ); expect(onPress).toHaveBeenCalledTimes(1); }); }); describe('WinnerFinalizedBanner', () => { - it('renders with winner_finalized strings', () => { + it('renders with consolidated winner_finalized strings', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.winner_finalized.title'), + getByText('rewards.campaign_outcome_banner.winner_finalized.title'), ).toBeDefined(); expect( - getByText('rewards.ondo_outcome_banner.winner_finalized.description'), + getByText('rewards.campaign_outcome_banner.winner_finalized.description'), ).toBeDefined(); }); }); describe('ParticipantFinalizedBanner', () => { - it('renders with participant_finalized strings', () => { + it('renders with consolidated participant_finalized strings', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.participant_finalized.title'), + getByText('rewards.campaign_outcome_banner.participant_finalized.title'), ).toBeDefined(); expect( getByText( - 'rewards.ondo_outcome_banner.participant_finalized.description', + 'rewards.campaign_outcome_banner.participant_finalized.description', ), ).toBeDefined(); }); }); describe('ParticipantPendingBanner', () => { - it('renders with participant_pending strings', () => { + it('renders with consolidated participant_pending strings', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.title'), + getByText('rewards.campaign_outcome_banner.participant_pending.title'), ).toBeDefined(); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.description'), + getByText( + 'rewards.campaign_outcome_banner.participant_pending.description', + ), ).toBeDefined(); }); }); -describe('OndoGmCampaignOutcomeBanner', () => { +describe('CampaignOutcomeBanner', () => { const onWinnerPress = jest.fn(); beforeEach(() => { @@ -106,83 +108,83 @@ describe('OndoGmCampaignOutcomeBanner', () => { it('renders WinnerPendingBanner when winner code is present and status is pending', () => { const { getByLabelText } = render( - , ); expect( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ).toBeDefined(); }); it('renders WinnerFinalizedBanner when winner code is present and status is finalized', () => { const { getByText, queryByLabelText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.winner_finalized.title'), + getByText('rewards.campaign_outcome_banner.winner_finalized.title'), ).toBeDefined(); expect( - queryByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + queryByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ).toBeNull(); }); it('renders ParticipantFinalizedBanner when no code and status is finalized', () => { const { getByText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.participant_finalized.title'), + getByText('rewards.campaign_outcome_banner.participant_finalized.title'), ).toBeDefined(); }); it('renders ParticipantPendingBanner when no code and status is pending', () => { const { getByText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.title'), + getByText('rewards.campaign_outcome_banner.participant_pending.title'), ).toBeDefined(); }); it('renders ParticipantPendingBanner when winnerVerificationCode is undefined', () => { const { getByText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.title'), + getByText('rewards.campaign_outcome_banner.participant_pending.title'), ).toBeDefined(); }); it('calls onWinnerPress when WinnerPendingBanner is pressed', () => { const mockOnWinnerPress = jest.fn(); const { getByLabelText } = render( - , ); fireEvent.press( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ); expect(mockOnWinnerPress).toHaveBeenCalledTimes(1); }); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.tsx similarity index 53% rename from app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.tsx rename to app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.tsx index 5127253bbb7..3402bca2d41 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.tsx @@ -15,7 +15,7 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import RewardsInfoBanner from '../RewardsInfoBanner'; -import type { OndoGmCampaignParticipantOutcomeStatus } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import type { CampaignParticipantOutcomeStatus } from '../../../../../core/Engine/controllers/rewards-controller/types'; export interface WinnerPendingBannerProps { onPress: () => void; @@ -25,7 +25,7 @@ export const WinnerPendingBanner = React.memo( ({ onPress }) => ( @@ -36,10 +36,12 @@ export const WinnerPendingBanner = React.memo( > - {strings('rewards.ondo_outcome_banner.winner_pending.title')} + {strings('rewards.campaign_outcome_banner.winner_pending.title')} - {strings('rewards.ondo_outcome_banner.winner_pending.description')} + {strings( + 'rewards.campaign_outcome_banner.winner_pending.description', + )} ( export const WinnerFinalizedBanner = React.memo(() => ( )); export const ParticipantFinalizedBanner = React.memo(() => ( )); export const ParticipantPendingBanner = React.memo(() => ( )); -export interface OndoGmCampaignOutcomeBannerProps { - outcomeStatus: OndoGmCampaignParticipantOutcomeStatus; +export interface CampaignOutcomeBannerProps { + outcomeStatus: CampaignParticipantOutcomeStatus; winnerVerificationCode: string | null | undefined; onWinnerPress: () => void; } -export const OndoGmCampaignOutcomeBanner = - React.memo( - ({ outcomeStatus, winnerVerificationCode, onWinnerPress }) => { - const hasCode = Boolean(winnerVerificationCode); - const isFinalized = outcomeStatus === 'finalized'; - if (hasCode && !isFinalized) - return ; - if (hasCode && isFinalized) return ; - if (isFinalized) return ; - return ; - }, - ); +export const CampaignOutcomeBanner = React.memo( + ({ outcomeStatus, winnerVerificationCode, onWinnerPress }) => { + const hasCode = Boolean(winnerVerificationCode); + const isFinalized = outcomeStatus === 'finalized'; + if (hasCode && !isFinalized) + return ; + if (hasCode && isFinalized) return ; + if (isFinalized) return ; + return ; + }, +); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignReminder.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignReminder.test.tsx new file mode 100644 index 00000000000..d9b27232060 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignReminder.test.tsx @@ -0,0 +1,345 @@ +import React from 'react'; +import { render, fireEvent, waitFor, act } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import CampaignReminder from './CampaignReminder'; +import { + buildCampaignReminderCompositeKey, + reminderStorageKeyForComposite, +} from '../../hooks/useCampaignReminderActions'; +import { + type CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { selectRewardsSubscriptionId } from '../../../../../selectors/rewards'; +import { + selectIsMetamaskNotificationsEnabled, + selectIsMetaMaskPushNotificationsEnabled, +} from '../../../../../selectors/notifications'; +import { isNotificationsFeatureEnabled } from '../../../../../util/notifications/constants'; + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); +const mockShowToast = jest.fn(); +const mockEnableNotifications = jest.fn(); +const mockEnableNotificationsNudge = jest.fn( + (linkButtonOptions: { label: string; onPress: () => Promise }) => ({ + variant: 'Plain', + hasNoTimeout: true, + linkButtonOptions, + closeButtonOptions: { + onPress: jest.fn(), + }, + }), +); +let mockEnableNotificationsLoading = false; + +const TEST_REWARDS_SUBSCRIPTION_ID = 'test-rewards-sub-id'; + +const mockGetItemSync = jest.fn((_key: string): string | null => null); +const mockSetItem = jest.fn( + (_key: string, _value: string): Promise => Promise.resolve(), +); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +jest.mock('../../../../../store/storage-wrapper', () => ({ + __esModule: true, + default: { + getItemSync: (key: string) => mockGetItemSync(key), + setItem: (key: string, value: string) => mockSetItem(key, value), + }, +})); + +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: jest.fn(() => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + })), +})); + +jest.mock('../../hooks/useRewardsToast', () => ({ + __esModule: true, + default: jest.fn(() => ({ + showToast: mockShowToast, + RewardsToastOptions: { + success: jest.fn((title: string, subtitle?: string) => ({ + variant: 'success', + title, + subtitle, + })), + error: jest.fn((title: string, subtitle?: string) => ({ + variant: 'error', + title, + subtitle, + })), + enableNotificationsNudge: mockEnableNotificationsNudge, + loading: jest.fn((title: string, subtitle?: string) => ({ + variant: 'loading', + title, + subtitle, + })), + entriesClosed: jest.fn(), + }, + })), +})); + +jest.mock('../../../../../util/notifications/hooks/useNotifications', () => ({ + useEnableNotifications: jest.fn(() => ({ + enableNotifications: mockEnableNotifications, + loading: mockEnableNotificationsLoading, + })), +})); + +jest.mock('../../../../../util/notifications/constants', () => ({ + isNotificationsFeatureEnabled: jest.fn(() => true), +})); + +jest.mock( + '../../../../../util/notifications/services/NotificationService', + () => ({ + __esModule: true, + default: { openSystemSettings: jest.fn() }, + getPushPermission: jest.fn().mockResolvedValue('authorized'), + }), +); + +jest.mock('../../../../../images/rewards/notification.svg', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'campaign-reminder-svg' }), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaign.up_next': 'Up next', + 'rewards.campaign.notify_me': 'Notify me', + 'rewards.campaign.remind_me_success_toast': 'We will notify you.', + 'rewards.campaign.remind_me_save_error': 'Save failed.', + 'rewards.notifications_nudge.turn_on_button': 'Turn on', + 'rewards.notifications_nudge.loading': 'Enabling notifications...', + 'rewards.notifications_nudge.loading_description': + 'This may take a moment.', + 'rewards.notifications_nudge.enable_error': + 'Failed to enable notifications', + }; + return translations[key] || key; + }, +})); + +const createTestCampaign = (overrides = {}): CampaignDto => ({ + id: 'campaign-reminder-1', + type: CampaignType.ONDO_HOLDING, + name: 'Preview Campaign', + startDate: '2028-01-01T00:00:00.000Z', + endDate: '2028-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: true, + showUpcomingDate: false, + ...overrides, +}); + +function mockSelectors({ notificationsEnabled = true } = {}) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) { + return TEST_REWARDS_SUBSCRIPTION_ID; + } + if ( + selector === selectIsMetamaskNotificationsEnabled || + selector === selectIsMetaMaskPushNotificationsEnabled + ) { + return notificationsEnabled; + } + return undefined; + }); +} + +describe('CampaignReminder', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockEnableNotifications.mockResolvedValue(undefined); + mockEnableNotificationsLoading = false; + mockGetItemSync.mockReturnValue(null); + mockSetItem.mockResolvedValue(undefined); + mockSelectors(); + mockCreateEventBuilder.mockImplementation(() => { + const builder = { + addProperties: jest.fn(), + build: jest.fn(() => ({})), + }; + (builder.addProperties as jest.Mock).mockReturnValue(builder); + return builder; + }); + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(true); + }); + + it('renders up next label and campaign name', async () => { + const campaign = createTestCampaign({ name: 'My Upcoming Campaign' }); + const { getByText, getByTestId } = render( + , + ); + + await waitFor(() => { + expect( + getByTestId('campaign-reminder-notify-campaign-reminder-1'), + ).toBeOnTheScreen(); + }); + expect(getByText('Up next')).toBeOnTheScreen(); + expect(getByText('My Upcoming Campaign')).toBeOnTheScreen(); + expect(getByText('Notify me')).toBeOnTheScreen(); + }); + + it('tracks reminder subscribed when Notify me is pressed', async () => { + const campaign = createTestCampaign({ id: 'cr-analytics' }); + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('campaign-reminder-notify-cr-analytics'), + ).toBeOnTheScreen(); + }); + + await act(async () => { + fireEvent.press(getByTestId('campaign-reminder-notify-cr-analytics')); + }); + + const compositeKey = buildCampaignReminderCompositeKey( + TEST_REWARDS_SUBSCRIPTION_ID, + 'cr-analytics', + ); + expect(mockSetItem).toHaveBeenCalledWith( + reminderStorageKeyForComposite(compositeKey), + '1', + ); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.REWARDS_CAMPAIGN_REMINDER_SUBSCRIBED, + ); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenCalledTimes(1); + }); + + it('prompts for notifications and tracks only after push notifications are enabled', async () => { + let notificationsEnabled = false; + mockSelectors({ notificationsEnabled }); + const campaign = createTestCampaign({ id: 'cr-notifications' }); + const { getByTestId, rerender } = render( + , + ); + + await waitFor(() => { + expect( + getByTestId('campaign-reminder-notify-cr-notifications'), + ).toBeOnTheScreen(); + }); + + await act(async () => { + fireEvent.press(getByTestId('campaign-reminder-notify-cr-notifications')); + }); + + expect(mockEnableNotificationsNudge).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'Turn on', + onPress: expect.any(Function), + }), + ); + expect(mockSetItem).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + expect(mockEnableNotifications).toHaveBeenCalledTimes(1); + + notificationsEnabled = true; + mockSelectors({ notificationsEnabled }); + rerender(); + + await waitFor(() => { + const compositeKey = buildCampaignReminderCompositeKey( + TEST_REWARDS_SUBSCRIPTION_ID, + 'cr-notifications', + ); + expect(mockSetItem).toHaveBeenCalledWith( + reminderStorageKeyForComposite(compositeKey), + '1', + ); + }); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.REWARDS_CAMPAIGN_REMINDER_SUBSCRIBED, + ); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + + it('does not show Notify me when the notifications feature flag is off', async () => { + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(false); + const campaign = createTestCampaign({ id: 'cr-feature-off' }); + const { queryByTestId } = render(); + + await waitFor(() => { + expect( + queryByTestId('campaign-reminder-notify-cr-feature-off'), + ).toBeNull(); + }); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('shows Notify me CTA when notifications are disabled even if reminder was already stored', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetItemSync.mockReturnValue('1'); + const campaign = createTestCampaign({ id: 'cr-re-subscribe' }); + const { getByTestId } = render(); + + await waitFor(() => { + expect( + getByTestId('campaign-reminder-notify-cr-re-subscribe'), + ).toBeOnTheScreen(); + }); + }); + + it('does not show Notify me CTA when notifications are enabled and reminder is already stored', async () => { + mockSelectors({ notificationsEnabled: true }); + mockGetItemSync.mockReturnValue('1'); + const campaign = createTestCampaign({ id: 'cr-already-stored' }); + const { queryByTestId } = render(); + + await waitFor(() => { + expect( + queryByTestId('campaign-reminder-notify-cr-already-stored'), + ).toBeNull(); + }); + }); +}); + +describe('campaign reminder storage helpers', () => { + describe('reminderStorageKeyForComposite', () => { + it('prefixes composite key for isolated MMKV rows', () => { + expect(reminderStorageKeyForComposite('sub-1:camp-2')).toBe( + 'rewards_campaign_reminder_subscribed::sub-1:camp-2', + ); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignReminder.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignReminder.tsx new file mode 100644 index 00000000000..6a4e79440cf --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignReminder.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { Pressable } from 'react-native'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Text, + TextColor, + TextVariant, + FontWeight, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useTheme } from '../../../../../util/theme'; +import NotificationIcon from '../../../../../images/rewards/notification.svg'; +import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../../locales/i18n'; +import { isCampaignTypeSupported } from './CampaignTile.utils'; +import { useCampaignReminderActions } from '../../hooks/useCampaignReminderActions'; + +interface CampaignReminderProps { + campaign: CampaignDto; +} + +/** + * Compact preview row for an upcoming featured campaign: label, name, and + * the same reminder flow as {@link CampaignTile}. + */ +const CampaignReminder: React.FC = ({ campaign }) => { + const tw = useTailwind(); + const { colors } = useTheme(); + const reminderEnabled = isCampaignTypeSupported(campaign.type); + const { showRemindMeCta, handleRemindMePress } = useCampaignReminderActions( + campaign, + reminderEnabled, + ); + + return ( + + + + {strings('rewards.campaign.up_next')} + + + {campaign.name} + + + {showRemindMeCta && ( + { + handleRemindMePress().catch(() => undefined); + }} + testID={`campaign-reminder-notify-${campaign.id}`} + accessibilityRole="button" + accessibilityLabel={strings('rewards.campaign.notify_me')} + style={({ pressed }) => + tw.style( + 'flex-row items-center gap-1.5 rounded-lg px-4 py-3 bg-background-muted', + pressed && 'opacity-70', + ) + } + > + + + {strings('rewards.campaign.notify_me')} + + + )} + + ); +}; + +export default CampaignReminder; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx index bf9ecc38339..e99df8af487 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.test.tsx @@ -1,6 +1,8 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { render, fireEvent, waitFor, act } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; import CampaignTile from './CampaignTile'; +import { reminderStorageKeyForComposite } from '../../hooks/useCampaignReminderActions'; import { type CampaignDto, CampaignType, @@ -11,8 +13,97 @@ import { } from './CampaignTile.utils'; import useGetCampaignParticipantStatus from '../../hooks/useGetCampaignParticipantStatus'; import Routes from '../../../../../constants/navigation/Routes'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { selectRewardsSubscriptionId } from '../../../../../selectors/rewards'; +import { + selectIsMetamaskNotificationsEnabled, + selectIsMetaMaskPushNotificationsEnabled, +} from '../../../../../selectors/notifications'; +import { isNotificationsFeatureEnabled } from '../../../../../util/notifications/constants'; const mockNavigate = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); +const mockShowToast = jest.fn(); +const mockEnableNotifications = jest.fn(); +const mockEnableNotificationsNudge = jest.fn( + (linkButtonOptions: { label: string; onPress: () => Promise }) => ({ + variant: 'Plain', + hasNoTimeout: true, + linkButtonOptions, + closeButtonOptions: { + onPress: jest.fn(), + }, + }), +); +let mockEnableNotificationsLoading = false; + +const TEST_REWARDS_SUBSCRIPTION_ID = 'test-rewards-sub-id'; + +const mockGetItemSync = jest.fn((_key: string): string | null => null); +const mockSetItem = jest.fn( + (_key: string, _value: string): Promise => Promise.resolve(), +); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +jest.mock('../../../../../store/storage-wrapper', () => ({ + __esModule: true, + default: { + getItemSync: (key: string) => mockGetItemSync(key), + setItem: (key: string, value: string) => mockSetItem(key, value), + }, +})); + +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: jest.fn(() => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + })), +})); + +jest.mock('../../hooks/useRewardsToast', () => ({ + __esModule: true, + default: jest.fn(() => ({ + showToast: mockShowToast, + RewardsToastOptions: { + success: jest.fn((title: string, subtitle?: string) => ({ + variant: 'success', + title, + subtitle, + })), + error: jest.fn((title: string, subtitle?: string) => ({ + variant: 'error', + title, + subtitle, + })), + enableNotificationsNudge: mockEnableNotificationsNudge, + entriesClosed: jest.fn(), + loading: jest.fn((title: string, subtitle?: string) => ({ + variant: 'loading', + title, + subtitle, + })), + }, + })), +})); + +jest.mock('../../../../../util/notifications/hooks/useNotifications', () => ({ + useEnableNotifications: jest.fn(() => ({ + enableNotifications: mockEnableNotifications, + loading: mockEnableNotificationsLoading, + })), +})); + +jest.mock('../../../../../util/notifications/constants', () => ({ + isNotificationsFeatureEnabled: jest.fn(() => true), +})); + jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn().mockReturnValue({ navigate: (...args: unknown[]) => mockNavigate(...args), @@ -58,6 +149,10 @@ jest.mock('../../../../../../locales/i18n', () => ({ const translations: Record = { 'rewards.campaign.enter': 'Enter', 'rewards.campaign.entered': 'Entered', + 'rewards.campaign.notify_me': 'Notify me', + 'rewards.campaign.remind_me_success_toast': 'We will notify you.', + 'rewards.campaign.remind_me_save_error': 'Save failed.', + 'rewards.notifications_nudge.turn_on_button': 'Turn on', }; return translations[key] || key; }, @@ -89,6 +184,31 @@ function setupParticipantStatus(optedIn: boolean) { describe('CampaignTile', () => { beforeEach(() => { jest.clearAllMocks(); + mockEnableNotifications.mockResolvedValue(undefined); + mockEnableNotificationsLoading = false; + mockGetItemSync.mockReturnValue(null); + mockSetItem.mockResolvedValue(undefined); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) { + return TEST_REWARDS_SUBSCRIPTION_ID; + } + if ( + selector === selectIsMetamaskNotificationsEnabled || + selector === selectIsMetaMaskPushNotificationsEnabled + ) { + return true; + } + return undefined; + }); + mockCreateEventBuilder.mockImplementation(() => { + const builder = { + addProperties: jest.fn(), + build: jest.fn(), + }; + builder.addProperties.mockImplementation(() => builder); + builder.build.mockImplementation(() => ({ category: 'test-event' })); + return builder; + }); (getCampaignStatusInfo as jest.Mock).mockReturnValue({ status: 'active', statusLabel: 'Active', @@ -96,6 +216,7 @@ describe('CampaignTile', () => { dateLabelIcon: 'Clock', }); (isCampaignTypeSupported as jest.Mock).mockReturnValue(true); + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(true); mockUseGetCampaignParticipantStatus.mockReturnValue({ status: null, isLoading: false, @@ -493,4 +614,358 @@ describe('CampaignTile', () => { ); }); }); + + describe('campaign reminder', () => { + it('shows Notify me for upcoming supported campaign', async () => { + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'upcoming', + statusLabel: 'Coming soon', + dateLabel: 'Starts June 1', + dateLabelIcon: 'Speed', + }); + const campaign = createTestCampaign({ + id: 'camp-upcoming-remind', + type: CampaignType.ONDO_HOLDING, + startDate: '2028-06-01T12:00:00.000Z', + }); + + const { getByTestId, getByLabelText } = render( + , + ); + + await waitFor(() => { + expect( + getByTestId('campaign-tile-remind-me-camp-upcoming-remind'), + ).toBeTruthy(); + }); + expect(getByLabelText('Notify me')).toBeTruthy(); + }); + + it('does not show Notify me for upcoming unsupported campaign type', () => { + (isCampaignTypeSupported as jest.Mock).mockReturnValue(false); + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'upcoming', + statusLabel: 'Coming soon', + dateLabel: 'Starts June 1', + dateLabelIcon: 'Speed', + }); + const campaign = createTestCampaign({ + id: 'camp-upcoming-unsupported', + type: 'UNKNOWN_TYPE' as CampaignType, + }); + + const { queryByTestId } = render(); + + expect( + queryByTestId('campaign-tile-remind-me-camp-upcoming-unsupported'), + ).toBeNull(); + }); + + it('tracks reminder subscribed and shows toast when Notify me is pressed', async () => { + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'upcoming', + statusLabel: 'Coming soon', + dateLabel: 'Starts June 1', + dateLabelIcon: 'Speed', + }); + const campaign = createTestCampaign({ + id: 'camp-remind-analytics', + type: CampaignType.PERPS_TRADING, + startDate: '2028-07-15T00:00:00.000Z', + }); + + const { getByTestId } = render(); + await waitFor(() => { + expect( + getByTestId('campaign-tile-remind-me-camp-remind-analytics'), + ).toBeTruthy(); + }); + + await act(async () => { + fireEvent.press( + getByTestId('campaign-tile-remind-me-camp-remind-analytics'), + ); + }); + + expect(mockSetItem).toHaveBeenCalledWith( + reminderStorageKeyForComposite( + `${TEST_REWARDS_SUBSCRIPTION_ID}:camp-remind-analytics`, + ), + '1', + ); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.REWARDS_CAMPAIGN_REMINDER_SUBSCRIBED, + ); + const builder = mockCreateEventBuilder.mock.results[0]?.value as { + addProperties: jest.Mock; + }; + expect(builder.addProperties).toHaveBeenCalledWith({ + campaign_id: 'camp-remind-analytics', + campaign_starts_at: '2028-07-15T00:00:00.000Z', + }); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenCalledTimes(1); + }); + + it('prompts for notifications and tracks only after push notifications are enabled', async () => { + let notificationsEnabled = false; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) { + return TEST_REWARDS_SUBSCRIPTION_ID; + } + if ( + selector === selectIsMetamaskNotificationsEnabled || + selector === selectIsMetaMaskPushNotificationsEnabled + ) { + return notificationsEnabled; + } + return undefined; + }); + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'upcoming', + statusLabel: 'Coming soon', + dateLabel: 'Starts June 1', + dateLabelIcon: 'Speed', + }); + const campaign = createTestCampaign({ + id: 'camp-remind-notifications', + type: CampaignType.PERPS_TRADING, + startDate: '2028-07-15T00:00:00.000Z', + }); + + const { getByTestId, rerender } = render( + , + ); + await waitFor(() => { + expect( + getByTestId('campaign-tile-remind-me-camp-remind-notifications'), + ).toBeTruthy(); + }); + + await act(async () => { + fireEvent.press( + getByTestId('campaign-tile-remind-me-camp-remind-notifications'), + ); + }); + + expect(mockEnableNotificationsNudge).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'Turn on', + onPress: expect.any(Function), + }), + ); + expect(mockSetItem).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + + const linkButtonOptions = mockEnableNotificationsNudge.mock + .calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + expect(mockEnableNotifications).toHaveBeenCalledTimes(1); + + notificationsEnabled = true; + rerender(); + + await waitFor(() => { + expect(mockSetItem).toHaveBeenCalledWith( + reminderStorageKeyForComposite( + `${TEST_REWARDS_SUBSCRIPTION_ID}:camp-remind-notifications`, + ), + '1', + ); + }); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.REWARDS_CAMPAIGN_REMINDER_SUBSCRIBED, + ); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + + it('cancels pending reminder subscription when the notifications nudge is dismissed', async () => { + let notificationsEnabled = false; + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) { + return TEST_REWARDS_SUBSCRIPTION_ID; + } + if ( + selector === selectIsMetamaskNotificationsEnabled || + selector === selectIsMetaMaskPushNotificationsEnabled + ) { + return notificationsEnabled; + } + return undefined; + }); + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'upcoming', + statusLabel: 'Coming soon', + dateLabel: 'Starts June 1', + dateLabelIcon: 'Speed', + }); + const campaign = createTestCampaign({ + id: 'camp-dismiss-nudge', + type: CampaignType.PERPS_TRADING, + startDate: '2028-07-15T00:00:00.000Z', + }); + + const { getByTestId, rerender } = render( + , + ); + await waitFor(() => { + expect( + getByTestId('campaign-tile-remind-me-camp-dismiss-nudge'), + ).toBeTruthy(); + }); + + await act(async () => { + fireEvent.press( + getByTestId('campaign-tile-remind-me-camp-dismiss-nudge'), + ); + }); + + const toastConfig = mockShowToast.mock.calls[0][0] as { + closeButtonOptions: { onPress: () => void }; + }; + act(() => { + toastConfig.closeButtonOptions.onPress(); + }); + + notificationsEnabled = true; + rerender(); + + expect(mockSetItem).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('does not show Notify me when the notifications feature flag is off', async () => { + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(false); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) { + return TEST_REWARDS_SUBSCRIPTION_ID; + } + if ( + selector === selectIsMetamaskNotificationsEnabled || + selector === selectIsMetaMaskPushNotificationsEnabled + ) { + return true; + } + return undefined; + }); + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'upcoming', + statusLabel: 'Coming soon', + dateLabel: 'Starts June 1', + dateLabelIcon: 'Speed', + }); + const campaign = createTestCampaign({ + id: 'camp-cannot-prompt', + type: CampaignType.PERPS_TRADING, + }); + + const { queryByTestId } = render(); + + await waitFor(() => { + expect( + queryByTestId('campaign-tile-remind-me-camp-cannot-prompt'), + ).toBeNull(); + }); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('does not show Notify me when storage already has subscription:campaign composite', async () => { + mockGetItemSync.mockImplementation((key: string) => + key === + reminderStorageKeyForComposite( + `${TEST_REWARDS_SUBSCRIPTION_ID}:camp-already-reminded`, + ) + ? '1' + : null, + ); + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'upcoming', + statusLabel: 'Coming soon', + dateLabel: 'Starts June 1', + dateLabelIcon: 'Speed', + }); + const campaign = createTestCampaign({ + id: 'camp-already-reminded', + type: CampaignType.ONDO_HOLDING, + }); + + const { queryByTestId } = render(); + + await waitFor(() => { + expect( + queryByTestId('campaign-tile-remind-me-camp-already-reminded'), + ).toBeNull(); + }); + }); + + it('hides Notify me CTA after a successful subscribe', async () => { + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'upcoming', + statusLabel: 'Coming soon', + dateLabel: 'Starts June 1', + dateLabelIcon: 'Speed', + }); + const campaign = createTestCampaign({ + id: 'camp-hide-after', + type: CampaignType.ONDO_HOLDING, + startDate: '2028-08-01T00:00:00.000Z', + }); + + const { getByTestId, queryByTestId } = render( + , + ); + await waitFor(() => { + expect( + getByTestId('campaign-tile-remind-me-camp-hide-after'), + ).toBeTruthy(); + }); + + await act(async () => { + fireEvent.press(getByTestId('campaign-tile-remind-me-camp-hide-after')); + }); + + await waitFor(() => { + expect( + queryByTestId('campaign-tile-remind-me-camp-hide-after'), + ).toBeNull(); + }); + }); + + it('shows error toast when storage setItem fails', async () => { + mockSetItem.mockRejectedValueOnce(new Error('disk full')); + (getCampaignStatusInfo as jest.Mock).mockReturnValue({ + status: 'upcoming', + statusLabel: 'Coming soon', + dateLabel: 'Starts June 1', + dateLabelIcon: 'Speed', + }); + const campaign = createTestCampaign({ + id: 'camp-save-fail', + type: CampaignType.ONDO_HOLDING, + startDate: '2028-09-01T00:00:00.000Z', + }); + + const { getByTestId } = render(); + await waitFor(() => { + expect( + getByTestId('campaign-tile-remind-me-camp-save-fail'), + ).toBeTruthy(); + }); + + await act(async () => { + fireEvent.press(getByTestId('campaign-tile-remind-me-camp-save-fail')); + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Save failed.', + }), + ); + }); + }); }); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx index 7ab6130bad2..9d717e57db3 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.tsx @@ -13,6 +13,8 @@ import { FontWeight, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useTheme } from '../../../../../util/theme'; +import NotificationIcon from '../../../../../images/rewards/notification.svg'; import { CampaignType, type CampaignDto, @@ -23,6 +25,7 @@ import { } from './CampaignTile.utils'; import { strings } from '../../../../../../locales/i18n'; import useGetCampaignParticipantStatus from '../../hooks/useGetCampaignParticipantStatus'; +import { useCampaignReminderActions } from '../../hooks/useCampaignReminderActions'; interface CampaignTileProps { campaign: CampaignDto; @@ -39,12 +42,14 @@ interface CampaignTileProps { * Tapping behavior is determined by campaign type: * - ONDO_HOLDING: navigates to Ondo campaign details * - SEASON_1: navigates to season one campaign details + * - PERPS_TRADING: navigates to Perps Trading campaign details * - Unsupported types: non-interactive unless onPress is provided * - With onPress: executes custom handler regardless of type */ const CampaignTile: React.FC = ({ campaign, onPress }) => { const tw = useTailwind(); const colorScheme = useColorScheme(); + const { colors } = useTheme(); const navigation = useNavigation(); const { @@ -55,7 +60,9 @@ const CampaignTile: React.FC = ({ campaign, onPress }) => { const { status: participantStatus, isLoading: isParticipantStatusLoading } = useGetCampaignParticipantStatus( - campaignStatus === 'active' && campaign.type === CampaignType.ONDO_HOLDING + campaignStatus === 'active' && + (campaign.type === CampaignType.ONDO_HOLDING || + campaign.type === CampaignType.PERPS_TRADING) ? campaign.id : undefined, ); @@ -64,6 +71,14 @@ const CampaignTile: React.FC = ({ campaign, onPress }) => { campaignStatus !== 'upcoming' && (onPress != null || isCampaignTypeSupported(campaign.type)); + const reminderFeatureEnabled = + campaignStatus === 'upcoming' && isCampaignTypeSupported(campaign.type); + + const { showRemindMeCta, handleRemindMePress } = useCampaignReminderActions( + campaign, + reminderFeatureEnabled, + ); + const shouldShowDateLabel = campaignStatus !== 'upcoming' || campaign.showUpcomingDate; @@ -98,92 +113,131 @@ const CampaignTile: React.FC = ({ campaign, onPress }) => { navigation.navigate(Routes.REWARDS_SEASON_ONE_CAMPAIGN_DETAILS_VIEW, { campaignId: campaign.id, }); + } else if (campaign.type === CampaignType.PERPS_TRADING) { + if (shouldShowTour) { + navigation.navigate(Routes.REWARDS_CAMPAIGN_TOUR_STEP, { + campaignId: campaign.id, + }); + } else { + navigation.navigate( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + { + campaignId: campaign.id, + }, + ); + } } }; return ( - - tw.style( - 'rounded-xl overflow-hidden h-50 bg-muted', - pressed && isInteractive && 'opacity-70', - ) - } - testID={`campaign-tile-${campaign.id}`} - > - + + tw.style('absolute inset-0', pressed && isInteractive && 'opacity-70') + } + testID={`campaign-tile-${campaign.id}`} > - - {participantStatus?.optedIn === true ? ( - - {strings('rewards.campaign.entered')} - - ) : ( - - {statusLabel} - - )} - {shouldShowDateLabel && ( - <> + + {participantStatus?.optedIn === true ? ( - • + {strings('rewards.campaign.entered')} + ) : ( - {dateLabel} + {statusLabel} - - )} - + )} + {shouldShowDateLabel && ( + <> + + • + + + {dateLabel} + + + )} + - - {campaign.name} - - - - + + + + {campaign.name} + + + {showRemindMeCta && ( + { + handleRemindMePress().catch(() => undefined); + }} + testID={`campaign-tile-remind-me-${campaign.id}`} + accessibilityRole="button" + accessibilityLabel={strings('rewards.campaign.notify_me')} + hitSlop={12} + style={({ pressed }) => tw.style(pressed && 'opacity-70')} + > + + + )} + + + + + ); }; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts index c218339c001..ce6d77de59c 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.test.ts @@ -23,6 +23,8 @@ jest.mock('@metamask/design-system-react-native', () => ({ })); jest.mock('../../../../../../locales/i18n', () => ({ + __esModule: true, + default: { locale: 'en-US' }, strings: jest.fn((key: string, params?: Record) => params ? `${key}:${JSON.stringify(params)}` : key, ), diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts index 24ba71b85fa..9c665255eb3 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts +++ b/app/components/UI/Rewards/components/Campaigns/CampaignTile.utils.ts @@ -4,7 +4,8 @@ import { type CampaignDto, type CampaignStatus, } from '../../../../../core/Engine/controllers/rewards-controller/types'; -import { strings } from '../../../../../../locales/i18n'; +import I18n, { strings } from '../../../../../../locales/i18n'; +import { getIntlDateTimeFormatter } from '../../../../../util/intl'; /** * Set of campaign types that have full UI support (details view, opt-in, etc.) @@ -12,6 +13,7 @@ import { strings } from '../../../../../../locales/i18n'; const SUPPORTED_CAMPAIGN_TYPES = new Set([ CampaignType.ONDO_HOLDING, CampaignType.SEASON_1, + CampaignType.PERPS_TRADING, ]); /** @@ -52,32 +54,18 @@ export function getCampaignStatus(campaign: CampaignDto): CampaignStatus { return 'complete'; } -const MONTHS = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', -]; - /** - * Formats a date for display in campaign tiles. + * Formats a date for display in campaign tiles (localized month and day). * * @param date - The date to format - * @returns Formatted date string (e.g., "March 15") + * @param locale - BCP 47 locale; defaults to the app locale + * @returns Formatted date string (e.g., "March 15" in en-US) */ -function formatCampaignDate(date: Date): string { - const month = MONTHS[date.getMonth()]; - const day = date.getDate(); - - return `${month} ${day}`; +function formatCampaignDate(date: Date, locale: string = I18n.locale): string { + return getIntlDateTimeFormatter(locale, { + month: 'long', + day: 'numeric', + }).format(date); } /** diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignViewHeader.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignViewHeader.test.tsx new file mode 100644 index 00000000000..1d189553915 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignViewHeader.test.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import CampaignViewHeader from './CampaignViewHeader'; + +const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + goBack: mockGoBack, + navigate: mockNavigate, + }), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../constants/navigation/Routes', () => ({ + REWARDS_CAMPAIGN_MECHANICS: 'REWARDS_CAMPAIGN_MECHANICS', +})); + +const defaultProps = { + title: 'Test Title', + backButtonTestID: 'test-back-button', + mechanicsButtonTestID: 'test-mechanics-button', + hasCampaign: true, + campaignId: 'campaign-123', +}; + +describe('CampaignViewHeader', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the title', () => { + const { getByText } = render(); + expect(getByText('Test Title')).toBeDefined(); + }); + + it('calls goBack when back button is pressed', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('test-back-button')); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('shows mechanics button when hasCampaign is true', () => { + const { getByTestId } = render(); + expect(getByTestId('test-mechanics-button')).toBeDefined(); + }); + + it('hides mechanics button when hasCampaign is false', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId('test-mechanics-button')).toBeNull(); + }); + + it('navigates to campaign mechanics when mechanics button is pressed', () => { + const { getByTestId } = render(); + fireEvent.press(getByTestId('test-mechanics-button')); + expect(mockNavigate).toHaveBeenCalledWith('REWARDS_CAMPAIGN_MECHANICS', { + campaignId: 'campaign-123', + }); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignViewHeader.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignViewHeader.tsx new file mode 100644 index 00000000000..0886cd71a08 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/CampaignViewHeader.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { + HeaderStandard, + TextVariant, +} from '@metamask/design-system-react-native'; +import Routes from '../../../../../constants/navigation/Routes'; +import { getCampaignMechanicsButtonProps } from '../../utils/campaignHeaderUtils'; + +interface CampaignViewHeaderProps { + title: string; + backButtonTestID: string; + mechanicsButtonTestID: string; + hasCampaign: boolean; + campaignId: string; +} + +const CampaignViewHeader: React.FC = ({ + title, + backButtonTestID, + mechanicsButtonTestID, + hasCampaign, + campaignId, +}) => { + const navigation = useNavigation(); + return ( + navigation.goBack()} + backButtonProps={{ testID: backButtonTestID }} + endButtonIconProps={getCampaignMechanicsButtonProps( + hasCampaign, + () => + navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, { + campaignId, + }), + mechanicsButtonTestID, + )} + includesTopInset + /> + ); +}; + +export default CampaignViewHeader; diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx index c1e99e97edc..46ee81c88df 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.test.tsx @@ -57,6 +57,20 @@ jest.mock('./CampaignTile', () => { }; }); +jest.mock('./CampaignReminder', () => { + const ReactActual = jest.requireActual('react'); + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ campaign }: { campaign: CampaignDto }) => + ReactActual.createElement( + Text, + { testID: `campaign-reminder-${campaign.id}` }, + `Reminder:${campaign.name}`, + ), + }; +}); + jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => { const translations: Record = { @@ -68,6 +82,7 @@ jest.mock('../../../../../../locales/i18n', () => ({ const now = new Date(); const futureDate = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); +const furtherFutureDate = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000); const pastDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const createTestCampaign = ( @@ -205,7 +220,7 @@ describe('CampaignsPreview', () => { expect(queryByTestId('campaign-tile-non-featured')).toBeNull(); }); - it('renders only the first featured campaign when multiple exist', () => { + it('renders all featured active campaigns when multiple exist', () => { const firstCampaign = createTestCampaign({ id: 'first-1', name: 'First Campaign', @@ -225,15 +240,34 @@ describe('CampaignsPreview', () => { campaigns: [firstCampaign, secondCampaign], }); - const { getByTestId, queryByTestId, getAllByTestId } = render( - , - ); + const { getByTestId, getAllByTestId } = render(); expect(getByTestId('campaign-tile-first-1')).toBeOnTheScreen(); - expect(queryByTestId('campaign-tile-second-1')).toBeNull(); + expect(getByTestId('campaign-tile-second-1')).toBeOnTheScreen(); const tiles = getAllByTestId(/^campaign-tile-/); - expect(tiles).toHaveLength(1); + expect(tiles).toHaveLength(2); + }); + + it('renders CampaignReminder for a featured upcoming campaign', () => { + const upcomingCampaign = createTestCampaign({ + id: 'upcoming-featured', + name: 'Soon Campaign', + startDate: futureDate.toISOString(), + endDate: furtherFutureDate.toISOString(), + featured: true, + }); + mockUseRewardCampaigns.mockReturnValue({ + ...mockHookDefaults, + campaigns: [upcomingCampaign], + }); + + const { getByTestId, queryByTestId } = render(); + + expect( + getByTestId('campaign-reminder-upcoming-featured'), + ).toBeOnTheScreen(); + expect(queryByTestId('campaign-tile-upcoming-featured')).toBeNull(); }); it('renders SEASON_1 campaign type as interactive', () => { diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.tsx index bd498349bb1..d553229cb57 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignsPreview.tsx @@ -20,12 +20,15 @@ import { REWARDS_VIEW_SELECTORS } from '../../Views/RewardsView.constants'; import { strings } from '../../../../../../locales/i18n'; import { useRewardCampaigns } from '../../hooks/useRewardCampaigns'; import CampaignTile from './CampaignTile'; +import CampaignReminder from './CampaignReminder'; import RewardsErrorBanner from '../RewardsErrorBanner'; import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { getCampaignStatus } from './CampaignTile.utils'; /** * CampaignsPreview shows featured campaigns on the dashboard. - * Only campaigns marked as featured are displayed, in the order returned by the API. + * All campaigns marked `featured` are displayed, in API order. Upcoming campaigns + * use {@link CampaignReminder}; active or complete campaigns use {@link CampaignTile}. */ const CampaignsPreview: React.FC = () => { const tw = useTailwind(); @@ -34,12 +37,12 @@ const CampaignsPreview: React.FC = () => { const { campaigns, isLoading, hasError, hasLoaded, fetchCampaigns } = useRewardCampaigns(); - const featuredCampaign = useMemo( - (): CampaignDto | undefined => (campaigns ?? []).find((c) => c.featured), + const featuredCampaigns = useMemo( + (): CampaignDto[] => (campaigns ?? []).filter((c) => c.featured), [campaigns], ); - const hasFeaturedCampaigns = Boolean(featuredCampaign); + const hasFeaturedCampaigns = featuredCampaigns.length > 0; const handleNavigateToCampaigns = useCallback(() => { navigation.navigate(Routes.REWARDS_CAMPAIGNS_VIEW); @@ -83,7 +86,13 @@ const CampaignsPreview: React.FC = () => { /> )} - {featuredCampaign && } + {featuredCampaigns.map((campaign) => + getCampaignStatus(campaign) === 'upcoming' ? ( + + ) : ( + + ), + )} ); }; diff --git a/app/components/UI/Rewards/components/Campaigns/LeaderboardPositionHeader.tsx b/app/components/UI/Rewards/components/Campaigns/LeaderboardPositionHeader.tsx index 66db7e8a976..7ebb591e7c3 100644 --- a/app/components/UI/Rewards/components/Campaigns/LeaderboardPositionHeader.tsx +++ b/app/components/UI/Rewards/components/Campaigns/LeaderboardPositionHeader.tsx @@ -14,7 +14,11 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { StatCell, PendingTag, IneligibleTag } from './CampaignStatsSummary'; +import { + StatCell, + PendingTag, + IneligibleTag, +} from './OndoCampaignStatsSummary'; import { strings } from '../../../../../../locales/i18n'; export const LEADERBOARD_POSITION_HEADER_TEST_IDS = { @@ -23,6 +27,7 @@ export const LEADERBOARD_POSITION_HEADER_TEST_IDS = { RETURN_VALUE: 'leaderboard-position-header-return', TIER_VALUE: 'leaderboard-position-header-tier', PRIZE_POOL_VALUE: 'leaderboard-position-header-prize-pool', + COMPUTED_AT: 'leaderboard-position-header-computed-at', PENDING_TAG: 'leaderboard-position-header-pending-tag', INELIGIBLE_TAG: 'leaderboard-position-header-ineligible-tag', QUALIFIED_ICON: 'leaderboard-position-header-qualified-icon', @@ -41,6 +46,8 @@ interface LeaderboardPositionHeaderProps { showPrizePool?: boolean; prizePoolValue?: string; prizePoolLoading?: boolean; + showComputedAt?: boolean; + computedAt?: string | null; } const LeaderboardPositionHeader: React.FC = ({ @@ -58,6 +65,7 @@ const LeaderboardPositionHeader: React.FC = ({ prizePoolLoading = false, }) => { const tw = useTailwind(); + const showSubtextRow = showReturn && Boolean(returnValue); return ( = ({ {isLoading ? ( - + <> + + {showSubtextRow && ( + + )} + ) : ( <> = ({ > {rank} - {showReturn && returnValue && ( - - {returnValue} - + + {showReturn && returnValue && ( + + {returnValue} + + )} + + )} )} diff --git a/app/components/UI/Rewards/components/Campaigns/OndoActivityRow.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoActivityRow.test.tsx index 18be482b848..ce0013ce0bb 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoActivityRow.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoActivityRow.test.tsx @@ -104,6 +104,18 @@ describe('OndoActivityRow', () => { expect(getByText('—')).toBeDefined(); }); + it('renders rebalance entry USD without a plus sign for positive amounts', () => { + const { getByText } = render( + , + ); + + expect(getByText('Rebalance')).toBeDefined(); + // formatUsd (rebalance) has no '+'; deposit/withdraw still use mocked formatSignedUsd + expect(getByText(/^\$5,000/)).toBeDefined(); + }); + it('renders external outflow entry with shortened destAddress', () => { const { getByText } = render( { it('renders token symbols in detail line', () => { const { getByText } = render(); - expect(getByText('USDC → AAPLon')).toBeDefined(); + expect(getByText('USDC → AAPLON')).toBeDefined(); }); it('renders only source token when destToken is null', () => { diff --git a/app/components/UI/Rewards/components/Campaigns/OndoActivityRow.tsx b/app/components/UI/Rewards/components/Campaigns/OndoActivityRow.tsx index 9f95a2836ec..3c2a556f620 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoActivityRow.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoActivityRow.tsx @@ -29,6 +29,7 @@ import { formatRewardsDate, formatRewardsTimeOnly, formatSignedUsd, + formatUsd, getChainHex, shortenAddress, } from '../../utils/formatUtils'; @@ -49,7 +50,25 @@ const LABEL_KEY_MAP: Record = { }; const tokenLabel = (token: ActivityTokenDto): string => - token.tokenSymbol || token.tokenName; + token.tokenSymbol.toUpperCase() || token.tokenName; + +/** Rebalance USD is not portfolio P&L; omit the '+' used for signed inflows/outflows. */ +const formatActivityUsd = ( + usdAmount: OndoGmActivityEntryDto['usdAmount'], + entryType: ActivityEntryType, +): string => { + if (entryType !== 'REBALANCE') { + return formatSignedUsd(usdAmount); + } + if (usdAmount === null) { + return '—'; + } + const num = typeof usdAmount === 'number' ? usdAmount : parseFloat(usdAmount); + if (Number.isNaN(num)) { + return '—'; + } + return formatUsd(usdAmount); +}; interface OndoActivityRowProps { entry: OndoGmActivityEntryDto; @@ -123,7 +142,7 @@ const OndoActivityRow: React.FC = ({ {label} - {formatSignedUsd(entry.usdAmount)} + {formatActivityUsd(entry.usdAmount, entryType)} diff --git a/app/components/UI/Rewards/components/Campaigns/CampaignStatsSummary.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx similarity index 77% rename from app/components/UI/Rewards/components/Campaigns/CampaignStatsSummary.test.tsx rename to app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx index 41ee6d2e016..9926b195451 100644 --- a/app/components/UI/Rewards/components/Campaigns/CampaignStatsSummary.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx @@ -2,12 +2,12 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import { Text as RNText } from 'react-native'; import { TextColor } from '@metamask/design-system-react-native'; -import CampaignStatsSummary, { +import OndoCampaignStatsSummary, { IneligibleTag, PendingTag, StatCell, - CAMPAIGN_STATS_SUMMARY_TEST_IDS, -} from './CampaignStatsSummary'; + ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS, +} from './OndoCampaignStatsSummary'; import type { CampaignLeaderboardPositionDto, OndoGmPortfolioSummaryDto, @@ -28,12 +28,12 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ useTailwind: () => ({ style: (...args: unknown[]) => args }), })); -jest.mock('./OndoCampaignOutcomeBanners', () => { +jest.mock('./CampaignOutcomeBanners', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return { __esModule: true, - OndoGmCampaignOutcomeBanner: ({ + CampaignOutcomeBanner: ({ outcomeStatus, winnerVerificationCode, }: { @@ -168,67 +168,69 @@ const baseProps = { }, }; -describe('CampaignStatsSummary', () => { +describe('OndoCampaignStatsSummary', () => { beforeEach(() => { jest.clearAllMocks(); }); it('renders all stats when both position and summary are provided', () => { - const { getByTestId } = render(); + const { getByTestId } = render(); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.CONTAINER), ).toBeDefined(); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, ).toBe('+7.01%'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .children, ).toBe('$13,057.58'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, ).toBe('05'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, ).toBe('Silver'); }); it('displays dash for rank and tier when leaderboard position is null but return from portfolio', () => { const { getByTestId } = render( - , + , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, ).toBe('+7.01%'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, ).toBe('-'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, ).toBe('-'); }); it('displays dash for return when portfolio summary is null', () => { const { getByTestId } = render( - , + , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, ).toBe('-'); }); it('displays dash for market value when portfolio summary is null', () => { const { getByTestId } = render( - , + , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .children, ).toBe('-'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, ).toBe('05'); }); @@ -240,7 +242,7 @@ describe('CampaignStatsSummary', () => { }; const { getByTestId } = render( - { ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.color, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .color, ).toBe(TextColor.ErrorDefault); }); it('uses success color for market value when portfolioPnl is positive', () => { - const { getByTestId } = render(); + const { getByTestId } = render(); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.color, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .color, ).toBe(TextColor.SuccessDefault); }); it('omits valueColor for market value when portfolioSummary is null', () => { const { getByTestId } = render( - , + , ); // Returns '-' and uses the StatCell default color (TextDefault) expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .children, ).toBe('-'); }); @@ -278,14 +283,14 @@ describe('CampaignStatsSummary', () => { }; const { getByTestId } = render( - , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, ).toBe('-5.00%'); }); @@ -299,39 +304,39 @@ describe('CampaignStatsSummary', () => { }; const { getByTestId, getAllByText } = render( - , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), ).toBeOnTheScreen(); expect(getAllByText('Pending')).toHaveLength(1); }); it('renders check icon on rank cell and no Pending tags when qualified is true', () => { const { getByTestId, queryAllByText, queryByTestId } = render( - , + , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.QUALIFIED_TAG), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.QUALIFIED_TAG), ).toBeOnTheScreen(); expect(queryAllByText('Pending')).toHaveLength(0); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), ).toBeNull(); }); it('does not render tags when leaderboardPosition is null', () => { const { queryByTestId } = render( - , + , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), ).toBeNull(); }); @@ -339,7 +344,7 @@ describe('CampaignStatsSummary', () => { it('shows skeletons for leaderboard cells when leaderboard is loading with no data', () => { const { queryByTestId } = render( - { ); // Return and market value still render since portfolio is fine - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN)).toBeDefined(); - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK)).toBeNull(); - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER)).toBeNull(); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN), + ).toBeDefined(); + expect(queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK)).toBeNull(); + expect(queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER)).toBeNull(); + expect( + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), ).toBeDefined(); }); it('shows stale leaderboard data instead of skeletons when loading with existing data', () => { const { getByTestId } = render( - , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN).props.children, ).toBe('+7.01%'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, ).toBe('05'); }); @@ -375,7 +382,7 @@ describe('CampaignStatsSummary', () => { it('shows skeleton for market value cell when portfolio is loading with no data', () => { const { queryByTestId } = render( - { ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), ).toBeNull(); // Return also shows skeleton since it now comes from portfolio - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN)).toBeNull(); + expect( + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN), + ).toBeNull(); // Leaderboard cells still render - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK)).toBeDefined(); + expect( + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK), + ).toBeDefined(); }); it('shows stale market value data instead of skeleton when loading with existing data', () => { const { getByTestId } = render( - , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE).props + .children, ).toBe('$13,057.58'); }); @@ -408,7 +420,7 @@ describe('CampaignStatsSummary', () => { it('shows all skeletons when both sources are loading with no data', () => { const { queryByTestId } = render( - { />, ); - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN)).toBeNull(); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN), + ).toBeNull(); + expect( + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), ).toBeNull(); - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK)).toBeNull(); - expect(queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER)).toBeNull(); + expect(queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK)).toBeNull(); + expect(queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER)).toBeNull(); }); // ── Leaderboard error ───────────────────────────────────────────── it('shows stats error banner when leaderboard fails with no data', () => { const { getByTestId } = render( - { ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), ).toBeDefined(); }); it('calls both refetches on stats error retry when leaderboard fails', () => { const { getByTestId } = render( - { ); fireEvent.press( - getByTestId(`${CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR}-retry`), + getByTestId(`${ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR}-retry`), ); expect(mockLeaderboardRefetch).toHaveBeenCalledTimes(1); expect(mockPortfolioRefetch).toHaveBeenCalledTimes(1); @@ -459,14 +473,14 @@ describe('CampaignStatsSummary', () => { it('hides stats error when stale leaderboard data exists', () => { const { queryByTestId } = render( - , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), ).toBeNull(); }); @@ -474,7 +488,7 @@ describe('CampaignStatsSummary', () => { it('shows stats error banner when portfolio fails with no data', () => { const { getByTestId } = render( - { ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), ).toBeDefined(); }); it('calls both refetches on stats error retry when portfolio fails', () => { const { getByTestId } = render( - { ); fireEvent.press( - getByTestId(`${CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR}-retry`), + getByTestId(`${ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR}-retry`), ); expect(mockPortfolioRefetch).toHaveBeenCalledTimes(1); expect(mockLeaderboardRefetch).toHaveBeenCalledTimes(1); @@ -506,7 +520,7 @@ describe('CampaignStatsSummary', () => { it('shows a single stats error banner when both sources fail with no data', () => { const { getAllByTestId } = render( - { ); expect( - getAllByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), + getAllByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR), ).toHaveLength(1); }); @@ -529,21 +543,21 @@ describe('CampaignStatsSummary', () => { qualifiedDays: 0, }; const { getByTestId, getAllByText } = render( - , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.INELIGIBLE_TAG), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.INELIGIBLE_TAG), ).toBeOnTheScreen(); expect(getAllByText('Ineligible')).toHaveLength(1); }); it('shows dash for rank and tier when isIneligible=true even with leaderboard data', () => { const { getByTestId } = render( - { />, ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RANK).props.children, ).toBe('-'); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER).props.children, ).toBe('-'); }); it('shows not-eligible banner when isIneligible=true', () => { const { getByTestId, getByText } = render( - { />, ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), ).toBeOnTheScreen(); expect(getByText('Not eligible')).toBeOnTheScreen(); }); it('hides pending tags when isIneligible=true', () => { const { queryAllByText } = render( - { it('does not show ineligible tags when isIneligible=false', () => { const { queryAllByText, queryByTestId } = render( - { ); expect(queryAllByText('Ineligible')).toHaveLength(0); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), ).toBeNull(); }); it('does not show not-eligible banner when isIneligible defaults to false', () => { - const { queryByTestId } = render(); + const { queryByTestId } = render( + , + ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), ).toBeNull(); }); @@ -623,10 +639,14 @@ describe('CampaignStatsSummary', () => { it('hides IneligibleTag from rank cell suffix when isCampaignComplete=true', () => { const { queryByTestId } = render( - , + , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.INELIGIBLE_TAG), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.INELIGIBLE_TAG), ).toBeNull(); }); @@ -637,20 +657,20 @@ describe('CampaignStatsSummary', () => { qualifiedDays: 3, }; const { queryByTestId } = render( - , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PENDING_TAG), ).toBeNull(); }); it('hides qualified card when isCampaignComplete=true', () => { const { queryByText } = render( - { it('hides not-eligible banner when isCampaignComplete=true', () => { const { queryByTestId } = render( - , + , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.NOT_ELIGIBLE_BANNER), ).toBeNull(); }); @@ -675,7 +699,7 @@ describe('CampaignStatsSummary', () => { qualifiedDays: 4, }; const { queryByText } = render( - { it('hides market value cell when isCampaignComplete=true', () => { const { queryByTestId } = render( - , + , ); expect( - queryByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), + queryByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), ).toBeNull(); }); it('shows market value cell when isCampaignComplete=false', () => { const { getByTestId } = render( - , + , ); expect( - getByTestId(CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), + getByTestId(ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE), ).toBeDefined(); }); it('shows outcome banner when isCampaignComplete=true and outcome is provided', () => { const { getByTestId } = render( - { it('does not show outcome banner when isCampaignComplete=false', () => { const { queryByTestId } = render( - { it('shows the qualified explainer card when qualified and tierMinDeposit is set', () => { const { getByText } = render( - , + , ); expect(getByText('You are qualified')).toBeOnTheScreen(); expect(getByText(/Qualified copy/)).toBeOnTheScreen(); @@ -744,7 +768,7 @@ describe('CampaignStatsSummary', () => { qualifiedDays: 4, }; const { getByText } = render( - { qualifiedDays: 10, }; const { queryByText } = render( - = ({ ); }; -export const CAMPAIGN_STATS_SUMMARY_TEST_IDS = { +export const ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS = { CONTAINER: 'campaign-stats-summary-container', RETURN: 'campaign-stats-summary-return', MARKET_VALUE: 'campaign-stats-summary-market-value', @@ -121,7 +121,7 @@ interface DataSourceState { refetch: () => void; } -interface CampaignStatsSummaryProps { +interface OndoCampaignStatsSummaryProps { leaderboardPosition: CampaignLeaderboardPositionDto | null; portfolioSummary: OndoGmPortfolioSummaryDto | null; leaderboard: DataSourceState; @@ -136,7 +136,7 @@ interface CampaignStatsSummaryProps { onWinnerPress?: () => void; } -const CampaignStatsSummary: React.FC = ({ +const OndoCampaignStatsSummary: React.FC = ({ leaderboardPosition, portfolioSummary, leaderboard, @@ -184,29 +184,32 @@ const CampaignStatsSummary: React.FC = ({ : formatTierDisplayName(leaderboardPosition.projectedTier); return ( - + {/* Rank | Tier */} ) : !isCampaignComplete && isPending ? ( ) : isQualified ? ( ) : undefined } @@ -215,7 +218,7 @@ const CampaignStatsSummary: React.FC = ({ label={strings('rewards.ondo_campaign_stats.label_tier')} value={tierValue} isLoading={leaderboardLoading} - testID={CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER} + testID={ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.TIER} /> @@ -226,7 +229,7 @@ const CampaignStatsSummary: React.FC = ({ value={returnValue} isLoading={portfolioLoading} valueColor={returnColor} - testID={CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN} + testID={ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.RETURN} /> {!isCampaignComplete && ( = ({ value={marketValue} isLoading={portfolioLoading} valueColor={returnColor} - testID={CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE} + testID={ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.MARKET_VALUE} /> )} {/* Outcome banner (campaign ended) */} {isCampaignComplete && outcomeStatus != null && onWinnerPress != null && ( - = ({ {!isCampaignComplete && isIneligible && ( {strings('rewards.ondo_campaign_stats.not_eligible_title')} @@ -337,11 +340,11 @@ const CampaignStatsSummary: React.FC = ({ portfolio.refetch(); }} confirmButtonLabel={strings('rewards.ondo_campaign_stats.retry')} - testID={CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR} + testID={ONDO_CAMPAIGN_STATS_SUMMARY_TEST_IDS.STATS_ERROR} /> )} ); }; -export default CampaignStatsSummary; +export default OndoCampaignStatsSummary; diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx index 5990d411c7f..43c1aa1b231 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.tsx @@ -8,23 +8,21 @@ import { BoxFlexDirection, BoxAlignItems, BoxJustifyContent, - Icon, - IconColor, - IconName, - IconSize, Text, TextColor, TextVariant, FontWeight, - Skeleton, } from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; import type { CampaignLeaderboardEntry } from '../../../../../core/Engine/controllers/rewards-controller/types'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import RewardsErrorBanner from '../RewardsErrorBanner'; -import CrownIcon from '../../../../../images/rewards/crown.svg'; -import { PendingTag } from './CampaignStatsSummary'; +import { + CampaignLeaderboardEntryRow, + CampaignLeaderboardNeighborSeparator, + CampaignLeaderboardSkeleton, + CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS, +} from './CampaignLeaderboard'; import { formatRateOfReturn, formatTierDisplayName, @@ -34,13 +32,14 @@ export const CAMPAIGN_LEADERBOARD_TEST_IDS = { CONTAINER: 'campaign-leaderboard-container', TIER_TOGGLE: 'campaign-leaderboard-tier-toggle', LIST: 'campaign-leaderboard-list', - ENTRY_ROW: 'campaign-leaderboard-entry-row', - PENDING_TAG: 'campaign-leaderboard-pending-tag', - NEIGHBOR_SEPARATOR: 'campaign-leaderboard-neighbor-separator', - LOADING: 'campaign-leaderboard-loading', + ENTRY_ROW: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.ENTRY_ROW, + PENDING_TAG: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.PENDING_TAG, + NEIGHBOR_SEPARATOR: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.NEIGHBOR_SEPARATOR, + LOADING: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.LOADING, ERROR: 'campaign-leaderboard-error', EMPTY: 'campaign-leaderboard-empty', NOT_YET_COMPUTED: 'campaign-leaderboard-not-yet-computed', + LAST_COMPUTED: 'campaign-leaderboard-last-computed', } as const; const MAX_ENTRIES_LIMIT = 20; @@ -72,139 +71,10 @@ interface CampaignLeaderboardProps { /** Campaign ID used for analytics tracking. */ campaignId?: string; isCampaignComplete?: boolean; + /** When true, hides the participants + tier toggle header row (used when the view renders its own tier selector). */ + hideTierHeader?: boolean; } -/** - * LeaderboardEntryRow displays a single leaderboard entry - */ -const LeaderboardEntryRow: React.FC<{ - entry: CampaignLeaderboardEntry; - isCurrentUser?: boolean; - showCrown?: boolean; - isCampaignComplete?: boolean; -}> = ({ - entry, - isCurrentUser = false, - showCrown = false, - isCampaignComplete = false, -}) => { - const isPositiveReturn = entry.rateOfReturn >= 0; - const textColor = isCurrentUser - ? isPositiveReturn - ? TextColor.SuccessDefault - : TextColor.ErrorDefault - : TextColor.TextDefault; - const isPending = !entry.qualified; - const rowBg = isCurrentUser - ? isPending - ? 'bg-muted' - : isPositiveReturn - ? 'bg-success-muted' - : 'bg-error-muted' - : ''; - - return ( - - - - {String(entry.rank).padStart(2, '0')} - - - - {entry.referralCode} - - {showCrown && entry.rank <= 5 && ( - - )} - - {isCurrentUser && isPending && !isCampaignComplete && ( - - )} - - - {formatRateOfReturn(entry.rateOfReturn)} - - - ); -}; - -/** - * LeaderboardSkeleton displays loading skeleton for the leaderboard section - */ -const LeaderboardSkeleton: React.FC = () => { - const tw = useTailwind(); - - return ( - - {/* Leaderboard rows skeleton */} - - {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => ( - - - - - - - - - - ))} - - - ); -}; - -/** - * OndoLeaderboard displays the leaderboard tiers and entries for a campaign. - * Position-specific data (user rank, tier, deposited value) is handled separately - * by the OndoLeaderboardPosition component. - */ -const NeighborSeparator: React.FC = () => ( - - - - ••• - - - -); - const OndoLeaderboard: React.FC = ({ tierNames, selectedTier, @@ -220,6 +90,7 @@ const OndoLeaderboard: React.FC = ({ userPosition, campaignId, isCampaignComplete = false, + hideTierHeader = false, }) => { const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useAnalytics(); @@ -306,7 +177,7 @@ const OndoLeaderboard: React.FC = ({ ); if (isLoading && entries.length === 0) { - return ; + return ; } if (hasError && entries.length === 0) { @@ -360,72 +231,70 @@ const OndoLeaderboard: React.FC = ({ return ( {/* Participants + tier subtitle */} - {(totalParticipants > 0 || Boolean(selectedTierLabel)) && ( - 1 ? openTierSelector : undefined} - testID={CAMPAIGN_LEADERBOARD_TEST_IDS.TIER_TOGGLE} - > - 0 || Boolean(selectedTierLabel)) && ( + 1 ? openTierSelector : undefined} + testID={CAMPAIGN_LEADERBOARD_TEST_IDS.TIER_TOGGLE} > - {totalParticipants > 0 && ( - - {strings( - 'rewards.ondo_campaign_leaderboard.total_participants', - { - count: totalParticipants.toLocaleString(), - }, - )} - - )} - {selectedTierLabel ? ( - <> + + {totalParticipants > 0 && ( + + {strings( + 'rewards.ondo_campaign_leaderboard.total_participants', + { + count: totalParticipants.toLocaleString(), + }, + )} + + )} + {Boolean(selectedTierLabel) && ( {selectedTierLabel} - {tierNames.length > 1 && ( - - )} - - ) : null} - - - )} + )} + + + )} {/* Leaderboard list */} {visibleEntries.length > 0 ? ( {visibleEntries.map((entry) => ( - formatRateOfReturn(e.rateOfReturn)} + isPositivePrimaryMetric={(e) => e.rateOfReturn >= 0} /> ))} {showSplitView && userPosition && ( <> - + {userPosition.neighbors.map((entry) => ( - + formatRateOfReturn(e.rateOfReturn) + } + isPositivePrimaryMetric={(e) => e.rateOfReturn >= 0} /> ))} diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts index c6604c55c87..ef0a551d155 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.test.ts @@ -1,18 +1,27 @@ import { + buildLeaderboardUserPosition, formatRateOfReturn, - formatComputedAt, formatTierDisplayName, + getCampaignTierNames, getTierMinNetDeposit, } from './OndoLeaderboard.utils'; +import type { CampaignLeaderboardPositionDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; jest.mock('../../../../../../locales/i18n', () => ({ - strings: (key: string) => { + strings: (key: string, params?: Record) => { const t: Record = { 'rewards.ondo_campaign_leaderboard.tier_starter': 'Bronze', 'rewards.ondo_campaign_leaderboard.tier_mid': 'Silver', 'rewards.ondo_campaign_leaderboard.tier_upper': 'Platinum', + 'rewards.perps_trading_campaign.last_updated': 'Last updated: {{time}}', }; - return t[key] ?? key; + let template = t[key] ?? key; + if (params) { + for (const [paramKey, value] of Object.entries(params)) { + template = template.split(`{{${paramKey}}}`).join(value); + } + } + return template; }, default: { locale: 'en-US' }, })); @@ -44,35 +53,6 @@ describe('OndoLeaderboard.utils', () => { }); }); - describe('formatComputedAt', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2024-03-20T12:00:00.000Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('returns empty string for null', () => { - expect(formatComputedAt(null)).toBe(''); - }); - - it('returns empty string for empty string', () => { - expect(formatComputedAt('')).toBe(''); - }); - - it('returns a non-empty string for a valid ISO timestamp', () => { - const result = formatComputedAt('2024-03-20T12:00:00.000Z'); - expect(result).toBeTruthy(); - expect(typeof result).toBe('string'); - }); - - it('returns empty string for an unparseable value', () => { - expect(formatComputedAt('not-a-date')).toBe(''); - }); - }); - describe('formatTierDisplayName', () => { it('maps STARTER to Bronze', () => { expect(formatTierDisplayName('STARTER')).toBe('Bronze'); @@ -99,6 +79,81 @@ describe('OndoLeaderboard.utils', () => { }); }); + describe('getCampaignTierNames', () => { + it('returns empty array for null', () => { + expect(getCampaignTierNames(null)).toEqual([]); + }); + + it('returns empty array for undefined', () => { + expect(getCampaignTierNames(undefined)).toEqual([]); + }); + + it('returns empty array when details is null', () => { + expect(getCampaignTierNames({ details: null })).toEqual([]); + }); + + it('returns empty array when tiers is undefined', () => { + expect(getCampaignTierNames({ details: {} })).toEqual([]); + }); + + it('returns empty array for empty tiers', () => { + expect(getCampaignTierNames({ details: { tiers: [] } })).toEqual([]); + }); + + it('returns tier names for a campaign with tiers', () => { + const campaign = { + details: { + tiers: [ + { name: 'STARTER', minNetDeposit: 500 }, + { name: 'MID', minNetDeposit: 1000 }, + ], + }, + }; + expect(getCampaignTierNames(campaign)).toEqual(['STARTER', 'MID']); + }); + }); + + describe('buildLeaderboardUserPosition', () => { + const position: CampaignLeaderboardPositionDto = { + projectedTier: 'MID', + rank: 3, + totalInTier: 50, + rateOfReturn: 0.12, + currentUsdValue: 15000, + totalUsdDeposited: 12000, + netDeposit: 11000, + qualifiedDays: 14, + qualified: true, + neighbors: [ + { + rank: 2, + referralCode: 'ABCDEF', + rateOfReturn: 0.13, + qualifiedDays: 14, + qualified: true, + }, + ], + computedAt: '2024-03-20T12:00:00.000Z', + }; + + it('returns null for null input', () => { + expect(buildLeaderboardUserPosition(null)).toBeNull(); + }); + + it('returns projectedTier, rank, and neighbors for a valid position', () => { + expect(buildLeaderboardUserPosition(position)).toEqual({ + projectedTier: 'MID', + rank: 3, + neighbors: position.neighbors, + }); + }); + + it('passes the neighbors array through unchanged', () => { + const result = buildLeaderboardUserPosition(position); + expect(result?.neighbors).toBe(position.neighbors); + }); + }); + describe('getTierMinNetDeposit', () => { const tiers = [ { name: 'STARTER', minNetDeposit: 500 }, diff --git a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts index 20b4692d541..bceab2d2e8d 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts +++ b/app/components/UI/Rewards/components/Campaigns/OndoLeaderboard.utils.ts @@ -1,11 +1,12 @@ import { strings } from '../../../../../../locales/i18n'; -import type { OndoCampaignTier } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import type { + CampaignLeaderboardEntry, + CampaignLeaderboardPositionDto, + OndoCampaignTier, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; // Re-export shared helpers so existing consumers keep working -export { - formatPercentChange as formatRateOfReturn, - formatComputedAt, -} from '../../utils/formatUtils'; +export { formatPercentChange as formatRateOfReturn } from '../../utils/formatUtils'; // ── Tier display names ────────────────────────────────────────────────── @@ -24,6 +25,28 @@ export const formatTierDisplayName = (tier: string): string => { return key ? strings(key) : tier; }; +export const getCampaignTierNames = ( + campaign: + | { details?: { tiers?: OndoCampaignTier[] } | null } + | null + | undefined, +): string[] => campaign?.details?.tiers?.map((t) => t.name) ?? []; + +export const buildLeaderboardUserPosition = ( + position: CampaignLeaderboardPositionDto | null, +): { + projectedTier: string; + rank: number; + neighbors: CampaignLeaderboardEntry[]; +} | null => + position + ? { + projectedTier: position.projectedTier, + rank: position.rank, + neighbors: position.neighbors, + } + : null; + /** * Looks up the minNetDeposit for a tier from campaign config tiers. * Returns null when the tier or config is missing. diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx index eeb25e8ae7f..6db8aef4c69 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoPortfolio.test.tsx @@ -168,10 +168,6 @@ jest.mock('../../../../../util/ondoGeoRestrictions', () => ({ isGeoRestricted: jest.fn(() => false), })); -jest.mock('./OndoLeaderboard.utils', () => ({ - formatComputedAt: jest.fn(), -})); - jest.mock('../../../../../images/rewards/rewards-no-positions.svg', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.test.tsx index 1cc026ed32c..bd1e1c9cb21 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.test.tsx @@ -1,9 +1,6 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; -import OndoPrizePool, { - ONDO_PRIZE_POOL_TEST_IDS, - getCurrentPrize, -} from './OndoPrizePool'; +import OndoPrizePool, { ONDO_PRIZE_POOL_TEST_IDS } from './OndoPrizePool'; jest.mock('@metamask/design-system-react-native', () => { const actual = jest.requireActual('@metamask/design-system-react-native'); @@ -218,40 +215,3 @@ describe('OndoPrizePool', () => { expect(mockRefetch).toHaveBeenCalledTimes(1); }); }); - -describe('getCurrentPrize', () => { - it('returns $25,000 for $0 deposits', () => { - expect(getCurrentPrize(0)).toBe(25_000); - }); - - it('returns $25,000 for deposits below $1.5M', () => { - expect(getCurrentPrize(500_000)).toBe(25_000); - expect(getCurrentPrize(1_499_999)).toBe(25_000); - }); - - it('returns $50,000 at exactly $1.5M', () => { - expect(getCurrentPrize(1_500_000)).toBe(50_000); - }); - - it('returns $50,000 for deposits between $1.5M and $3.5M', () => { - expect(getCurrentPrize(2_000_000)).toBe(50_000); - expect(getCurrentPrize(3_499_999)).toBe(50_000); - }); - - it('returns $75,000 at exactly $3.5M', () => { - expect(getCurrentPrize(3_500_000)).toBe(75_000); - }); - - it('returns $75,000 for deposits between $3.5M and $6M', () => { - expect(getCurrentPrize(4_500_000)).toBe(75_000); - expect(getCurrentPrize(5_999_999)).toBe(75_000); - }); - - it('returns $100,000 at exactly $6M', () => { - expect(getCurrentPrize(6_000_000)).toBe(100_000); - }); - - it('returns $100,000 for deposits above $6M', () => { - expect(getCurrentPrize(10_000_000)).toBe(100_000); - }); -}); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.tsx b/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.tsx index a614713eb56..c734e835ff8 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoPrizePool.tsx @@ -13,6 +13,7 @@ import { import { useTailwind } from '@metamask/design-system-twrnc-preset'; import RewardsErrorBanner from '../RewardsErrorBanner'; import { formatCompactUsd, formatUsd } from '../../utils/formatUtils'; +import { computePrizePoolProgress } from '../../utils/prizePoolUtils'; import { strings } from '../../../../../../locales/i18n'; export const ONDO_PRIZE_POOL_TEST_IDS = { @@ -30,50 +31,6 @@ export const BREAKPOINTS = [ { deposit: 6_000_000, prize: 100_000 }, ] as const; -export function getCurrentPrize(totalDeposited: number): number { - for (let i = BREAKPOINTS.length - 1; i >= 0; i--) { - if (totalDeposited >= BREAKPOINTS[i].deposit) { - return BREAKPOINTS[i].prize; - } - } - return BREAKPOINTS[0].prize; -} - -function computeProgress(totalDeposited: number) { - let currentIndex = 0; - for (let i = BREAKPOINTS.length - 1; i >= 0; i--) { - if (totalDeposited >= BREAKPOINTS[i].deposit) { - currentIndex = i; - break; - } - } - - const current = BREAKPOINTS[currentIndex]; - const next = BREAKPOINTS[currentIndex + 1]; - - if (!next) { - return { - progress: 1, - currentPrize: current.prize, - nextPrize: null, - nextThreshold: current.deposit, - isMaxTier: true, - }; - } - - const rangeDeposit = next.deposit - current.deposit; - const progressInRange = totalDeposited - current.deposit; - const progress = Math.min(progressInRange / rangeDeposit, 1); - - return { - progress, - currentPrize: current.prize, - nextPrize: next.prize, - nextThreshold: next.deposit, - isMaxTier: false, - }; -} - interface OndoPrizePoolProps { totalUsdDeposited: string | null; isLoading: boolean; @@ -103,7 +60,11 @@ const OndoPrizePool: React.FC = ({ isMaxTier: false, }; } - return computeProgress(parseFloat(totalUsdDeposited)); + return computePrizePoolProgress( + BREAKPOINTS, + parseFloat(totalUsdDeposited), + (m) => m.deposit, + ); }, [totalUsdDeposited]); const progressPercent: `${number}%` = `${Math.round(progress * 100)}%`; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.test.tsx new file mode 100644 index 00000000000..071018490ea --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.test.tsx @@ -0,0 +1,280 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { TextColor } from '@metamask/design-system-react-native'; +import PerpsCampaignStatsSummary, { + PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS, +} from './PerpsCampaignStatsSummary'; +import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + return { + ...actual, + Text: (props: Record) => + ReactActual.createElement(RN.Text, props, props.children), + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +jest.mock('./CampaignOutcomeBanners', () => { + const ReactActual = jest.requireActual('react'); + const { Pressable, Text } = jest.requireActual('react-native'); + return { + CampaignOutcomeBanner: ({ + outcomeStatus, + winnerVerificationCode, + onWinnerPress, + }: { + outcomeStatus: string; + winnerVerificationCode?: string | null; + onWinnerPress: () => void; + }) => + ReactActual.createElement( + Pressable, + { + testID: `campaign-outcome-banner-${outcomeStatus}-${winnerVerificationCode ?? 'null'}`, + onPress: onWinnerPress, + }, + ReactActual.createElement(Text, null, 'Campaign outcome'), + ), + }; +}); + +const TEST_IDS = PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS; + +const mockLeaderboard = { + campaignId: 'c1', + computedAt: '2025-01-01T00:00:00.000Z', + entries: [], + totalParticipants: 0, +}; + +const basePosition: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 7, + pnl: 1500.25, + notionalVolume: 30_000, + marginDeployed: 2000, + qualified: true, + neighbors: [], + computedAt: '2025-01-01T00:00:00.000Z', +}; + +describe('PerpsCampaignStatsSummary', () => { + it('renders container and four stat labels', () => { + const { getByTestId, getByText } = render( + , + ); + + expect(getByTestId(TEST_IDS.CONTAINER)).toBeDefined(); + expect( + getByText('rewards.perps_trading_campaign.label_rank'), + ).toBeDefined(); + expect(getByText('rewards.perps_trading_campaign.label_pnl')).toBeDefined(); + expect( + getByText('rewards.perps_trading_campaign.label_volume'), + ).toBeDefined(); + expect( + getByText('rewards.perps_trading_campaign.label_margin'), + ).toBeDefined(); + expect(getByText('07')).toBeDefined(); + expect(getByText('+$1,500.25')).toBeDefined(); + }); + + it('uses success color for non-negative pnl', () => { + const { getByTestId } = render( + , + ); + const pnlCell = getByTestId(TEST_IDS.PNL); + expect(pnlCell.props.color).toBe(TextColor.SuccessDefault); + }); + + it('uses error color for negative pnl', () => { + const { getByTestId } = render( + , + ); + const pnlCell = getByTestId(TEST_IDS.PNL); + expect(pnlCell.props.color).toBe(TextColor.ErrorDefault); + }); + + it('renders em dashes when position is null', () => { + const { getAllByText } = render( + , + ); + expect(getAllByText('—').length).toBeGreaterThanOrEqual(4); + }); + + it('shows pending tag on rank when campaign is active and user is not qualified', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.PENDING_TAG)).toBeDefined(); + expect(queryByTestId(TEST_IDS.QUALIFIED_TAG)).toBeNull(); + }); + + it('shows qualified check on rank when user is qualified', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.QUALIFIED_TAG)).toBeDefined(); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + }); + + it('does not show pending tag on rank when campaign is complete and user is not qualified', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + expect(queryByTestId(TEST_IDS.QUALIFIED_TAG)).toBeNull(); + }); + + it('shows qualified check when campaign is complete and user is qualified', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.QUALIFIED_TAG)).toBeDefined(); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + }); + + it("shows You're qualified card when campaign is active and user is qualified", () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.QUALIFIED_CARD)).toBeDefined(); + expect(queryByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeNull(); + }); + + it('hides volume and margin StatCells when campaign is complete (only rank and PnL remain)', () => { + const { queryByTestId, getByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.RANK)).toBeDefined(); + expect(getByTestId(TEST_IDS.PNL)).toBeDefined(); + expect(queryByTestId(TEST_IDS.NOTIONAL_VOLUME)).toBeNull(); + expect(queryByTestId(TEST_IDS.MARGIN_DEPLOYED)).toBeNull(); + }); + + it("hides You're qualified card when campaign is complete", () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.QUALIFIED_CARD)).toBeNull(); + }); + + it('shows Qualify for rank card when pending and below qualification thresholds', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeDefined(); + expect(queryByTestId(TEST_IDS.QUALIFIED_CARD)).toBeNull(); + }); + + it('hides Qualify for rank card when notional volume already meets threshold even if still pending', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeNull(); + }); + + it('shows outcome banner for complete campaigns and handles winner press', () => { + const onWinnerPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press( + getByTestId('campaign-outcome-banner-pending-PERPS-WINNER-123'), + ); + expect(onWinnerPress).toHaveBeenCalledTimes(1); + }); + + it('does not show outcome banner before campaign completion', () => { + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId('campaign-outcome-banner-pending-PERPS-WINNER-123'), + ).toBeNull(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx new file mode 100644 index 00000000000..7c3c7a2fe49 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx @@ -0,0 +1,205 @@ +import React from 'react'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { + CampaignParticipantOutcomeStatus, + PerpsTradingCampaignLeaderboardDto, + PerpsTradingCampaignLeaderboardPositionDto, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../../locales/i18n'; +import { formatSignedUsd, formatUsd } from '../../utils/formatUtils'; +import { PERPS_QUALIFICATION_NOTIONAL_USD } from '../../utils/perpsCampaignConstants'; +import { PendingTag, StatCell } from './OndoCampaignStatsSummary'; +import { CampaignOutcomeBanner } from './CampaignOutcomeBanners'; + +const PERPS_NOTIONAL_THRESHOLD_LABEL = formatUsd( + PERPS_QUALIFICATION_NOTIONAL_USD, +); + +export const PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS = { + CONTAINER: 'perps-campaign-stats-summary-container', + RANK: 'perps-campaign-stats-summary-rank', + PNL: 'perps-campaign-stats-summary-pnl', + NOTIONAL_VOLUME: 'perps-campaign-stats-summary-notional-volume', + MARGIN_DEPLOYED: 'perps-campaign-stats-summary-margin-deployed', + PENDING_TAG: 'perps-campaign-stats-summary-pending-tag', + QUALIFIED_TAG: 'perps-campaign-stats-summary-qualified-tag', + QUALIFIED_CARD: 'perps-campaign-stats-summary-qualified-card', + QUALIFY_FOR_RANK_CARD: 'perps-campaign-stats-summary-qualify-for-rank-card', +} as const; + +export interface PerpsCampaignStatsSummaryProps { + leaderboardPosition: PerpsTradingCampaignLeaderboardPositionDto | null; + /** Passed for future use (e.g. leaderboard-level metadata); stats values come from `leaderboardPosition`. */ + leaderboard: PerpsTradingCampaignLeaderboardDto | null; + /** When false, pending (not yet qualified) users see a {@link PendingTag} next to rank. */ + isCampaignComplete?: boolean; + outcomeStatus?: CampaignParticipantOutcomeStatus; + winnerVerificationCode?: string | null; + onWinnerPress?: () => void; +} + +const PerpsCampaignStatsSummary: React.FC = ({ + leaderboardPosition, + leaderboard: _leaderboard, + isCampaignComplete = false, + outcomeStatus, + winnerVerificationCode, + onWinnerPress, +}) => { + const isPending = + leaderboardPosition != null && !leaderboardPosition.qualified; + const isQualified = + leaderboardPosition != null && leaderboardPosition.qualified; + + const rankDisplay = leaderboardPosition + ? String(leaderboardPosition.rank).padStart(2, '0') + : '—'; + + const pnlDisplay = leaderboardPosition + ? formatSignedUsd(leaderboardPosition.pnl) + : '—'; + + const pnlColor = leaderboardPosition + ? leaderboardPosition.pnl >= 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault + : TextColor.TextDefault; + + const volumeDisplay = leaderboardPosition + ? formatUsd(leaderboardPosition.notionalVolume) + : '—'; + + const marginDisplay = leaderboardPosition + ? formatUsd(leaderboardPosition.marginDeployed) + : '—'; + + const notionalGap = leaderboardPosition + ? Math.max( + 0, + PERPS_QUALIFICATION_NOTIONAL_USD - leaderboardPosition.notionalVolume, + ) + : 0; + + const showQualifiedCard = + !isCampaignComplete && isQualified && leaderboardPosition != null; + + const showQualifyForRankCard = + !isCampaignComplete && + isPending && + leaderboardPosition != null && + notionalGap > 0; + + return ( + + + + ) : isQualified ? ( + + ) : undefined + } + /> + + + {!isCampaignComplete && ( + + + + + )} + + {showQualifiedCard && ( + + + {strings('rewards.perps_trading_campaign.stats_qualified_title')} + + + {strings( + 'rewards.perps_trading_campaign.stats_qualified_description', + )} + + + )} + + {showQualifyForRankCard && ( + + + + {strings( + 'rewards.perps_trading_campaign.stats_qualify_for_rank_title', + )} + + + + {strings( + 'rewards.perps_trading_campaign.stats_qualify_for_rank_description', + { + notionalRemaining: formatUsd(notionalGap), + }, + )} + + + )} + + {isCampaignComplete && outcomeStatus != null && onWinnerPress != null && ( + + )} + + ); +}; + +export default PerpsCampaignStatsSummary; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.test.tsx new file mode 100644 index 00000000000..45efd5f19a9 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.test.tsx @@ -0,0 +1,210 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import PerpsTradingCampaignCTA from './PerpsTradingCampaignCTA'; +import { CAMPAIGN_CTA_TEST_IDS } from './CampaignOptInCta'; +import { + type CampaignDto, + CampaignType, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { selectPerpsEligibility } from '../../../Perps/selectors/perpsController'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +const mockHandleDeeplink = jest.fn(); +jest.mock('../../../../../core/DeeplinkManager', () => ({ + handleDeeplink: (...args: unknown[]) => mockHandleDeeplink(...args), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +const mockShowToast = jest.fn(); +const mockEntriesClosed = jest.fn(() => ({ variant: 'icon' })); + +jest.mock('../../hooks/useRewardsToast', () => ({ + __esModule: true, + default: () => ({ + showToast: mockShowToast, + RewardsToastOptions: { + success: jest.fn(), + error: jest.fn(), + entriesClosed: mockEntriesClosed, + }, + }), +})); + +jest.mock('./CampaignOptInSheet', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'campaign-opt-in-sheet' }), + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const map: Record = { + 'rewards.perps_trading_campaign.open_position_cta': 'Open Position', + 'rewards.campaign_details.join_campaign': 'Join Campaign', + 'rewards.campaign.geo_locked_cta': 'Geo locked', + 'rewards.campaign.geo_locked_toast_title': 'Not available', + 'rewards.campaign.geo_locked_toast_description': 'Region restricted', + }; + return map[key] ?? key; + }, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +function buildCampaign(overrides: Partial = {}): CampaignDto { + return { + id: 'perps-campaign-1', + type: CampaignType.PERPS_TRADING, + name: 'Perps Trading Campaign', + startDate: '2025-06-01T00:00:00.000Z', + endDate: '2026-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: true, + showUpcomingDate: false, + ...overrides, + }; +} + +const notOptedIn = { + status: { optedIn: false, participantCount: 0 } as const, + isLoading: false, +}; + +const optedIn = { + status: { optedIn: true, participantCount: 1 } as const, + isLoading: false, +}; + +describe('PerpsTradingCampaignCTA', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-08-15T12:00:00.000Z')); + jest.clearAllMocks(); + mockHandleDeeplink.mockResolvedValue(undefined); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsEligibility) { + return true; + } + return undefined; + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders nothing while participant status is loading', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull(); + }); + + it('renders nothing when campaign is upcoming', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull(); + }); + + it('renders nothing when campaign is complete', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)).toBeNull(); + }); + + it('when opted in, shows Open Position and calls handleDeeplink with perps market-list URL', () => { + const { getByTestId, getByText } = render( + , + ); + + expect(getByText('Open Position')).toBeOnTheScreen(); + fireEvent.press(getByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)); + + expect(mockHandleDeeplink).toHaveBeenCalledWith({ + uri: 'https://link.metamask.io/perps?screen=market-list', + }); + }); + + it('when not opted in and geo-ineligible, shows geo locked CTA and toast on press', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsEligibility) { + return false; + } + return undefined; + }); + + const { getByTestId, getByText } = render( + , + ); + + expect(getByText('Geo locked')).toBeOnTheScreen(); + fireEvent.press(getByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)); + + expect(mockEntriesClosed).toHaveBeenCalledWith( + 'Not available', + 'Region restricted', + ); + expect(mockShowToast).toHaveBeenCalledWith({ variant: 'icon' }); + expect(mockHandleDeeplink).not.toHaveBeenCalled(); + }); + + it('when not opted in and eligible, shows Join Campaign and opens opt-in sheet', () => { + const { getByTestId, getByText, queryByTestId } = render( + , + ); + + expect(queryByTestId('campaign-opt-in-sheet')).toBeNull(); + expect(getByText('Join Campaign')).toBeOnTheScreen(); + + fireEvent.press(getByTestId(CAMPAIGN_CTA_TEST_IDS.CTA_BUTTON)); + + expect(getByTestId('campaign-opt-in-sheet')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.tsx new file mode 100644 index 00000000000..43f39e96ee0 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignCTA.tsx @@ -0,0 +1,131 @@ +import React, { useCallback, useState } from 'react'; +import { + Box, + Button, + ButtonSize, + ButtonVariant, + IconName, +} from '@metamask/design-system-react-native'; +import type { CampaignDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import type { UseGetCampaignParticipantStatusResult } from '../../hooks/useGetCampaignParticipantStatus'; +import { getCampaignStatus } from './CampaignTile.utils'; +import { strings } from '../../../../../../locales/i18n'; +import CampaignOptInSheet from './CampaignOptInSheet'; +import { CAMPAIGN_CTA_TEST_IDS } from './CampaignOptInCta'; +import { selectPerpsEligibility } from '../../../Perps/selectors/perpsController'; +import { useSelector } from 'react-redux'; +import useRewardsToast from '../../hooks/useRewardsToast'; +import { handleDeeplink } from '../../../../../core/DeeplinkManager'; + +interface PerpsTradingCampaignCTAProps { + campaign: CampaignDto; + participantStatus: Pick< + UseGetCampaignParticipantStatusResult, + 'status' | 'isLoading' + >; +} + +const PerpsTradingCampaignCTA: React.FC = ({ + campaign, + participantStatus, +}) => { + const { showToast, RewardsToastOptions } = useRewardsToast(); + const isPerpsEligible = useSelector(selectPerpsEligibility); + const [isOptInSheetOpen, setIsOptInSheetOpen] = useState(false); + + const campaignStatus = getCampaignStatus(campaign); + const isLoading = participantStatus.isLoading; + const isOptedIn = participantStatus?.status?.optedIn === true; + + const handleGeoLockedPress = useCallback(() => { + showToast( + RewardsToastOptions.entriesClosed( + strings('rewards.campaign.geo_locked_toast_title'), + strings('rewards.campaign.geo_locked_toast_description'), + ), + ); + }, [showToast, RewardsToastOptions]); + + const handleJoinPress = useCallback(() => { + setIsOptInSheetOpen(true); + }, []); + + const handleOpenPosition = useCallback(async () => { + await handleDeeplink({ + uri: 'https://link.metamask.io/perps?screen=market-list', + }); + }, []); + + if (isLoading) { + return null; + } + + // Campaign complete — no CTA (leaderboard section handles it) + if (campaignStatus === 'complete') { + return null; + } + + if (campaignStatus !== 'active') { + return null; + } + + // Opted in — show "Open Position" + if (isOptedIn) { + return ( + + + + ); + } + + // Not opted in — geo-restricted + if (!isPerpsEligible) { + return ( + + + + ); + } + + // Not opted in — eligible + return ( + <> + + + + {isOptInSheetOpen && ( + setIsOptInSheetOpen(false)} + /> + )} + + ); +}; + +export default PerpsTradingCampaignCTA; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx new file mode 100644 index 00000000000..f3cb469d9ea --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx @@ -0,0 +1,276 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsTradingCampaignEndedStats, { + PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS, +} from './PerpsTradingCampaignEndedStats'; +import type { + PerpsTradingCampaignLeaderboardDto, + PerpsTradingCampaignLeaderboardEntry, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('../RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: { title: string; onConfirm: () => void }) => + ReactActual.createElement( + RN.View, + { testID: 'rewards-error-banner' }, + ReactActual.createElement(RN.Text, null, props.title), + ReactActual.createElement(RN.TouchableOpacity, { + testID: 'rewards-error-banner-retry', + onPress: props.onConfirm, + }), + ), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + return { + ...actual, + Text: (props: Record) => + ReactActual.createElement(RN.Text, props, props.children), + Skeleton: (props: Record) => + ReactActual.createElement(RN.View, { testID: 'skeleton', ...props }), + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +jest.mock('../../utils/formatUtils', () => ({ + formatCompactUsd: (value: number) => `$${(value / 1_000_000).toFixed(1)}M`, + formatSignedUsd: (value: number) => { + const sign = value >= 0 ? '+' : '-'; + const abs = Math.abs(value).toLocaleString(); + return `${sign}$${abs}`; + }, +})); + +const makeEntry = ( + rank: number, + pnl: number, + qualified = true, +): PerpsTradingCampaignLeaderboardEntry => ({ + rank, + referralCode: `T-${rank}`, + pnl, + qualified, +}); + +const makeLeaderboard = ( + entriesCount: number, + totalParticipants?: number, + topPnl = 50_000, +): PerpsTradingCampaignLeaderboardDto => { + const entries = Array.from({ length: entriesCount }, (_, i) => + makeEntry(i + 1, topPnl - i * 1000), + ); + return { + campaignId: 'perps-1', + computedAt: '2026-01-01T00:00:00Z', + totalParticipants: totalParticipants ?? entriesCount, + entries, + }; +}; + +describe('PerpsTradingCampaignEndedStats', () => { + it('renders all four stat cells with correct values when leaderboard has 20+ entries', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.CONTAINER), + ).toBeTruthy(); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props + .children, + ).toBe((200).toLocaleString()); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props + .children, + ).toBe('$27.5M'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('+$80,000'); + // Leaderboard has 25 entries (>= 20) → fixed 20 winners + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('20'); + }); + + it('shows dash for winners when leaderboard has fewer than 20 entries', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('-'); + }); + + it('shows dashes when leaderboard and volume are null', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props + .children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props + .children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('-'); + }); + + it('renders skeletons while data is loading', () => { + const { getAllByTestId } = render( + , + ); + + const skeletons = getAllByTestId('skeleton'); + expect(skeletons.length).toBeGreaterThanOrEqual(3); + }); + + it('handles a leaderboard with no entries (no top PnL)', () => { + const empty: PerpsTradingCampaignLeaderboardDto = { + campaignId: 'perps-1', + computedAt: '2026-01-01T00:00:00Z', + totalParticipants: 0, + entries: [], + }; + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props + .children, + ).toBe('0'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('-'); + }); + + it('shows error banner when both sources fail and triggers both retries', () => { + const onRetryLeaderboard = jest.fn(); + const onRetryVolume = jest.fn(); + + const { getByTestId } = render( + , + ); + + expect(getByTestId('rewards-error-banner')).toBeTruthy(); + fireEvent.press(getByTestId('rewards-error-banner-retry')); + expect(onRetryLeaderboard).toHaveBeenCalledTimes(1); + expect(onRetryVolume).toHaveBeenCalledTimes(1); + }); + + it('shows error banner when only leaderboard fails; volume still renders', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('rewards-error-banner')).toBeTruthy(); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props + .children, + ).toBe('$27.5M'); + }); + + it('does not render error banner when there are no errors', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('rewards-error-banner')).toBeNull(); + }); + + it('renders negative top PnL with error color and a minus sign', () => { + const negativeTop: PerpsTradingCampaignLeaderboardDto = { + campaignId: 'perps-1', + computedAt: '2026-01-01T00:00:00Z', + totalParticipants: 1, + entries: [makeEntry(1, -5_000)], + }; + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('-$5,000'); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx new file mode 100644 index 00000000000..af3a1616288 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx @@ -0,0 +1,153 @@ +import React, { useMemo } from 'react'; +import { + Box, + BoxFlexDirection, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { PerpsTradingCampaignLeaderboardDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { StatCell } from './OndoCampaignStatsSummary'; +import RewardsErrorBanner from '../RewardsErrorBanner'; +import { strings } from '../../../../../../locales/i18n'; +import { formatCompactUsd, formatSignedUsd } from '../../utils/formatUtils'; + +const PERPS_WINNERS_CAP = 20; + +export const PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS = { + CONTAINER: 'perps-campaign-ended-stats-container', + TOTAL_PARTICIPANTS: 'perps-campaign-ended-stats-total-participants', + TOTAL_VOLUME: 'perps-campaign-ended-stats-total-volume', + TOP_PNL: 'perps-campaign-ended-stats-top-pnl', + WINNERS: 'perps-campaign-ended-stats-winners', +} as const; + +interface PerpsTradingCampaignEndedStatsProps { + leaderboard: PerpsTradingCampaignLeaderboardDto | null; + totalNotionalVolume: string | null; + isLeaderboardLoading: boolean; + isVolumeLoading: boolean; + hasLeaderboardError?: boolean; + hasVolumeError?: boolean; + onRetryLeaderboard?: () => void; + onRetryVolume?: () => void; +} + +const PerpsTradingCampaignEndedStats: React.FC< + PerpsTradingCampaignEndedStatsProps +> = ({ + leaderboard, + totalNotionalVolume, + isLeaderboardLoading, + isVolumeLoading, + hasLeaderboardError, + hasVolumeError, + onRetryLeaderboard, + onRetryVolume, +}) => { + const stats = useMemo(() => { + if (!leaderboard) return null; + + const entries = leaderboard.entries ?? []; + const totalParticipants = leaderboard.totalParticipants; + const topPnl = + entries.length > 0 ? Math.max(...entries.map((e) => e.pnl)) : null; + const hasFullLeaderboard = entries.length >= PERPS_WINNERS_CAP; + return { totalParticipants, topPnl, hasFullLeaderboard }; + }, [leaderboard]); + + const isLeaderboardSkeletonVisible = isLeaderboardLoading && !leaderboard; + const isVolumeSkeletonVisible = isVolumeLoading && !totalNotionalVolume; + + const hasError = + (hasLeaderboardError && !leaderboard) || + (hasVolumeError && !totalNotionalVolume); + + const totalParticipantsValue = stats + ? stats.totalParticipants.toLocaleString() + : '-'; + + const totalVolumeValue = totalNotionalVolume + ? formatCompactUsd(parseFloat(totalNotionalVolume)) + : '-'; + + const topPnlValue = + stats?.topPnl != null ? formatSignedUsd(stats.topPnl) : '-'; + + const topPnlColor = + stats?.topPnl != null && stats.topPnl >= 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault; + + const winnersValue = stats?.hasFullLeaderboard + ? String(PERPS_WINNERS_CAP) + : '-'; + + return ( + + + {strings('rewards.perps_trading_campaign.stats_title')} + + {hasError && ( + { + onRetryLeaderboard?.(); + onRetryVolume?.(); + }} + confirmButtonLabel={strings( + 'rewards.perps_trading_campaign.stats_retry', + )} + /> + )} + + + + + + + + + + ); +}; + +export default PerpsTradingCampaignEndedStats; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx new file mode 100644 index 00000000000..8b15a8f46ca --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.test.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsTradingCampaignLeaderboard, { + PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS, +} from './PerpsTradingCampaignLeaderboard'; +import type { PerpsTradingCampaignLeaderboardEntry } from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +jest.mock('../../utils/formatUtils', () => ({ + formatSignedUsd: (value: number) => `$${value.toFixed(2)}`, +})); + +jest.mock('../RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + return { + __esModule: true, + default: ({ children }: { children?: React.ReactNode }) => + ReactActual.createElement(ReactActual.Fragment, null, children), + }; +}); + +jest.mock('../../../../../images/rewards/crown.svg', () => 'CrownIcon'); + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: mockNavigate, + }), +})); + +jest.mock('../../../../../constants/navigation/Routes', () => ({ + __esModule: true, + default: { + BROWSER: { HOME: 'BrowserHome', VIEW: 'BrowserView' }, + }, +})); + +const TEST_IDS = PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS; + +const createPerpsEntry = ( + overrides: Partial = {}, +): PerpsTradingCampaignLeaderboardEntry => ({ + rank: 1, + referralCode: 'REF001', + pnl: 100, + qualified: true, + ...overrides, +}); + +const defaultProps = { + entries: [ + createPerpsEntry({ rank: 1, referralCode: 'A' }), + createPerpsEntry({ rank: 2, referralCode: 'B' }), + ], + isLoading: false, + hasError: false, +}; + +describe('PerpsTradingCampaignLeaderboard', () => { + beforeEach(() => { + mockNavigate.mockClear(); + }); + + it('renders container and list with entry testIDs', () => { + const { getByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.CONTAINER)).toBeDefined(); + expect(getByTestId(TEST_IDS.LIST)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-1`)).toBeDefined(); + expect(getByTestId(TEST_IDS.POWERED_BY)).toBeDefined(); + }); + + it('navigates to in-app browser with HyperTracker attribution URL when brand is pressed', () => { + const { getByText } = render( + , + ); + fireEvent.press( + getByText( + 'rewards.perps_trading_campaign.leaderboard_hypertracker_brand', + ), + ); + + expect(mockNavigate).toHaveBeenCalledWith( + 'BrowserHome', + expect.objectContaining({ + screen: 'BrowserView', + params: expect.objectContaining({ + newTabUrl: + 'https://hypertracker.io?utm_source=metamask&utm_medium=leaderboard&utm_campaign=partner-attribution', + }), + }), + ); + }); + + describe('split view top count (preview vs full, ranks 21–22 vs other)', () => { + const tenEntries = Array.from({ length: 10 }, (_, i) => + createPerpsEntry({ + rank: i + 1, + referralCode: `S${String(i + 1).padStart(3, '0')}`, + pnl: 10 - i, + }), + ); + + it('preview mode shows top 3 then separator and neighbors when rank is outside range', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-1`)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-2`)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-3`)).toBeDefined(); + expect(queryByTestId(`${TEST_IDS.ENTRY_ROW}-4`)).toBeNull(); + expect(getByTestId(TEST_IDS.NEIGHBOR_SEPARATOR)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-250`)).toBeDefined(); + }); + + const twentyFiveEntries = Array.from({ length: 25 }, (_, i) => + createPerpsEntry({ + rank: i + 1, + referralCode: `R${String(i + 1).padStart(3, '0')}`, + pnl: 1000 - i, + }), + ); + + it('full mode shows 18 top rows when user rank is 21 (reduced top strip)', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-1`)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-18`)).toBeDefined(); + expect(queryByTestId(`${TEST_IDS.ENTRY_ROW}-19`)).toBeNull(); + expect(getByTestId(TEST_IDS.NEIGHBOR_SEPARATOR)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-20`)).toBeDefined(); + }); + + it('full mode shows 20 top rows when user rank is 23 (standard strip)', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-20`)).toBeDefined(); + expect(queryByTestId(`${TEST_IDS.ENTRY_ROW}-21`)).toBeNull(); + expect(getByTestId(TEST_IDS.NEIGHBOR_SEPARATOR)).toBeDefined(); + expect(getByTestId(`${TEST_IDS.ENTRY_ROW}-22`)).toBeDefined(); + }); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx new file mode 100644 index 00000000000..8537bdc3051 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignLeaderboard.tsx @@ -0,0 +1,240 @@ +import React, { useCallback, useMemo } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { + Box, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { PerpsTradingCampaignLeaderboardEntry } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../../locales/i18n'; +import RewardsErrorBanner from '../RewardsErrorBanner'; +import { formatSignedUsd } from '../../utils/formatUtils'; +import { + CampaignLeaderboardEntryRow, + CampaignLeaderboardNeighborSeparator, + CampaignLeaderboardSkeleton, + CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS, +} from './CampaignLeaderboard'; +import Routes from '../../../../../constants/navigation/Routes'; +import { HYPERTRACKER_ATTRIBUTION_URL } from '../../utils/perpsCampaignConstants'; + +export const PERPS_CAMPAIGN_LEADERBOARD_TEST_IDS = { + CONTAINER: 'perps-campaign-leaderboard-container', + LIST: 'perps-campaign-leaderboard-list', + ENTRY_ROW: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.ENTRY_ROW, + PENDING_TAG: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.PENDING_TAG, + NEIGHBOR_SEPARATOR: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.NEIGHBOR_SEPARATOR, + LOADING: CAMPAIGN_LEADERBOARD_SHARED_TEST_IDS.LOADING, + ERROR: 'perps-campaign-leaderboard-error', + EMPTY: 'perps-campaign-leaderboard-empty', + NOT_YET_COMPUTED: 'perps-campaign-leaderboard-not-yet-computed', + TOTAL_PARTICIPANTS: 'perps-campaign-leaderboard-total-participants', + POWERED_BY: 'perps-campaign-leaderboard-powered-by', +} as const; + +const MAX_ENTRIES_LIMIT = 20; +const SPLIT_VIEW_TOP_COUNT_PREVIEW = 3; +/** Ranks just below the first page: show one fewer top rows to keep split view from crowding the neighbor block. */ +const FULL_SPLIT_TOP_REDUCED_AT_RANKS: readonly number[] = [21, 22]; + +interface UserPosition { + rank: number; + neighbors: PerpsTradingCampaignLeaderboardEntry[]; +} + +export interface PerpsTradingCampaignLeaderboardProps { + entries: PerpsTradingCampaignLeaderboardEntry[]; + isLoading: boolean; + hasError: boolean; + isLeaderboardNotYetComputed?: boolean; + onRetry?: () => void; + currentUserReferralCode?: string | null; + maxEntries?: number; + userPosition?: UserPosition | null; + campaignId?: string; + isCampaignComplete?: boolean; +} + +const PerpsTradingCampaignLeaderboard: React.FC< + PerpsTradingCampaignLeaderboardProps +> = ({ + entries, + isLoading, + hasError, + isLeaderboardNotYetComputed = false, + onRetry, + currentUserReferralCode, + maxEntries, + userPosition, + isCampaignComplete = false, +}) => { + const navigation = useNavigation(); + + const handleHyperTrackerPress = useCallback(() => { + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: HYPERTRACKER_ATTRIBUTION_URL, + timestamp: Date.now(), + }, + }); + }, [navigation]); + + const isPreview = maxEntries != null; + + const effectiveMaxEntries = + maxEntries != null && maxEntries <= MAX_ENTRIES_LIMIT + ? maxEntries + : MAX_ENTRIES_LIMIT; + + /** Top rows above the neighbor separator in split view (preview: 3; full: 18 for rank 21–22, else 20). */ + const splitViewTopCount = useMemo(() => { + if (isPreview) { + return SPLIT_VIEW_TOP_COUNT_PREVIEW; + } + const rank = userPosition?.rank; + if (rank == null) { + return MAX_ENTRIES_LIMIT; + } + return FULL_SPLIT_TOP_REDUCED_AT_RANKS.includes(rank) + ? MAX_ENTRIES_LIMIT - 2 + : MAX_ENTRIES_LIMIT; + }, [isPreview, userPosition?.rank]); + + const showSplitView = useMemo(() => { + if (!userPosition) return false; + return ( + userPosition.rank > effectiveMaxEntries && + userPosition.neighbors.length > 0 + ); + }, [userPosition, effectiveMaxEntries]); + + const visibleEntries = useMemo(() => { + if (showSplitView) { + return entries.slice(0, splitViewTopCount); + } + return entries.slice(0, effectiveMaxEntries); + }, [entries, effectiveMaxEntries, showSplitView, splitViewTopCount]); + + const isCurrentUser = useCallback( + (entry: PerpsTradingCampaignLeaderboardEntry) => + !!currentUserReferralCode && + entry.referralCode === currentUserReferralCode, + [currentUserReferralCode], + ); + + if (isLoading && entries.length === 0) { + return ; + } + + if (hasError && entries.length === 0) { + return ( + + + + ); + } + + if (isLeaderboardNotYetComputed && !isLoading && entries.length === 0) { + return ( + + + {strings( + 'rewards.perps_trading_campaign.leaderboard_not_yet_computed', + )} + + + ); + } + + if (entries.length === 0) { + return ( + + + {strings( + 'rewards.perps_trading_campaign.leaderboard_not_yet_computed', + )} + + + ); + } + + return ( + + {/* Leaderboard list */} + + {visibleEntries.map((entry) => ( + formatSignedUsd(e.pnl)} + isPositivePrimaryMetric={(e) => e.pnl >= 0} + /> + ))} + {showSplitView && userPosition && ( + <> + + {userPosition.neighbors.map((entry) => ( + formatSignedUsd(e.pnl)} + isPositivePrimaryMetric={(e) => e.pnl >= 0} + /> + ))} + + )} + + + {strings( + 'rewards.perps_trading_campaign.leaderboard_powered_by_prefix', + )} + + {strings( + 'rewards.perps_trading_campaign.leaderboard_hypertracker_brand', + )} + + + + ); +}; + +export default PerpsTradingCampaignLeaderboard; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.test.tsx new file mode 100644 index 00000000000..360a3b52187 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.test.tsx @@ -0,0 +1,271 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsTradingCampaignPrizePool, { + PERPS_PRIZE_POOL_TEST_IDS, +} from './PerpsTradingCampaignPrizePool'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + return { ...actual }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + title, + onConfirm, + confirmButtonLabel, + testID, + }: { + title: string; + description: string; + onConfirm?: () => void; + confirmButtonLabel?: string; + testID?: string; + }) => + ReactActual.createElement( + View, + { testID }, + ReactActual.createElement(Text, null, title), + confirmButtonLabel && + ReactActual.createElement( + Pressable, + { onPress: onConfirm, testID: `${testID}-retry` }, + ReactActual.createElement(Text, null, confirmButtonLabel), + ), + ), + }; +}); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string, params?: Record) => { + const t: Record = { + 'rewards.perps_trading_campaign.prize_pool_error_title': + 'Prize pool unavailable', + 'rewards.perps_trading_campaign.prize_pool_error_description': + 'Could not load prize pool.', + 'rewards.perps_trading_campaign.prize_pool_retry_button': 'Retry', + 'rewards.perps_trading_campaign.prize_pool_current_label': 'Current', + 'rewards.perps_trading_campaign.prize_pool_next_label': 'Next', + 'rewards.perps_trading_campaign.prize_pool_volume_subtext': + '{{current}} of {{target}} volume', + 'rewards.perps_trading_campaign.prize_pool_max_tier_subtext': + '{{maxThreshold}}+ volume — all milestones reached', + 'rewards.perps_trading_campaign.prize_pool_max_badge': 'Max prize', + }; + let result = t[key] ?? key; + if (params) { + Object.entries(params).forEach(([k, v]) => { + result = result.replace(`{{${k}}}`, v); + }); + } + return result; + }, + default: { locale: 'en-US' }, +})); + +jest.mock('../../utils/formatUtils', () => ({ + formatUsd: (value: string | number) => + `$${Number(value).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`, + formatCompactUsd: (value: number) => { + if (value >= 1_000_000) { + return `$${(value / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + } + if (value >= 1_000) { + return `$${(value / 1_000).toFixed(0)}K`; + } + return `$${value}`; + }, +})); + +const mockRefetch = jest.fn(); + +const baseProps = { + totalNotionalVolume: '7500000' as string | null, + isLoading: false, + hasError: false, + refetch: mockRefetch, +}; + +describe('PerpsTradingCampaignPrizePool', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders container, progress bar, and subtext when data is provided', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.CONTAINER)).toBeDefined(); + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR)).toBeDefined(); + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.SUBTEXT)).toBeDefined(); + }); + + it('shows current and next prize between $5M and $10M notional', () => { + const { getByText } = render( + , + ); + + expect(getByText('$15,000.00')).toBeDefined(); + expect(getByText('$20,000.00')).toBeDefined(); + }); + + it('computes 50% progress halfway between $5M and $10M volume', () => { + const { getByTestId } = render( + , + ); + + const progressBar = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR); + const innerBar = progressBar.props.children; + expect(innerBar.props.style).toEqual({ width: '50%' }); + }); + + it('shows max badge and full progress at $40M notional (top tier)', () => { + const { getByTestId, getByText, queryByText } = render( + , + ); + + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.MAX_BADGE)).toBeDefined(); + expect(getByText('Max prize')).toBeDefined(); + expect(getByText('$50,000.00')).toBeDefined(); + expect(queryByText('Next')).toBeNull(); + + const progressBar = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR); + const innerBar = progressBar.props.children; + expect(innerBar.props.style).toEqual({ width: '100%' }); + + const subtext = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.SUBTEXT); + expect(subtext.props.children).toBe( + '$40M+ volume — all milestones reached', + ); + }); + + it('does not show max badge below top tier', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId(PERPS_PRIZE_POOL_TEST_IDS.MAX_BADGE)).toBeNull(); + }); + + it('with null volume and not loading, shows first-tier defaults ($10k → $15k)', () => { + const { getByText, getByTestId } = render( + , + ); + + expect(getByText('$10,000.00')).toBeDefined(); + expect(getByText('$15,000.00')).toBeDefined(); + const progressBar = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR); + const innerBar = progressBar.props.children; + expect(innerBar.props.style).toEqual({ width: '0%' }); + }); + + it('with zero notional string uses first milestone segment (0% in range to $5M)', () => { + const { getByTestId } = render( + , + ); + + const progressBar = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR); + const innerBar = progressBar.props.children; + expect(innerBar.props.style).toEqual({ width: '0%' }); + }); + + it('shows skeleton when loading with no volume data', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.CONTAINER)).toBeDefined(); + expect(queryByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR)).toBeNull(); + expect(queryByTestId(PERPS_PRIZE_POOL_TEST_IDS.SUBTEXT)).toBeNull(); + }); + + it('shows stale content when loading but volume already exists', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR)).toBeDefined(); + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.SUBTEXT)).toBeDefined(); + }); + + it('shows error banner when hasError and no volume data', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.ERROR_BANNER)).toBeDefined(); + expect(queryByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR)).toBeNull(); + }); + + it('hides error banner when hasError but stale volume exists', () => { + const { queryByTestId, getByTestId } = render( + , + ); + + expect(queryByTestId(PERPS_PRIZE_POOL_TEST_IDS.ERROR_BANNER)).toBeNull(); + expect(getByTestId(PERPS_PRIZE_POOL_TEST_IDS.PROGRESS_BAR)).toBeDefined(); + }); + + it('calls refetch when error retry is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press( + getByTestId(`${PERPS_PRIZE_POOL_TEST_IDS.ERROR_BANNER}-retry`), + ); + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + + it('renders volume subtext with compact amounts', () => { + const { getByTestId } = render( + , + ); + + const subtext = getByTestId(PERPS_PRIZE_POOL_TEST_IDS.SUBTEXT); + expect(subtext.props.children).toBe('$7.5M of $10M volume'); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.tsx new file mode 100644 index 00000000000..c639cd74baa --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignPrizePool.tsx @@ -0,0 +1,196 @@ +import React, { useMemo } from 'react'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + FontWeight, + Skeleton, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import RewardsErrorBanner from '../RewardsErrorBanner'; +import { formatCompactUsd, formatUsd } from '../../utils/formatUtils'; +import { computePrizePoolProgress } from '../../utils/prizePoolUtils'; +import { strings } from '../../../../../../locales/i18n'; + +export const PERPS_PRIZE_POOL_TEST_IDS = { + CONTAINER: 'perps-prize-pool-container', + PROGRESS_BAR: 'perps-prize-pool-progress-bar', + MAX_BADGE: 'perps-prize-pool-max-badge', + SUBTEXT: 'perps-prize-pool-subtext', + ERROR_BANNER: 'perps-prize-pool-error-banner', +} as const; + +// $10k base prize, scales by $5k per $5M notional volume, up to $50k at $40M +export const PERPS_PRIZE_POOL_MILESTONES = [ + { notionalVolume: 0, prize: 10_000 }, + { notionalVolume: 5_000_000, prize: 15_000 }, + { notionalVolume: 10_000_000, prize: 20_000 }, + { notionalVolume: 15_000_000, prize: 25_000 }, + { notionalVolume: 20_000_000, prize: 30_000 }, + { notionalVolume: 25_000_000, prize: 35_000 }, + { notionalVolume: 30_000_000, prize: 40_000 }, + { notionalVolume: 35_000_000, prize: 45_000 }, + { notionalVolume: 40_000_000, prize: 50_000 }, +] as const; + +interface PerpsTradingCampaignPrizePoolProps { + totalNotionalVolume: string | null; + isLoading: boolean; + hasError: boolean; + refetch: () => void; +} + +const PerpsTradingCampaignPrizePool: React.FC< + PerpsTradingCampaignPrizePoolProps +> = ({ totalNotionalVolume, isLoading, hasError, refetch }) => { + const tw = useTailwind(); + + const showSkeleton = isLoading && !totalNotionalVolume; + const showError = hasError && !totalNotionalVolume; + + const { progress, currentPrize, nextPrize, nextThreshold, isMaxTier } = + useMemo(() => { + if (!totalNotionalVolume) { + return { + progress: 0, + currentPrize: PERPS_PRIZE_POOL_MILESTONES[0].prize, + nextPrize: PERPS_PRIZE_POOL_MILESTONES[1].prize as number | null, + nextThreshold: PERPS_PRIZE_POOL_MILESTONES[1].notionalVolume, + isMaxTier: false, + }; + } + return computePrizePoolProgress( + PERPS_PRIZE_POOL_MILESTONES, + parseFloat(totalNotionalVolume), + (m) => m.notionalVolume, + ); + }, [totalNotionalVolume]); + + const progressPercent: `${number}%` = `${Math.round(progress * 100)}%`; + const currentVolume = totalNotionalVolume + ? parseFloat(totalNotionalVolume) + : 0; + + if (showError) { + return ( + + + + ); + } + + if (showSkeleton) { + return ( + + + + + ); + } + + return ( + + + + + {strings('rewards.perps_trading_campaign.prize_pool_current_label')} + + + {formatUsd(currentPrize)} + + + {isMaxTier ? ( + + + + {strings('rewards.perps_trading_campaign.prize_pool_max_badge')} + + + + ) : nextPrize !== null ? ( + + + {strings('rewards.perps_trading_campaign.prize_pool_next_label')} + + + {formatUsd(nextPrize)} + + + ) : null} + + + + + + + + {isMaxTier + ? strings( + 'rewards.perps_trading_campaign.prize_pool_max_tier_subtext', + { + maxThreshold: formatCompactUsd(nextThreshold), + }, + ) + : strings( + 'rewards.perps_trading_campaign.prize_pool_volume_subtext', + { + current: formatCompactUsd(currentVolume), + target: formatCompactUsd(nextThreshold), + }, + )} + + + ); +}; + +export default PerpsTradingCampaignPrizePool; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx new file mode 100644 index 00000000000..2894eaf9e28 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx @@ -0,0 +1,172 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { TextColor } from '@metamask/design-system-react-native'; +import PerpsTradingCampaignStatsHeader, { + PERPS_STATS_HEADER_TEST_IDS, +} from './PerpsTradingCampaignStatsHeader'; +import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + return { + ...actual, + Text: (props: Record) => + ReactActual.createElement(RN.Text, props, props.children), + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +jest.mock('../../utils/formatUtils', () => { + const fmt = (n: number) => + n.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + return { + formatSignedUsd: (value: number) => { + if (value < 0) { + return `-$${fmt(Math.abs(value))}`; + } + if (value > 0) { + return `+$${fmt(value)}`; + } + return '$0.00'; + }, + formatRewardsTimeOnly: () => 'time-stub', + }; +}); + +const TEST_IDS = PERPS_STATS_HEADER_TEST_IDS; + +const basePosition: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 7, + pnl: 1500.25, + notionalVolume: 30_000, + marginDeployed: 2000, + qualified: true, + neighbors: [], + computedAt: '2025-01-01T00:00:00.000Z', +}; + +describe('PerpsTradingCampaignStatsHeader', () => { + it('renders container and your-rank label', () => { + const { getByTestId, getByText } = render( + , + ); + expect(getByTestId(TEST_IDS.CONTAINER)).toBeDefined(); + expect( + getByText('rewards.perps_trading_campaign.label_your_rank'), + ).toBeDefined(); + }); + + it('shows padded rank, positive PnL with success color, and qualified icon when qualified', () => { + const { getByTestId, getByText, queryByTestId } = render( + , + ); + const rank = getByTestId(TEST_IDS.RANK_VALUE); + expect(rank.props.children).toBe('07'); + const pnl = getByTestId(TEST_IDS.PNL_VALUE); + expect(pnl.props.color).toBe(TextColor.SuccessDefault); + expect(getByText('+$1,500.25', { exact: true })).toBeDefined(); + expect(getByTestId(TEST_IDS.QUALIFIED_ICON)).toBeDefined(); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + }); + + it('uses error color and minus sign in display for negative PnL', () => { + const { getByTestId } = render( + , + ); + const pnl = getByTestId(TEST_IDS.PNL_VALUE); + expect(pnl.props.color).toBe(TextColor.ErrorDefault); + }); + + it('shows pending tag and no qualified icon when not qualified', () => { + const { getByTestId, queryByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.PENDING_TAG)).toBeDefined(); + expect(queryByTestId(TEST_IDS.QUALIFIED_ICON)).toBeNull(); + }); + + it('hides the pending tag when the campaign is complete', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + }); + + it('shows em dashes for rank and PnL when position is null', () => { + const { getByTestId } = render( + , + ); + const rank = getByTestId(TEST_IDS.RANK_VALUE); + const pnl = getByTestId(TEST_IDS.PNL_VALUE); + expect(rank.props.children).toBe('—'); + expect(pnl.props.children).toBe('—'); + expect(pnl.props.color).toBe(TextColor.TextDefault); + }); + + it('hides PnL and computed-at subtext when showPnl and showComputedAt are false', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.PNL_VALUE)).toBeNull(); + expect(queryByTestId(TEST_IDS.COMPUTED_AT)).toBeNull(); + }); + + it('shows computed-at line when showComputedAt is true and position has a timestamp', () => { + const { getByTestId } = render( + , + ); + const computed = getByTestId(TEST_IDS.COMPUTED_AT); + expect(computed.props.children).toBe( + 'rewards.perps_trading_campaign.last_updated', + ); + }); + + it('omits computed-at when formatted label is empty', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.COMPUTED_AT)).toBeNull(); + }); + + it('skips rank and PnL testIDs and shows loading skeletons when isLoading is true', () => { + const { queryByTestId, getByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.CONTAINER)).toBeDefined(); + expect(queryByTestId(TEST_IDS.RANK_VALUE)).toBeNull(); + expect(queryByTestId(TEST_IDS.PNL_VALUE)).toBeNull(); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx new file mode 100644 index 00000000000..31d3623c69b --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Icon, + IconColor, + IconName, + IconSize, + Skeleton, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { PendingTag } from './OndoCampaignStatsSummary'; +import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../../locales/i18n'; +import { + formatRewardsTimeOnly, + formatSignedUsd, +} from '../../utils/formatUtils'; + +export const PERPS_STATS_HEADER_TEST_IDS = { + CONTAINER: 'perps-stats-header-container', + RANK_VALUE: 'perps-stats-header-rank', + PNL_VALUE: 'perps-stats-header-pnl', + COMPUTED_AT: 'perps-stats-header-computed-at', + PENDING_TAG: 'perps-stats-header-pending-tag', + QUALIFIED_ICON: 'perps-stats-header-qualified-icon', +} as const; + +interface PerpsTradingCampaignStatsHeaderProps { + position: PerpsTradingCampaignLeaderboardPositionDto | null; + isLoading?: boolean; + /** When true, shows PnL under the rank in BodySm (same pattern as return in LeaderboardPositionHeader). */ + showPnl?: boolean; + /** When true, shows formatted `computedAt` time on the same row as PnL, right-aligned in alternative text color. */ + showComputedAt?: boolean; + /** When true, suppresses the "Pending" tag — qualification is locked once the campaign ends. */ + isCampaignComplete?: boolean; +} + +const PerpsTradingCampaignStatsHeader: React.FC< + PerpsTradingCampaignStatsHeaderProps +> = ({ + position, + isLoading = false, + showPnl = true, + showComputedAt = true, + isCampaignComplete = false, +}) => { + const tw = useTailwind(); + + const isPending = position != null && !position.qualified; + const isQualified = position != null && position.qualified; + + const rankValue = position ? String(position.rank).padStart(2, '0') : '—'; + const pnlValue = position ? formatSignedUsd(position.pnl) : '—'; + const pnlColor = position + ? position.pnl >= 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault + : TextColor.TextDefault; + + const computedAtLabel = position?.computedAt + ? strings('rewards.perps_trading_campaign.last_updated', { + time: formatRewardsTimeOnly(new Date(position.computedAt)), + }) + : ''; + + const showSubtextRow = showPnl || showComputedAt; + + return ( + + + + + {strings('rewards.perps_trading_campaign.label_your_rank')} + + {isPending && !isCampaignComplete && ( + + )} + {isQualified && ( + + )} + + + {isLoading ? ( + <> + + {showSubtextRow && ( + + )} + + ) : ( + <> + + {rankValue} + + {showSubtextRow && ( + + + {showPnl && ( + + {pnlValue} + + )} + + {showComputedAt && computedAtLabel.length > 0 && ( + + {computedAtLabel} + + )} + + )} + + )} + + + ); +}; + +export default PerpsTradingCampaignStatsHeader; diff --git a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx index c4115b1d46f..147dce239cb 100644 --- a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx +++ b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.test.tsx @@ -146,6 +146,7 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ // Mock design system components jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); const ReactActual = jest.requireActual('react'); const { View, Text, TouchableOpacity } = jest.requireActual('react-native'); @@ -210,6 +211,7 @@ jest.mock('@metamask/design-system-react-native', () => { ); return { + ...actual, Box, Text: TextComponent, Button, @@ -274,40 +276,6 @@ jest.mock( }, ); -// Mock HeaderCompactStandard -jest.mock( - '../../../../../component-library/components-temp/HeaderCompactStandard', - () => { - const ReactActual = jest.requireActual('react'); - const { View, Text, TouchableOpacity } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - title, - onClose, - closeButtonProps, - }: { - title?: React.ReactNode; - onClose?: () => void; - closeButtonProps?: { testID?: string }; - }) => - ReactActual.createElement( - View, - { testID: 'bottom-sheet-header' }, - ReactActual.createElement(Text, {}, title), - ReactActual.createElement( - TouchableOpacity, - { - onPress: onClose, - testID: closeButtonProps?.testID ?? 'close-button', - }, - ReactActual.createElement(Text, {}, 'Close'), - ), - ), - }; - }, -); - // Mock TextField jest.mock('../../../../../component-library/components/Form/TextField', () => { const ReactActual = jest.requireActual('react'); diff --git a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.tsx b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.tsx index ed073861460..063c5979e08 100644 --- a/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.tsx +++ b/app/components/UI/Rewards/components/EndOfSeasonClaimBottomSheet/EndOfSeasonClaimBottomSheet.tsx @@ -26,6 +26,7 @@ import { Text, TextVariant, FontWeight, + HeaderStandard, Icon, IconName, IconSize, @@ -33,7 +34,6 @@ import { } from '@metamask/design-system-react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; import { REWARDS_VIEW_SELECTORS } from '../../Views/RewardsView.constants'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import { ClaimRewardDto, SeasonRewardType, @@ -513,7 +513,7 @@ const EndOfSeasonClaimBottomSheet = ({ testID={REWARDS_VIEW_SELECTORS.CLAIM_MODAL} keyboardAvoidingViewEnabled={!needsKeyboardAvoiding} > - { = 6 && !referralCodeIsValid && !isValidatingReferralCode && !isUnknownErrorReferralCode } + inputProps={{ + autoCapitalize: 'characters', + maxLength: 6, + testID: 'referral-input', + }} /> {referralCode.length >= 6 && !referralCodeIsValid && diff --git a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingMainStep.test.tsx b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingMainStep.test.tsx index 20d9dd57cea..041baf3da4c 100644 --- a/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingMainStep.test.tsx +++ b/app/components/UI/Rewards/components/Onboarding/__tests__/OnboardingMainStep.test.tsx @@ -210,14 +210,17 @@ jest.mock('@metamask/design-system-react-native', () => { onChangeText?: (text: string) => void; placeholder?: string; isDisabled?: boolean; + inputProps?: { testID?: string; [key: string]: unknown }; [key: string]: unknown; }) => { const { TextInput: RNTextInput } = jest.requireActual('react-native'); + const wrapperTestId = props.testID ?? 'text-field'; + const inputTestId = props.inputProps?.testID ?? `${wrapperTestId}-input`; return ReactActual.createElement( View, - { testID: props.testID || 'text-field' }, + { testID: wrapperTestId }, ReactActual.createElement(RNTextInput, { - testID: `${props.testID || 'text-field'}-input`, + testID: inputTestId, value: props.value, onChangeText: props.onChangeText, placeholder: props.placeholder, diff --git a/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx b/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx index 7023d15b341..6bf5b7a0cc9 100644 --- a/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx +++ b/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx @@ -14,7 +14,7 @@ import { import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; interface CopyableFieldProps { - label: string; + label?: string; value?: string | null; onCopy?: () => void; valueLoading?: boolean; @@ -40,9 +40,11 @@ const CopyableField: React.FC = ({ return ( - - {label} - + {label && ( + + {label} + + )} { - const ReactActual = jest.requireActual('react'); - const { View, Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ title }: { title?: string; onClose?: () => void }) => - ReactActual.createElement( - View, - { testID: 'header-compact-standard' }, - ReactActual.createElement(Text, null, title), - ), - }; - }, -); - // Mock Avatar jest.mock('../../../../../component-library/components/Avatars/Avatar', () => { const ReactActual = jest.requireActual('react'); @@ -79,6 +61,7 @@ jest.mock('../../../../../component-library/components/Avatars/Avatar', () => { // Mock design system components jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); const ReactActual = jest.requireActual('react'); const { View, @@ -86,6 +69,7 @@ jest.mock('@metamask/design-system-react-native', () => { TouchableOpacity, } = jest.requireActual('react-native'); return { + ...actual, Box: ({ children, testID, diff --git a/app/components/UI/Rewards/components/Settings/LinkedOffDeviceAccountsSheet.tsx b/app/components/UI/Rewards/components/Settings/LinkedOffDeviceAccountsSheet.tsx index 2e8a29b63f4..7558ae19b2e 100644 --- a/app/components/UI/Rewards/components/Settings/LinkedOffDeviceAccountsSheet.tsx +++ b/app/components/UI/Rewards/components/Settings/LinkedOffDeviceAccountsSheet.tsx @@ -9,6 +9,7 @@ import { BoxFlexDirection, BoxAlignItems, ButtonIcon, + HeaderStandard, IconName, IconSize, FontWeight, @@ -27,7 +28,6 @@ import { } from '../../../../../util/networks'; import ClipboardManager from '../../../../../core/ClipboardManager'; import type { OffDeviceAccount } from '../../hooks/useLinkedOffDeviceAccounts'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import { METAMASK_SUPPORT_URL } from '../../../../../constants/urls'; const styles = StyleSheet.create({ @@ -147,7 +147,8 @@ const LinkedOffDeviceAccountsSheet: React.FC< return ( - diff --git a/app/components/UI/Rewards/components/Settings/RewardOptInAccountGroupModal.tsx b/app/components/UI/Rewards/components/Settings/RewardOptInAccountGroupModal.tsx index 11fb5231277..5e0d323829a 100644 --- a/app/components/UI/Rewards/components/Settings/RewardOptInAccountGroupModal.tsx +++ b/app/components/UI/Rewards/components/Settings/RewardOptInAccountGroupModal.tsx @@ -10,11 +10,11 @@ import { Button, ButtonVariant, ButtonSize, + HeaderStandard, } from '@metamask/design-system-react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import { AccountGroupId } from '@metamask/account-api'; import { selectAccountGroupById } from '../../../../../selectors/multichainAccounts/accountTreeController'; import { useSelector } from 'react-redux'; @@ -373,7 +373,7 @@ const RewardOptInAccountGroupModal: React.FC = () => { onClose={handleDismiss} > {Boolean(accountGroupContext?.metadata?.name) && ( - sheetRef.current?.onCloseBottomSheet()} closeButtonProps={{ testID: 'header-close-button' }} diff --git a/app/components/UI/Rewards/components/Settings/RewardsEnvironmentToggle.tsx b/app/components/UI/Rewards/components/Settings/RewardsEnvironmentToggle.tsx index 5b097ccc8da..47168f818ea 100644 --- a/app/components/UI/Rewards/components/Settings/RewardsEnvironmentToggle.tsx +++ b/app/components/UI/Rewards/components/Settings/RewardsEnvironmentToggle.tsx @@ -7,6 +7,7 @@ import { Button, ButtonSize, ButtonVariant, + HeaderStandard, Text, TextColor, TextVariant, @@ -18,7 +19,6 @@ import AppConstants from '../../../../../core/AppConstants'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import ListItemSelect from '../../../../../component-library/components/List/ListItemSelect'; import { VerticalAlignment } from '../../../../../component-library/components/List/ListItem'; import { cancelBulkLink } from '../../../../../store/sagas/rewardsBulkLinkAccountGroups'; @@ -118,7 +118,7 @@ const RewardsEnvironmentToggle: React.FC = () => { ref={sheetRef} onClose={() => setIsSheetOpen(false)} > - sheetRef.current?.onCloseBottomSheet()} closeButtonProps={{ testID: 'environment-sheet-close-button' }} diff --git a/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.test.ts b/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.test.ts new file mode 100644 index 00000000000..3c7982808f0 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.test.ts @@ -0,0 +1,508 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useContext } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import { playNotification, NotificationMoment } from '../../../../util/haptics'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; +import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards'; +import { + selectCampaigns, + selectDismissedCampaignOutcomeToasts, +} from '../../../../reducers/rewards/selectors'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { + CampaignType, + type BaseCampaignParticipantOutcomeDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +const ToastVariants = { Icon: 'Icon', App: 'App', Plain: 'Plain' }; +const ButtonIconVariant = { Icon: 'Icon' }; +const IconName = { Close: 'Close', Confirmation: 'Confirmation', Star: 'Star' }; + +jest.mock('../../../../component-library/components/Toast', () => ({ + ToastContext: { Consumer: jest.fn(), Provider: jest.fn() }, +})); + +jest.mock('../../../../component-library/components/Toast/Toast.types', () => ({ + ToastVariants: { Icon: 'Icon', App: 'App', Plain: 'Plain' }, + ButtonIconVariant: { Icon: 'Icon' }, +})); + +jest.mock('../../../../component-library/components/Icons/Icon', () => ({ + IconName: { Close: 'Close', Confirmation: 'Confirmation', Star: 'Star' }, +})); + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn(), + useCallback: jest.fn((fn) => fn), + useMemo: jest.fn((fn) => fn()), +})); + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), + useSelector: jest.fn(), +})); + +jest.mock('@react-navigation/native', () => ({ + useFocusEffect: jest.fn(), + useNavigation: jest.fn(), +})); + +jest.mock('../../../../util/haptics', () => ({ + playNotification: jest.fn(), + NotificationMoment: { + Success: 'Success', + Warning: 'Warning', + Error: 'Error', + }, +})); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, params?: Record) => { + if (params?.campaignName) return `${key}:${params.campaignName}`; + return key; + }), +})); + +jest.mock('../../../../util/theme', () => { + const actual = jest.requireActual('../../../../util/theme'); + return { + ...actual, + useAppThemeFromContext: () => actual.mockTheme, + }; +}); + +jest.mock('../../../../reducers/rewards', () => ({ + dismissCampaignOutcomeToast: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaigns: jest.fn(), + selectDismissedCampaignOutcomeToasts: jest.fn(), +})); + +jest.mock('../../../../selectors/rewards', () => ({ + selectRewardsSubscriptionId: jest.fn(), +})); + +jest.mock('../components/Campaigns/CampaignTile.utils', () => ({ + getCampaignStatus: jest.fn(() => 'complete'), +})); + +const mockDispatch = jest.fn(); +const mockNavigate = jest.fn(); +const mockShowToast = jest.fn(); +const mockCloseToast = jest.fn(); +const mockToastRef = { + current: { showToast: mockShowToast, closeToast: mockCloseToast }, +}; + +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseFocusEffect = useFocusEffect as jest.MockedFunction< + typeof useFocusEffect +>; +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockDismissCampaignOutcomeToast = + dismissCampaignOutcomeToast as jest.MockedFunction< + typeof dismissCampaignOutcomeToast + >; + +const SUBSCRIPTION_ID = 'sub-123'; +const CAMPAIGN_ID = 'campaign-456'; +const CAMPAIGN_NAME = 'Test Campaign'; + +const mockUseOutcome = jest.fn(); + +const makeCompletedCampaign = (id = CAMPAIGN_ID, endDate = '2025-01-01') => ({ + id, + name: CAMPAIGN_NAME, + type: CampaignType.ONDO_HOLDING, + endDate, + startDate: '2024-01-01', +}); + +const WINNER_NAV = { + route: 'WinnerRoute', + params: { campaignId: CAMPAIGN_ID }, +}; +const NON_WINNER_NAV = { + route: 'NonWinnerRoute', + params: { campaignId: CAMPAIGN_ID }, +}; + +const mockConfig = { + campaignType: CampaignType.ONDO_HOLDING, + useOutcome: mockUseOutcome, + getWinnerNavigation: jest.fn(() => WINNER_NAV), + getNonWinnerNavigation: jest.fn(() => NON_WINNER_NAV), +}; + +function setupDefaults({ + campaigns = [makeCompletedCampaign()], + dismissed = {}, + subscriptionId = SUBSCRIPTION_ID, + outcome = null, +}: { + campaigns?: ReturnType[] | undefined; + dismissed?: Record; + subscriptionId?: string | null; + outcome?: BaseCampaignParticipantOutcomeDto | null; +} = {}) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectCampaigns) return campaigns ?? []; + if (selector === selectDismissedCampaignOutcomeToasts) return dismissed; + if (selector === selectRewardsSubscriptionId) return subscriptionId; + return undefined; + }); + mockUseOutcome.mockReturnValue({ + outcome, + isLoading: false, + hasError: false, + }); +} + +describe('useCampaignOutcomeToast', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + mockUseNavigation.mockReturnValue({ navigate: mockNavigate } as never); + (useContext as jest.Mock).mockReturnValue({ toastRef: mockToastRef }); + mockUseFocusEffect.mockImplementation((cb) => { + cb(); + }); + mockDismissCampaignOutcomeToast.mockReturnValue({ + type: 'rewards/dismissCampaignOutcomeToast', + } as never); + mockConfig.getWinnerNavigation.mockReturnValue(WINNER_NAV); + mockConfig.getNonWinnerNavigation.mockReturnValue(NON_WINNER_NAV); + }); + + describe('does not show toast when', () => { + it('outcome is null', () => { + setupDefaults({ outcome: null }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('no campaigns match the campaignType', () => { + setupDefaults({ campaigns: [] }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('campaigns are missing from persisted state', () => { + setupDefaults({ campaigns: undefined }); + expect(() => + renderHook(() => useCampaignOutcomeToast(mockConfig)), + ).not.toThrow(); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('subscriptionId is missing', () => { + setupDefaults({ + subscriptionId: null, + outcome: { + subscriptionId: '', + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('winner toast was already dismissed', () => { + const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:winner`; + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + dismissed: { [key]: true }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('non_winner toast was already dismissed', () => { + const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:non_winner`; + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + dismissed: { [key]: true }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('outcome is finalized with a verification code (neither variant)', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + }); + + describe('winner toast', () => { + const winnerOutcome: BaseCampaignParticipantOutcomeDto = { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'WINNER-XYZ', + }; + + it('shows Plain variant toast with trophy startAccessory', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Plain, + hasNoTimeout: true, + startAccessory: expect.anything(), + }), + ); + }); + + it('uses consolidated winner locale keys', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + labelOptions: [ + { + label: 'rewards.campaign_outcome_toast.winner.title', + isBold: true, + }, + ], + descriptionOptions: { + description: `rewards.campaign_outcome_toast.winner.description:${CAMPAIGN_NAME}`, + }, + linkButtonOptions: expect.objectContaining({ + label: 'rewards.campaign_outcome_toast.winner.cta', + }), + }), + ); + }); + + it('shows close button with correct config', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + closeButtonOptions: expect.objectContaining({ + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + }), + }), + ); + }); + + it('fires success haptic via playNotification', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(playNotification).toHaveBeenCalledWith(NotificationMoment.Success); + }); + }); + + describe('non-winner toast', () => { + const nonWinnerOutcome: BaseCampaignParticipantOutcomeDto = { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }; + + it('shows Icon variant toast with Confirmation icon', () => { + setupDefaults({ outcome: nonWinnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Icon, + iconName: IconName.Confirmation, + backgroundColor: 'transparent', + hasNoTimeout: true, + }), + ); + }); + + it('uses consolidated non_winner locale keys', () => { + setupDefaults({ outcome: nonWinnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + labelOptions: [ + { + label: 'rewards.campaign_outcome_toast.non_winner.title', + isBold: true, + }, + ], + descriptionOptions: { + description: `rewards.campaign_outcome_toast.non_winner.description:${CAMPAIGN_NAME}`, + }, + linkButtonOptions: expect.objectContaining({ + label: 'rewards.campaign_outcome_toast.non_winner.cta', + }), + }), + ); + }); + + it('fires warning haptic via playNotification', () => { + setupDefaults({ outcome: nonWinnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(playNotification).toHaveBeenCalledWith(NotificationMoment.Warning); + }); + }); + + describe('handleDismiss', () => { + it('dispatches dismissCampaignOutcomeToast with variant winner and closes toast', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const closeButtonOptions = + mockShowToast.mock.calls[0][0].closeButtonOptions; + closeButtonOptions.onPress(); + + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ + campaignId: CAMPAIGN_ID, + subscriptionId: SUBSCRIPTION_ID, + variant: 'winner', + }); + expect(mockCloseToast).toHaveBeenCalled(); + }); + + it('dispatches dismissCampaignOutcomeToast with variant non_winner', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const closeButtonOptions = + mockShowToast.mock.calls[0][0].closeButtonOptions; + closeButtonOptions.onPress(); + + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ + campaignId: CAMPAIGN_ID, + subscriptionId: SUBSCRIPTION_ID, + variant: 'non_winner', + }); + }); + }); + + describe('handleCta', () => { + it('navigates to winner route and dismisses for winner variant', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const { onPress } = mockShowToast.mock.calls[0][0].linkButtonOptions; + onPress(); + + expect(mockNavigate).toHaveBeenCalledWith( + WINNER_NAV.route as never, + WINNER_NAV.params as never, + ); + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'winner' }), + ); + }); + + it('navigates to non-winner route and dismisses for non_winner variant', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const { onPress } = mockShowToast.mock.calls[0][0].linkButtonOptions; + onPress(); + + expect(mockNavigate).toHaveBeenCalledWith( + NON_WINNER_NAV.route as never, + NON_WINNER_NAV.params as never, + ); + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'non_winner' }), + ); + }); + }); + + describe('cleanup on blur', () => { + it('calls closeToast in cleanup when screen blurs', () => { + let cleanupFn: (() => void) | undefined; + mockUseFocusEffect.mockImplementation((cb) => { + const cleanup = cb(); + if (typeof cleanup === 'function') cleanupFn = cleanup; + }); + + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + expect(cleanupFn).toBeDefined(); + cleanupFn?.(); + expect(mockCloseToast).toHaveBeenCalled(); + }); + + it('does not return cleanup when variant is null', () => { + let cleanupFn: (() => void) | undefined; + mockUseFocusEffect.mockImplementation((cb) => { + const result = cb(); + if (typeof result === 'function') cleanupFn = result; + }); + + setupDefaults({ outcome: null }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + expect(cleanupFn).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('handles null toastRef gracefully', () => { + (useContext as jest.Mock).mockReturnValue({ toastRef: null }); + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + expect(() => + renderHook(() => useCampaignOutcomeToast(mockConfig)), + ).not.toThrow(); + }); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts b/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts new file mode 100644 index 00000000000..4ea89f6df6e --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts @@ -0,0 +1,174 @@ +import { useCallback, useContext, useMemo } from 'react'; +import { + useFocusEffect, + useNavigation, + type NavigationProp, + type ParamListBase, +} from '@react-navigation/native'; +import { useDispatch, useSelector } from 'react-redux'; +import { strings } from '../../../../../locales/i18n'; +import { ToastContext } from '../../../../component-library/components/Toast'; +import type { + BaseCampaignParticipantOutcomeDto, + CampaignType, + CampaignDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; +import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards'; +import { + selectCampaigns, + selectDismissedCampaignOutcomeToasts, +} from '../../../../reducers/rewards/selectors'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import useRewardsToast from './useRewardsToast'; + +export interface CampaignOutcomeToastConfig { + campaignType: CampaignType; + useOutcome: (id: string | undefined) => { + outcome: BaseCampaignParticipantOutcomeDto | null; + }; + getWinnerNavigation: (campaign: CampaignDto) => { + route: string; + params: object; + }; + getNonWinnerNavigation: (campaign: CampaignDto) => { + route: string; + params: object; + }; +} + +export function useCampaignOutcomeToast( + config: CampaignOutcomeToastConfig, +): void { + const { + campaignType, + useOutcome, + getWinnerNavigation, + getNonWinnerNavigation, + } = config; + + const dispatch = useDispatch(); + const { toastRef } = useContext(ToastContext); + const { showToast, RewardsToastOptions } = useRewardsToast(); + const navigation = useNavigation>(); + + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const campaigns = useSelector(selectCampaigns); + const dismissed = useSelector(selectDismissedCampaignOutcomeToasts); + + const targetCampaign = useMemo(() => { + const completed = campaigns + .filter( + (c) => c.type === campaignType && getCampaignStatus(c) === 'complete', + ) + .sort( + (a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime(), + ); + return completed[0] ?? null; + }, [campaigns, campaignType]); + + const { outcome } = useOutcome(targetCampaign?.id); + + // Standardized variant derivation: winner = has code and not yet finalized + const variant = useMemo((): 'winner' | 'non_winner' | null => { + if (!outcome) return null; + if ( + outcome.winnerVerificationCode && + outcome.outcomeStatus !== 'finalized' + ) { + return 'winner'; + } + if ( + outcome.outcomeStatus === 'finalized' && + !outcome.winnerVerificationCode + ) { + return 'non_winner'; + } + return null; + }, [outcome]); + + const isDismissed = useMemo(() => { + if (!variant || !targetCampaign || !subscriptionId) return true; + const key = `${targetCampaign.id}:${subscriptionId}:${variant}`; + return dismissed[key] === true; + }, [variant, targetCampaign, subscriptionId, dismissed]); + + const handleDismiss = useCallback(() => { + if (!variant || !targetCampaign || !subscriptionId) return; + dispatch( + dismissCampaignOutcomeToast({ + campaignId: targetCampaign.id, + subscriptionId, + variant, + }), + ); + toastRef?.current?.closeToast(); + }, [variant, targetCampaign, subscriptionId, dispatch, toastRef]); + + const handleCta = useCallback(() => { + if (!targetCampaign || !variant) return; + handleDismiss(); + const nav = + variant === 'winner' + ? getWinnerNavigation(targetCampaign) + : getNonWinnerNavigation(targetCampaign); + navigation.navigate(nav.route, nav.params); + }, [ + variant, + targetCampaign, + handleDismiss, + navigation, + getWinnerNavigation, + getNonWinnerNavigation, + ]); + + useFocusEffect( + useCallback(() => { + if (!variant || isDismissed || !targetCampaign) return; + + const isWinner = variant === 'winner'; + if (isWinner) { + showToast( + RewardsToastOptions.outcomeWinner({ + title: strings('rewards.campaign_outcome_toast.winner.title'), + description: strings( + 'rewards.campaign_outcome_toast.winner.description', + { campaignName: targetCampaign.name ?? '' }, + ), + ctaLabel: strings('rewards.campaign_outcome_toast.winner.cta'), + onCtaPress: handleCta, + onClosePress: handleDismiss, + }), + ); + } else { + showToast( + RewardsToastOptions.outcomeNonWinner({ + title: strings('rewards.campaign_outcome_toast.non_winner.title'), + description: strings( + 'rewards.campaign_outcome_toast.non_winner.description', + { campaignName: targetCampaign.name ?? '' }, + ), + ctaLabel: strings('rewards.campaign_outcome_toast.non_winner.cta'), + onCtaPress: handleCta, + onClosePress: handleDismiss, + }), + ); + } + + return () => { + toastRef?.current?.closeToast(); + }; + }, [ + variant, + isDismissed, + targetCampaign, + toastRef, + showToast, + RewardsToastOptions, + handleCta, + handleDismiss, + ]), + ); +} + +export default useCampaignOutcomeToast; diff --git a/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts new file mode 100644 index 00000000000..012cbe4549a --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts @@ -0,0 +1,173 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { selectCampaignParticipantOptedIn } from '../../../../reducers/rewards/selectors'; +import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome'; +import type { BaseCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('../../../../selectors/rewards', () => ({ + selectRewardsSubscriptionId: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockSelectCampaignParticipantOptedIn = + selectCampaignParticipantOptedIn as jest.MockedFunction< + typeof selectCampaignParticipantOptedIn + >; + +const CAMPAIGN_ID = 'campaign-123'; +const SUBSCRIPTION_ID = 'sub-456'; +const MESSENGER_ACTION = 'RewardsController:getOndoCampaignParticipantOutcome'; +const CONFIG = { messengerAction: MESSENGER_ACTION }; + +const MOCK_OUTCOME: BaseCampaignParticipantOutcomeDto = { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'WINNER-XYZ', +}; + +function setupSelectors({ + subscriptionId = SUBSCRIPTION_ID, + isOptedIn = true, +}: { + subscriptionId?: string | null; + isOptedIn?: boolean; +} = {}) { + const participantOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + mockSelectCampaignParticipantOptedIn.mockReturnValue( + participantOptedInSelector, + ); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return subscriptionId; + if (selector === participantOptedInSelector) return isOptedIn; + return undefined; + }); +} + +describe('useCampaignParticipantOutcome', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches and returns outcome when subscriptionId, campaignId, and isOptedIn are truthy', async () => { + setupSelectors(); + mockCall.mockResolvedValue(MOCK_OUTCOME); + + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(mockCall).toHaveBeenCalledWith( + MESSENGER_ACTION, + CAMPAIGN_ID, + SUBSCRIPTION_ID, + ); + expect(result.current.outcome).toEqual(MOCK_OUTCOME); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('returns null outcome when campaignId is undefined', async () => { + setupSelectors(); + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(undefined, CONFIG), + ); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('returns null outcome when subscriptionId is missing', async () => { + setupSelectors({ subscriptionId: null }); + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('returns null outcome when user is not opted in', async () => { + setupSelectors({ isOptedIn: false }); + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('sets hasError and clears outcome when the fetch throws', async () => { + setupSelectors(); + mockCall.mockRejectedValue(new Error('fetch failed')); + + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(true); + }); + + it('resets state when campaignId changes to undefined', async () => { + setupSelectors(); + mockCall.mockResolvedValue(MOCK_OUTCOME); + + const initialProps: { id: string | undefined } = { id: CAMPAIGN_ID }; + const { result, waitForNextUpdate, rerender } = renderHook( + ({ id }: { id: string | undefined }) => + useCampaignParticipantOutcome(id, CONFIG), + { initialProps }, + ); + + await act(async () => { + await waitForNextUpdate(); + }); + expect(result.current.outcome).toEqual(MOCK_OUTCOME); + + rerender({ id: undefined }); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts new file mode 100644 index 00000000000..1f10639c665 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { selectCampaignParticipantOptedIn } from '../../../../reducers/rewards/selectors'; +import type { BaseCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +export interface UseCampaignParticipantOutcomeResult< + T extends BaseCampaignParticipantOutcomeDto, +> { + outcome: T | null; + isLoading: boolean; + hasError: boolean; +} + +export interface CampaignOutcomeFetchConfig { + messengerAction: string; +} + +export function useCampaignParticipantOutcome< + T extends BaseCampaignParticipantOutcomeDto, +>( + campaignId: string | undefined, + config: CampaignOutcomeFetchConfig, +): UseCampaignParticipantOutcomeResult { + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isOptedIn = useSelector( + selectCampaignParticipantOptedIn(subscriptionId, campaignId), + ); + const [outcome, setOutcome] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + + const fetchOutcome = useCallback(async (): Promise => { + if (!subscriptionId || !campaignId || !isOptedIn) { + setOutcome(null); + setIsLoading(false); + setHasError(false); + return; + } + + try { + setIsLoading(true); + setHasError(false); + const result = await Engine.controllerMessenger.call( + config.messengerAction as Parameters< + typeof Engine.controllerMessenger.call + >[0], + campaignId, + subscriptionId, + ); + setOutcome(result as T); + } catch { + setOutcome(null); + setHasError(true); + } finally { + setIsLoading(false); + } + }, [campaignId, subscriptionId, isOptedIn, config.messengerAction]); + + useEffect(() => { + fetchOutcome(); + }, [fetchOutcome]); + + return { outcome, isLoading, hasError }; +} + +export default useCampaignParticipantOutcome; diff --git a/app/components/UI/Rewards/hooks/useCampaignReminderActions.test.ts b/app/components/UI/Rewards/hooks/useCampaignReminderActions.test.ts new file mode 100644 index 00000000000..6003b493f85 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignReminderActions.test.ts @@ -0,0 +1,378 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { + buildCampaignReminderCompositeKey, + reminderStorageKeyForComposite, + useCampaignReminderActions, +} from './useCampaignReminderActions'; +import { + CampaignType, + type CampaignDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { + selectIsMetamaskNotificationsEnabled, + selectIsMetaMaskPushNotificationsEnabled, +} from '../../../../selectors/notifications'; +import { isNotificationsFeatureEnabled } from '../../../../util/notifications/constants'; + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(); +const mockShowToast = jest.fn(); +const mockEnableNotifications = jest.fn(); +const mockEnableNotificationsNudge = jest.fn( + (linkButtonOptions: { label: string; onPress: () => Promise }) => ({ + variant: 'Plain', + hasNoTimeout: true, + linkButtonOptions, + closeButtonOptions: { + onPress: jest.fn(), + }, + }), +); +const mockGetItemSync = jest.fn((_key: string): string | null => null); +const mockSetItem = jest.fn( + (_key: string, _value: string): Promise => Promise.resolve(), +); +let mockEnableNotificationsLoading = false; + +const TEST_REWARDS_SUBSCRIPTION_ID = 'test-rewards-sub-id'; +const TEST_CAMPAIGN_ID = 'test-campaign-id'; +const TEST_CAMPAIGN_START_DATE = '2028-07-15T00:00:00.000Z'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../store/storage-wrapper', () => ({ + __esModule: true, + default: { + getItemSync: (key: string) => mockGetItemSync(key), + setItem: (key: string, value: string) => mockSetItem(key, value), + }, +})); + +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: jest.fn(() => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + })), +})); + +jest.mock('./useRewardsToast', () => ({ + __esModule: true, + default: jest.fn(() => ({ + showToast: mockShowToast, + RewardsToastOptions: { + success: jest.fn((title: string, subtitle?: string) => ({ + variant: 'success', + title, + subtitle, + })), + error: jest.fn((title: string, subtitle?: string) => ({ + variant: 'error', + title, + subtitle, + })), + enableNotificationsNudge: mockEnableNotificationsNudge, + loading: jest.fn((title: string, subtitle?: string) => ({ + variant: 'loading', + title, + subtitle, + })), + }, + })), +})); + +jest.mock('../../../../util/notifications/hooks/useNotifications', () => ({ + useEnableNotifications: jest.fn(() => ({ + enableNotifications: mockEnableNotifications, + loading: mockEnableNotificationsLoading, + })), +})); + +jest.mock('../../../../util/notifications/constants', () => ({ + isNotificationsFeatureEnabled: jest.fn(() => true), +})); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.campaign.remind_me_success_toast': 'We will notify you.', + 'rewards.campaign.remind_me_save_error': 'Save failed.', + 'rewards.notifications_nudge.turn_on_button': 'Turn on', + }; + return translations[key] || key; + }, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +const createCampaign = (overrides = {}): CampaignDto => ({ + id: TEST_CAMPAIGN_ID, + type: CampaignType.PERPS_TRADING, + name: 'Test Campaign', + startDate: TEST_CAMPAIGN_START_DATE, + endDate: '2028-12-31T23:59:59.999Z', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: true, + showUpcomingDate: false, + ...overrides, +}); + +function mockSelectors({ + subscriptionId = TEST_REWARDS_SUBSCRIPTION_ID, + notificationsEnabled = true, +}: { + subscriptionId?: string | null; + notificationsEnabled?: boolean; +} = {}) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) { + return subscriptionId; + } + if ( + selector === selectIsMetamaskNotificationsEnabled || + selector === selectIsMetaMaskPushNotificationsEnabled + ) { + return notificationsEnabled; + } + return undefined; + }); +} + +describe('useCampaignReminderActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockEnableNotifications.mockResolvedValue(undefined); + mockEnableNotificationsLoading = false; + mockGetItemSync.mockReturnValue(null); + mockSetItem.mockResolvedValue(undefined); + mockSelectors(); + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(true); + mockCreateEventBuilder.mockImplementation(() => { + const builder = { + addProperties: jest.fn(), + build: jest.fn(() => ({ category: 'test-event' })), + }; + builder.addProperties.mockReturnValue(builder); + return builder; + }); + }); + + it('shows Remind Me CTA after storage hydration when reminder is enabled', async () => { + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(true); + }); + }); + + it('does not show Remind Me CTA when the reminder feature is disabled', async () => { + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), false), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(false); + }); + }); + + it('does not show Remind Me CTA when storage already has the reminder', async () => { + mockGetItemSync.mockReturnValueOnce('1'); + + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(false); + }); + }); + + it('does not show Remind Me CTA when subscription id is missing', async () => { + mockSelectors({ subscriptionId: null }); + + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(false); + }); + }); + + it('shows Remind Me CTA when notifications are disabled even if reminder is already stored', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetItemSync.mockReturnValue('1'); + + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(true); + }); + }); + + it('does not show Remind Me CTA when notifications are enabled and reminder is already stored', async () => { + mockSelectors({ notificationsEnabled: true }); + mockGetItemSync.mockReturnValue('1'); + + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(false); + }); + }); + + it('does not show Remind Me CTA when the notifications feature flag is off', async () => { + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(false); + + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(false); + }); + }); + + it('persists, tracks, and shows success toast when notifications are already enabled', async () => { + const campaign = createCampaign(); + const { result } = renderHook(() => + useCampaignReminderActions(campaign, true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(true); + }); + + await act(async () => { + await result.current.handleRemindMePress(); + }); + + expect(mockSetItem).toHaveBeenCalledWith( + reminderStorageKeyForComposite( + `${TEST_REWARDS_SUBSCRIPTION_ID}:${TEST_CAMPAIGN_ID}`, + ), + '1', + ); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.REWARDS_CAMPAIGN_REMINDER_SUBSCRIBED, + ); + const builder = mockCreateEventBuilder.mock.results[0]?.value as { + addProperties: jest.Mock; + }; + expect(builder.addProperties).toHaveBeenCalledWith({ + campaign_id: TEST_CAMPAIGN_ID, + campaign_starts_at: TEST_CAMPAIGN_START_DATE, + }); + expect(mockTrackEvent).toHaveBeenCalledWith({ category: 'test-event' }); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'success', + title: 'We will notify you.', + }), + ); + expect(result.current.showRemindMeCta).toBe(false); + }); + + it('shows error toast and does not track when reminder storage fails', async () => { + mockSetItem.mockRejectedValueOnce(new Error('disk full')); + const { result } = renderHook(() => + useCampaignReminderActions(createCampaign(), true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(true); + }); + + await act(async () => { + await result.current.handleRemindMePress(); + }); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'error', + title: 'Save failed.', + }), + ); + expect(result.current.showRemindMeCta).toBe(true); + }); + + it('prompts for notifications and defers subscription until notifications are enabled', async () => { + let notificationsEnabled = false; + mockSelectors({ notificationsEnabled }); + const campaign = createCampaign(); + const { result, rerender } = renderHook(() => + useCampaignReminderActions(campaign, true), + ); + + await waitFor(() => { + expect(result.current.showRemindMeCta).toBe(true); + }); + + await act(async () => { + await result.current.handleRemindMePress(); + }); + + expect(mockEnableNotificationsNudge).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'Turn on', + onPress: expect.any(Function), + }), + ); + expect(mockSetItem).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + expect(mockEnableNotifications).toHaveBeenCalledTimes(1); + + notificationsEnabled = true; + mockSelectors({ notificationsEnabled }); + rerender(); + + await waitFor(() => { + expect(mockSetItem).toHaveBeenCalledWith( + reminderStorageKeyForComposite( + `${TEST_REWARDS_SUBSCRIPTION_ID}:${TEST_CAMPAIGN_ID}`, + ), + '1', + ); + }); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); +}); + +describe('campaign reminder storage helpers', () => { + describe('buildCampaignReminderCompositeKey', () => { + it('joins subscription and campaign with colon', () => { + expect(buildCampaignReminderCompositeKey('sub-1', 'camp-2')).toBe( + 'sub-1:camp-2', + ); + }); + }); + + describe('reminderStorageKeyForComposite', () => { + it('prefixes composite key for isolated MMKV rows', () => { + expect(reminderStorageKeyForComposite('sub-1:camp-2')).toBe( + 'rewards_campaign_reminder_subscribed::sub-1:camp-2', + ); + }); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useCampaignReminderActions.ts b/app/components/UI/Rewards/hooks/useCampaignReminderActions.ts new file mode 100644 index 00000000000..884964159ac --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignReminderActions.ts @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import type { CampaignDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { strings } from '../../../../../locales/i18n'; +import useRewardsToast from './useRewardsToast'; +import { useRewardsNotificationsNudge } from './useRewardsNotificationsNudge'; +import StorageWrapper from '../../../../store/storage-wrapper'; + +const REMINDER_SUBSCRIBED_VALUE = '1'; + +export function buildCampaignReminderCompositeKey( + subscriptionId: string, + campaignId: string, +): string { + return `${subscriptionId}:${campaignId}`; +} + +/** + * One MMKV key per subscription:campaign reminder (no shared JSON list). + */ +export function reminderStorageKeyForComposite(compositeKey: string): string { + return `rewards_campaign_reminder_subscribed::${compositeKey}`; +} + +/** + * Shared storage, analytics, and toasts for campaign start reminders + * (tile icon CTA and preview "Notify me" CTA). + */ +export function useCampaignReminderActions( + campaign: CampaignDto, + enabled: boolean, +): { + showRemindMeCta: boolean; + handleRemindMePress: () => Promise; +} { + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isStoredRef = useRef(false); + const [hydrated, setHydrated] = useState(false); + const [renderKey, setRenderKey] = useState(0); + const { trackEvent, createEventBuilder } = useAnalytics(); + const { showToast, RewardsToastOptions } = useRewardsToast(); + const { + canPromptToEnableNotifications, + areNotificationsEnabled, + runAfterNotificationsEnabled, + } = useRewardsNotificationsNudge({ enabled }); + + const compositeKey = useMemo(() => { + if (!subscriptionId || !campaign.id) { + return null; + } + return buildCampaignReminderCompositeKey(subscriptionId, campaign.id); + }, [subscriptionId, campaign.id]); + + useEffect(() => { + if (!enabled) { + setHydrated(true); + return; + } + if (!compositeKey) { + setHydrated(true); + return; + } + const storageKey = reminderStorageKeyForComposite(compositeKey); + const raw = StorageWrapper.getItemSync(storageKey); + isStoredRef.current = raw === REMINDER_SUBSCRIBED_VALUE; + setHydrated(true); + setRenderKey((k) => k + 1); + }, [enabled, compositeKey]); + + const showRemindMeCta = Boolean( + renderKey >= 0 && + enabled && + canPromptToEnableNotifications && + hydrated && + compositeKey && + (!areNotificationsEnabled || !isStoredRef.current), + ); + + const persistReminderSubscription = useCallback(async () => { + if (!compositeKey) { + throw new Error('Missing subscription or campaign for reminder storage'); + } + if (isStoredRef.current) { + return; + } + const storageKey = reminderStorageKeyForComposite(compositeKey); + await StorageWrapper.setItem(storageKey, REMINDER_SUBSCRIBED_VALUE); + isStoredRef.current = true; + setRenderKey((k) => k + 1); + }, [compositeKey]); + + const subscribeToReminder = useCallback(async () => { + try { + await persistReminderSubscription(); + } catch { + showToast( + RewardsToastOptions.error( + strings('rewards.campaign.remind_me_save_error'), + ), + ); + return; + } + trackEvent( + createEventBuilder(MetaMetricsEvents.REWARDS_CAMPAIGN_REMINDER_SUBSCRIBED) + .addProperties({ + campaign_id: campaign.id, + campaign_starts_at: campaign.startDate, + }) + .build(), + ); + showToast( + RewardsToastOptions.success( + strings('rewards.campaign.remind_me_success_toast'), + ), + ); + }, [ + campaign.id, + campaign.startDate, + persistReminderSubscription, + trackEvent, + createEventBuilder, + showToast, + RewardsToastOptions, + ]); + + const handleRemindMePress = useCallback(async () => { + await runAfterNotificationsEnabled(subscribeToReminder); + }, [runAfterNotificationsEnabled, subscribeToReminder]); + + return { showRemindMeCta, handleRemindMePress }; +} diff --git a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts index c0bed6ad4a6..f424932ae16 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts @@ -3,11 +3,11 @@ import { waitFor } from '@testing-library/react-native'; import { useSelector, useDispatch } from 'react-redux'; import { useGetOndoCampaignActivity } from './useGetOndoCampaignActivity'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignActivityById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignActivityById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignActivity } from '../../../../reducers/rewards'; import type { OndoGmActivityEntryDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -22,10 +22,10 @@ jest.mock('../../../../core/Engine', () => ({ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), - selectCampaignParticipantOptedIn: jest.fn(), })); jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), selectOndoCampaignActivityById: jest.fn(), })); diff --git a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts index 3c28894340a..92faacd5610 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignActivityById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignActivityById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignActivity } from '../../../../reducers/rewards'; import type { OndoGmActivityEntryDto } from '../../../../core/Engine/controllers/rewards-controller/types'; diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts index f3170271b60..2910af46256 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts @@ -2,11 +2,11 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useSelector, useDispatch } from 'react-redux'; import { useGetOndoLeaderboardPosition } from './useGetOndoLeaderboardPosition'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignLeaderboardPosition } from '../../../../reducers/rewards'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; import type { CampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -26,10 +26,10 @@ jest.mock('./useInvalidateByRewardEvents', () => ({ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), - selectCampaignParticipantOptedIn: jest.fn(), })); jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), selectOndoCampaignLeaderboardPositionById: jest.fn(), })); @@ -83,16 +83,18 @@ interface SelectorState { function setupSelectors(state: SelectorState) { const isOptedIn = state.isOptedIn ?? true; const mockPositionSelector = jest.fn().mockReturnValue(state.position); - const mockOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + const mockParticipantOptedInSelector = jest.fn().mockReturnValue(isOptedIn); mockSelectCampaignLeaderboardPositionById.mockReturnValue( mockPositionSelector, ); - mockSelectCampaignParticipantOptedIn.mockReturnValue(mockOptedInSelector); + mockSelectCampaignParticipantOptedIn.mockReturnValue( + mockParticipantOptedInSelector, + ); mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return state.subscriptionId; if (selector === mockPositionSelector) return state.position; - if (selector === mockOptedInSelector) return isOptedIn; + if (selector === mockParticipantOptedInSelector) return isOptedIn; return undefined; }); } diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts index be4f0b96cc3..9b0bccab4f3 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignLeaderboardPosition } from '../../../../reducers/rewards'; import type { CampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; diff --git a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts index 80300f1f62c..332ad4b7415 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts @@ -3,11 +3,11 @@ import { waitFor } from '@testing-library/react-native'; import { useSelector, useDispatch } from 'react-redux'; import { useGetOndoPortfolioPosition } from './useGetOndoPortfolioPosition'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignPortfolioById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignPortfolioById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignPortfolioPosition } from '../../../../reducers/rewards'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; import type { OndoGmPortfolioDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -27,10 +27,10 @@ jest.mock('./useInvalidateByRewardEvents', () => ({ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), - selectCampaignParticipantOptedIn: jest.fn(), })); jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), selectOndoCampaignPortfolioById: jest.fn(), })); diff --git a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts index 2270b597a59..e0f960916cc 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignPortfolioById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignPortfolioById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignPortfolioPosition } from '../../../../reducers/rewards'; import type { OndoGmPortfolioDto } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.test.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.test.ts new file mode 100644 index 00000000000..007822dca57 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.test.ts @@ -0,0 +1,244 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector, useDispatch } from 'react-redux'; +import { useGetPerpsTradingCampaignLeaderboard } from './useGetPerpsTradingCampaignLeaderboard'; +import Engine from '../../../../core/Engine'; +import { + selectPerpsTradingCampaignLeaderboard, + selectPerpsTradingCampaignLeaderboardLoading, + selectPerpsTradingCampaignLeaderboardError, +} from '../../../../reducers/rewards/selectors'; +import { + setPerpsTradingCampaignLeaderboard, + setPerpsTradingCampaignLeaderboardLoading, + setPerpsTradingCampaignLeaderboardError, +} from '../../../../reducers/rewards'; +import type { PerpsTradingCampaignLeaderboardDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectPerpsTradingCampaignLeaderboard: jest.fn(), + selectPerpsTradingCampaignLeaderboardLoading: jest.fn(), + selectPerpsTradingCampaignLeaderboardError: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards', () => ({ + setPerpsTradingCampaignLeaderboard: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignLeaderboard', + payload, + })), + setPerpsTradingCampaignLeaderboardLoading: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignLeaderboardLoading', + payload, + })), + setPerpsTradingCampaignLeaderboardError: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignLeaderboardError', + payload, + })), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; + +const CAMPAIGN_ID = 'campaign-123'; +const MOCK_LEADERBOARD: PerpsTradingCampaignLeaderboardDto = { + campaignId: CAMPAIGN_ID, + computedAt: '2024-03-20T12:00:00.000Z', + entries: [ + { rank: 1, referralCode: 'ABC123', pnl: 1500, qualified: true }, + { rank: 2, referralCode: 'DEF456', pnl: 800, qualified: true }, + ], + totalParticipants: 50, +}; + +interface SelectorState { + leaderboard: PerpsTradingCampaignLeaderboardDto | null; + isLoading: boolean; + hasError: boolean; +} + +function setupSelectors(state: SelectorState) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsTradingCampaignLeaderboard) + return state.leaderboard; + if (selector === selectPerpsTradingCampaignLeaderboardLoading) + return state.isLoading; + if (selector === selectPerpsTradingCampaignLeaderboardError) + return state.hasError; + return undefined; + }); +} + +describe('useGetPerpsTradingCampaignLeaderboard', () => { + const mockDispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + setupSelectors({ leaderboard: null, isLoading: false, hasError: false }); + }); + + it('does not fetch when campaignId is undefined but resets loading and error', async () => { + renderHook(() => useGetPerpsTradingCampaignLeaderboard(undefined)); + + expect(mockCall).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardLoading(false), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardError(false), + ); + }); + + it('fetches leaderboard and dispatches actions on success', async () => { + mockCall.mockResolvedValueOnce(MOCK_LEADERBOARD as never); + + renderHook(() => useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardLoading(true), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardError(false), + ); + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPerpsTradingCampaignLeaderboard', + CAMPAIGN_ID, + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboard(MOCK_LEADERBOARD), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardLoading(false), + ); + }); + + it('dispatches error action on non-404 fetch failure', async () => { + mockCall.mockRejectedValueOnce(new Error('Network error') as never); + + renderHook(() => useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardError(true), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardLoading(false), + ); + }); + + it('returns isLeaderboardNotYetComputed true on 404 error', async () => { + mockCall.mockRejectedValueOnce( + new Error('leaderboard failed: 404') as never, + ); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isLeaderboardNotYetComputed).toBe(true); + expect(mockDispatch).not.toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardError(true), + ); + }); + + it('returns leaderboard data from selector', () => { + setupSelectors({ + leaderboard: MOCK_LEADERBOARD, + isLoading: false, + hasError: false, + }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + expect(result.current.leaderboard).toEqual(MOCK_LEADERBOARD); + }); + + it('returns loading state from selector', () => { + setupSelectors({ leaderboard: null, isLoading: true, hasError: false }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + expect(result.current.isLoading).toBe(true); + }); + + it('returns error state from selector', () => { + setupSelectors({ leaderboard: null, isLoading: false, hasError: true }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + expect(result.current.hasError).toBe(true); + }); + + it('refetch function re-fetches the leaderboard', async () => { + mockCall.mockResolvedValue(MOCK_LEADERBOARD as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + mockDispatch.mockClear(); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockCall).toHaveBeenCalledTimes(2); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardLoading(true), + ); + }); + + it('returns isLeaderboardNotYetComputed false initially', () => { + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(undefined), + ); + + expect(result.current.isLeaderboardNotYetComputed).toBe(false); + }); + + it('returns isLeaderboardNotYetComputed false on non-404 error', async () => { + mockCall.mockRejectedValueOnce(new Error('Server error') as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboard(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isLeaderboardNotYetComputed).toBe(false); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.ts new file mode 100644 index 00000000000..6e7e7e8b953 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboard.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { + selectPerpsTradingCampaignLeaderboard, + selectPerpsTradingCampaignLeaderboardLoading, + selectPerpsTradingCampaignLeaderboardError, +} from '../../../../reducers/rewards/selectors'; +import { + setPerpsTradingCampaignLeaderboard, + setPerpsTradingCampaignLeaderboardLoading, + setPerpsTradingCampaignLeaderboardError, +} from '../../../../reducers/rewards'; +import type { PerpsTradingCampaignLeaderboardDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +export interface UseGetPerpsTradingCampaignLeaderboardResult { + leaderboard: PerpsTradingCampaignLeaderboardDto | null; + isLoading: boolean; + hasError: boolean; + isLeaderboardNotYetComputed: boolean; + refetch: () => Promise; +} + +export const useGetPerpsTradingCampaignLeaderboard = ( + campaignId: string | undefined, +): UseGetPerpsTradingCampaignLeaderboardResult => { + const dispatch = useDispatch(); + const leaderboard = useSelector(selectPerpsTradingCampaignLeaderboard); + const isLoading = useSelector(selectPerpsTradingCampaignLeaderboardLoading); + const hasError = useSelector(selectPerpsTradingCampaignLeaderboardError); + const [isLeaderboardNotYetComputed, setIsLeaderboardNotYetComputed] = + useState(false); + + const fetchLeaderboard = useCallback(async (): Promise => { + if (!campaignId) { + dispatch(setPerpsTradingCampaignLeaderboardLoading(false)); + dispatch(setPerpsTradingCampaignLeaderboardError(false)); + setIsLeaderboardNotYetComputed(false); + return; + } + + try { + dispatch(setPerpsTradingCampaignLeaderboardLoading(true)); + dispatch(setPerpsTradingCampaignLeaderboardError(false)); + setIsLeaderboardNotYetComputed(false); + const result = await Engine.controllerMessenger.call( + 'RewardsController:getPerpsTradingCampaignLeaderboard', + campaignId, + ); + dispatch(setPerpsTradingCampaignLeaderboard(result)); + } catch (error) { + const is404 = error instanceof Error && error.message.includes('404'); + if (is404) { + setIsLeaderboardNotYetComputed(true); + } else { + dispatch(setPerpsTradingCampaignLeaderboardError(true)); + } + } finally { + dispatch(setPerpsTradingCampaignLeaderboardLoading(false)); + } + }, [dispatch, campaignId]); + + useEffect(() => { + fetchLeaderboard(); + }, [fetchLeaderboard]); + + return { + leaderboard, + isLoading, + hasError, + isLeaderboardNotYetComputed, + refetch: fetchLeaderboard, + }; +}; + +export default useGetPerpsTradingCampaignLeaderboard; diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts new file mode 100644 index 00000000000..d85a3b027cf --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts @@ -0,0 +1,262 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector, useDispatch } from 'react-redux'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from './useGetPerpsTradingCampaignLeaderboardPosition'; +import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { + selectCampaignParticipantOptedIn, + selectPerpsTradingCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; +import { setPerpsTradingCampaignLeaderboardPosition } from '../../../../reducers/rewards'; +import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; +import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('./useInvalidateByRewardEvents', () => ({ + useInvalidateByRewardEvents: jest.fn(), +})); + +jest.mock('../../../../selectors/rewards', () => ({ + selectRewardsSubscriptionId: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), + selectPerpsTradingCampaignLeaderboardPositionById: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards', () => ({ + setPerpsTradingCampaignLeaderboardPosition: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignLeaderboardPosition', + payload, + })), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseInvalidateByRewardEvents = + useInvalidateByRewardEvents as jest.MockedFunction< + typeof useInvalidateByRewardEvents + >; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockSelectPositionById = + selectPerpsTradingCampaignLeaderboardPositionById as jest.MockedFunction< + typeof selectPerpsTradingCampaignLeaderboardPositionById + >; +const mockSelectCampaignParticipantOptedIn = + selectCampaignParticipantOptedIn as jest.MockedFunction< + typeof selectCampaignParticipantOptedIn + >; + +const CAMPAIGN_ID = 'campaign-123'; +const SUBSCRIPTION_ID = 'sub-456'; +const MOCK_POSITION: PerpsTradingCampaignLeaderboardPositionDto = { + rank: 5, + pnl: 1500, + notionalVolume: 30000, + marginDeployed: 2000, + qualified: true, + neighbors: [], + computedAt: '2024-03-20T12:00:00.000Z', +}; + +interface SelectorState { + subscriptionId: string | null; + position: PerpsTradingCampaignLeaderboardPositionDto | null; + isOptedIn?: boolean; +} + +function setupSelectors(state: SelectorState) { + const isOptedIn = state.isOptedIn ?? true; + const mockPositionSelector = jest.fn().mockReturnValue(state.position); + const mockParticipantOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + mockSelectPositionById.mockReturnValue(mockPositionSelector); + mockSelectCampaignParticipantOptedIn.mockReturnValue( + mockParticipantOptedInSelector, + ); + + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return state.subscriptionId; + if (selector === mockPositionSelector) return state.position; + if (selector === mockParticipantOptedInSelector) return isOptedIn; + return undefined; + }); +} + +describe('useGetPerpsTradingCampaignLeaderboardPosition', () => { + const mockDispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + setupSelectors({ subscriptionId: SUBSCRIPTION_ID, position: null }); + }); + + it('does not fetch when subscriptionId is missing', async () => { + setupSelectors({ subscriptionId: null, position: null }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(mockCall).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('does not fetch when not opted in', async () => { + setupSelectors({ + subscriptionId: SUBSCRIPTION_ID, + position: null, + isOptedIn: false, + }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(mockCall).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('does not fetch when campaignId is undefined', async () => { + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(undefined), + ); + + expect(mockCall).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('fetches position and dispatches actions on success', async () => { + mockCall.mockResolvedValueOnce(MOCK_POSITION as never); + + renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPerpsTradingCampaignLeaderboardPosition', + CAMPAIGN_ID, + SUBSCRIPTION_ID, + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignLeaderboardPosition({ + subscriptionId: SUBSCRIPTION_ID, + campaignId: CAMPAIGN_ID, + position: MOCK_POSITION, + }), + ); + }); + + it('sets hasError on fetch failure', async () => { + mockCall.mockRejectedValueOnce(new Error('Network error') as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.hasError).toBe(true); + }); + + it('returns position data from selector', () => { + setupSelectors({ + subscriptionId: SUBSCRIPTION_ID, + position: MOCK_POSITION, + }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(result.current.position).toEqual(MOCK_POSITION); + }); + + it('returns null position when not loaded', () => { + setupSelectors({ subscriptionId: SUBSCRIPTION_ID, position: null }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(result.current.position).toBeNull(); + }); + + it('refetch function re-fetches the position', async () => { + mockCall.mockResolvedValue(MOCK_POSITION as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + mockDispatch.mockClear(); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockCall).toHaveBeenCalledTimes(2); + }); + + it('calls selectPerpsTradingCampaignLeaderboardPositionById with subscriptionId and campaignId', () => { + renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(mockSelectPositionById).toHaveBeenCalledWith( + SUBSCRIPTION_ID, + CAMPAIGN_ID, + ); + }); + + it('subscribes to leaderboardPositionInvalidated to auto-refetch', () => { + renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + expect(mockUseInvalidateByRewardEvents).toHaveBeenCalledWith( + expect.arrayContaining([ + 'RewardsController:leaderboardPositionInvalidated', + ]), + expect.any(Function), + ); + }); + + it('sets hasFetched after successful fetch', async () => { + mockCall.mockResolvedValueOnce(MOCK_POSITION as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignLeaderboardPosition(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.hasFetched).toBe(true); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts new file mode 100644 index 00000000000..aedd1ff533a --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts @@ -0,0 +1,83 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { + selectCampaignParticipantOptedIn, + selectPerpsTradingCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; +import { setPerpsTradingCampaignLeaderboardPosition } from '../../../../reducers/rewards'; +import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; + +export interface UseGetPerpsTradingCampaignLeaderboardPositionResult { + position: PerpsTradingCampaignLeaderboardPositionDto | null; + isLoading: boolean; + hasError: boolean; + hasFetched: boolean; + refetch: () => Promise; +} + +export const useGetPerpsTradingCampaignLeaderboardPosition = ( + campaignId: string | undefined, +): UseGetPerpsTradingCampaignLeaderboardPositionResult => { + const dispatch = useDispatch(); + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isOptedIn = useSelector( + selectCampaignParticipantOptedIn(subscriptionId, campaignId), + ); + const position = useSelector( + selectPerpsTradingCampaignLeaderboardPositionById( + subscriptionId ?? undefined, + campaignId, + ), + ); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const [hasFetched, setHasFetched] = useState(false); + + const fetchPosition = useCallback(async (): Promise => { + if (!subscriptionId || !campaignId || !isOptedIn) { + setIsLoading(false); + setHasError(false); + setHasFetched(false); + return; + } + + try { + setIsLoading(true); + setHasError(false); + const result = await Engine.controllerMessenger.call( + 'RewardsController:getPerpsTradingCampaignLeaderboardPosition', + campaignId, + subscriptionId, + ); + dispatch( + setPerpsTradingCampaignLeaderboardPosition({ + subscriptionId, + campaignId, + position: result, + }), + ); + } catch { + setHasError(true); + } finally { + setIsLoading(false); + setHasFetched(true); + } + }, [dispatch, subscriptionId, campaignId, isOptedIn]); + + useEffect(() => { + fetchPosition(); + }, [fetchPosition]); + + const invalidationEvents = useMemo( + () => ['RewardsController:leaderboardPositionInvalidated'] as const, + [], + ); + useInvalidateByRewardEvents(invalidationEvents, fetchPosition); + + return { position, isLoading, hasError, hasFetched, refetch: fetchPosition }; +}; + +export default useGetPerpsTradingCampaignLeaderboardPosition; diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.test.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.test.ts new file mode 100644 index 00000000000..dacbffded39 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.test.ts @@ -0,0 +1,196 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector, useDispatch } from 'react-redux'; +import { useGetPerpsTradingCampaignVolume } from './useGetPerpsTradingCampaignVolume'; +import Engine from '../../../../core/Engine'; +import { + selectPerpsTradingCampaignVolume, + selectPerpsTradingCampaignVolumeLoading, + selectPerpsTradingCampaignVolumeError, +} from '../../../../reducers/rewards/selectors'; +import { + setPerpsTradingCampaignVolume, + setPerpsTradingCampaignVolumeLoading, + setPerpsTradingCampaignVolumeError, +} from '../../../../reducers/rewards'; +import type { PerpsTradingCampaignVolumeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectPerpsTradingCampaignVolume: jest.fn(), + selectPerpsTradingCampaignVolumeLoading: jest.fn(), + selectPerpsTradingCampaignVolumeError: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards', () => ({ + setPerpsTradingCampaignVolume: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignVolume', + payload, + })), + setPerpsTradingCampaignVolumeLoading: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignVolumeLoading', + payload, + })), + setPerpsTradingCampaignVolumeError: jest.fn((payload) => ({ + type: 'rewards/setPerpsTradingCampaignVolumeError', + payload, + })), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; + +const CAMPAIGN_ID = 'campaign-123'; +const MOCK_VOLUME: PerpsTradingCampaignVolumeDto = { + totalUsdVolume: '5000000', +}; + +interface SelectorState { + volume: PerpsTradingCampaignVolumeDto | null; + isLoading: boolean; + hasError: boolean; +} + +function setupSelectors(state: SelectorState) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsTradingCampaignVolume) return state.volume; + if (selector === selectPerpsTradingCampaignVolumeLoading) + return state.isLoading; + if (selector === selectPerpsTradingCampaignVolumeError) + return state.hasError; + return undefined; + }); +} + +describe('useGetPerpsTradingCampaignVolume', () => { + const mockDispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + setupSelectors({ volume: null, isLoading: false, hasError: false }); + }); + + it('does not fetch when campaignId is undefined but resets loading and error', async () => { + renderHook(() => useGetPerpsTradingCampaignVolume(undefined)); + + expect(mockCall).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeLoading(false), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeError(false), + ); + }); + + it('fetches volume and dispatches actions on success', async () => { + mockCall.mockResolvedValueOnce(MOCK_VOLUME as never); + + renderHook(() => useGetPerpsTradingCampaignVolume(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeLoading(true), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeError(false), + ); + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getPerpsTradingCampaignVolume', + CAMPAIGN_ID, + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolume(MOCK_VOLUME), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeLoading(false), + ); + }); + + it('dispatches error action on fetch failure', async () => { + mockCall.mockRejectedValueOnce(new Error('Network error') as never); + + renderHook(() => useGetPerpsTradingCampaignVolume(CAMPAIGN_ID)); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeError(true), + ); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeLoading(false), + ); + }); + + it('returns volume data from selector', () => { + setupSelectors({ + volume: MOCK_VOLUME, + isLoading: false, + hasError: false, + }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignVolume(CAMPAIGN_ID), + ); + + expect(result.current.volume).toEqual(MOCK_VOLUME); + }); + + it('returns loading state from selector', () => { + setupSelectors({ volume: null, isLoading: true, hasError: false }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignVolume(CAMPAIGN_ID), + ); + + expect(result.current.isLoading).toBe(true); + }); + + it('returns error state from selector', () => { + setupSelectors({ volume: null, isLoading: false, hasError: true }); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignVolume(CAMPAIGN_ID), + ); + + expect(result.current.hasError).toBe(true); + }); + + it('refetch function re-fetches the volume', async () => { + mockCall.mockResolvedValue(MOCK_VOLUME as never); + + const { result } = renderHook(() => + useGetPerpsTradingCampaignVolume(CAMPAIGN_ID), + ); + + await act(async () => { + await Promise.resolve(); + }); + + mockDispatch.mockClear(); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockCall).toHaveBeenCalledTimes(2); + expect(mockDispatch).toHaveBeenCalledWith( + setPerpsTradingCampaignVolumeLoading(true), + ); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.ts new file mode 100644 index 00000000000..b5a2974dbd0 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignVolume.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { + selectPerpsTradingCampaignVolume, + selectPerpsTradingCampaignVolumeLoading, + selectPerpsTradingCampaignVolumeError, +} from '../../../../reducers/rewards/selectors'; +import { + setPerpsTradingCampaignVolume, + setPerpsTradingCampaignVolumeLoading, + setPerpsTradingCampaignVolumeError, +} from '../../../../reducers/rewards'; + +export interface UseGetPerpsTradingCampaignVolumeResult { + volume: ReturnType; + isLoading: boolean; + hasError: boolean; + refetch: () => Promise; +} + +export const useGetPerpsTradingCampaignVolume = ( + campaignId: string | undefined, +): UseGetPerpsTradingCampaignVolumeResult => { + const dispatch = useDispatch(); + const volume = useSelector(selectPerpsTradingCampaignVolume); + const isLoading = useSelector(selectPerpsTradingCampaignVolumeLoading); + const hasError = useSelector(selectPerpsTradingCampaignVolumeError); + + const fetchVolume = useCallback(async (): Promise => { + if (!campaignId) { + dispatch(setPerpsTradingCampaignVolumeLoading(false)); + dispatch(setPerpsTradingCampaignVolumeError(false)); + return; + } + + try { + dispatch(setPerpsTradingCampaignVolumeLoading(true)); + dispatch(setPerpsTradingCampaignVolumeError(false)); + const result = await Engine.controllerMessenger.call( + 'RewardsController:getPerpsTradingCampaignVolume', + campaignId, + ); + dispatch(setPerpsTradingCampaignVolume(result)); + } catch { + dispatch(setPerpsTradingCampaignVolumeError(true)); + } finally { + dispatch(setPerpsTradingCampaignVolumeLoading(false)); + } + }, [dispatch, campaignId]); + + useEffect(() => { + fetchVolume(); + }, [fetchVolume]); + + return { volume, isLoading, hasError, refetch: fetchVolume }; +}; + +export default useGetPerpsTradingCampaignVolume; diff --git a/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts index 5efbf8058e7..b614232da40 100644 --- a/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts +++ b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts @@ -82,6 +82,21 @@ describe('useLinkAccountAddress', () => { iconName: 'confirmation', hapticsType: 'success', }), + enableNotificationsNudge: jest.fn().mockReturnValue({ + variant: 'plain', + hapticsType: 'warning', + }), + loading: jest.fn().mockReturnValue({ + variant: 'loading', + }), + outcomeWinner: jest.fn().mockReturnValue({ + variant: 'plain', + hapticsType: 'success', + }), + outcomeNonWinner: jest.fn().mockReturnValue({ + variant: 'icon', + hapticsType: 'warning', + }), }; const mockAccount: InternalAccount = { diff --git a/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts b/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts index e59a982d667..7c5c8603ec7 100644 --- a/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts +++ b/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts @@ -103,6 +103,21 @@ describe('useLinkAccountGroup', () => { iconName: 'error', hapticsType: 'error', }), + enableNotificationsNudge: jest.fn().mockReturnValue({ + variant: 'plain', + hapticsType: 'warning', + }), + loading: jest.fn().mockReturnValue({ + variant: 'loading', + }), + outcomeWinner: jest.fn().mockReturnValue({ + variant: 'plain', + hapticsType: 'success', + }), + outcomeNonWinner: jest.fn().mockReturnValue({ + variant: 'icon', + hapticsType: 'warning', + }), }; // Mock account data diff --git a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts index 5dc86d8617a..6a4189dfcfb 100644 --- a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts +++ b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts @@ -1,149 +1,65 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { selectCampaignParticipantStatus } from '../../../../reducers/rewards/selectors'; +import { renderHook } from '@testing-library/react-hooks'; import { useOndoCampaignParticipantOutcome } from './useOndoCampaignParticipantOutcome'; -import type { OndoGmCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome'; -jest.mock('react-redux', () => ({ - useSelector: jest.fn(), +jest.mock('./useCampaignParticipantOutcome', () => ({ + useCampaignParticipantOutcome: jest.fn(), })); -jest.mock('../../../../core/Engine', () => ({ - controllerMessenger: { call: jest.fn() }, -})); - -jest.mock('../../../../selectors/rewards', () => ({ - selectRewardsSubscriptionId: jest.fn(), -})); - -jest.mock('../../../../reducers/rewards/selectors', () => ({ - selectCampaignParticipantStatus: jest.fn(), -})); - -const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< - typeof Engine.controllerMessenger.call ->; -const mockUseSelector = useSelector as jest.MockedFunction; -const mockSelectCampaignParticipantStatus = - selectCampaignParticipantStatus as jest.MockedFunction< - typeof selectCampaignParticipantStatus +const mockUseCampaignParticipantOutcome = + useCampaignParticipantOutcome as jest.MockedFunction< + typeof useCampaignParticipantOutcome >; const CAMPAIGN_ID = 'campaign-123'; -const SUBSCRIPTION_ID = 'sub-456'; - -const MOCK_OUTCOME: OndoGmCampaignParticipantOutcomeDto = { - subscriptionId: SUBSCRIPTION_ID, - outcomeStatus: 'pending', - winnerVerificationCode: 'WINNER-XYZ', -}; - -function setupSelectors({ - subscriptionId = SUBSCRIPTION_ID, - isOptedIn = true, -}: { - subscriptionId?: string | null; - isOptedIn?: boolean; -} = {}) { - const participantStatusSelector = jest - .fn() - .mockReturnValue(isOptedIn ? { optedIn: true } : null); - mockSelectCampaignParticipantStatus.mockReturnValue( - participantStatusSelector, - ); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectRewardsSubscriptionId) return subscriptionId; - if (selector === participantStatusSelector) - return isOptedIn ? { optedIn: true } : null; - return undefined; - }); -} describe('useOndoCampaignParticipantOutcome', () => { beforeEach(() => { jest.clearAllMocks(); - }); - - it('returns null outcome and no loading when campaignId is undefined', async () => { - setupSelectors(); - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(undefined), - ); - await act(async () => { - await waitForNextUpdate().catch(() => undefined); + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, }); - expect(result.current.outcome).toBeNull(); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); - expect(mockCall).not.toHaveBeenCalled(); }); - it('returns null outcome when subscriptionId is missing', async () => { - setupSelectors({ subscriptionId: null }); - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(CAMPAIGN_ID), - ); - await act(async () => { - await waitForNextUpdate().catch(() => undefined); - }); - expect(result.current.outcome).toBeNull(); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); - expect(mockCall).not.toHaveBeenCalled(); - }); + it('delegates to useCampaignParticipantOutcome with the Ondo messenger action', () => { + renderHook(() => useOndoCampaignParticipantOutcome(CAMPAIGN_ID)); - it('returns null outcome when user is not opted in', async () => { - setupSelectors({ isOptedIn: false }); - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(CAMPAIGN_ID), + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith( + CAMPAIGN_ID, + { + messengerAction: 'RewardsController:getOndoCampaignParticipantOutcome', + }, ); - await act(async () => { - await waitForNextUpdate().catch(() => undefined); - }); - expect(result.current.outcome).toBeNull(); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); - expect(mockCall).not.toHaveBeenCalled(); }); - it('fetches outcome and returns it when all conditions are met', async () => { - setupSelectors(); - mockCall.mockResolvedValue(MOCK_OUTCOME); - - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(CAMPAIGN_ID), - ); + it('passes undefined campaignId through to the generic hook', () => { + renderHook(() => useOndoCampaignParticipantOutcome(undefined)); - await act(async () => { - await waitForNextUpdate(); + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith(undefined, { + messengerAction: 'RewardsController:getOndoCampaignParticipantOutcome', }); - - expect(mockCall).toHaveBeenCalledWith( - 'RewardsController:getOndoCampaignParticipantOutcome', - CAMPAIGN_ID, - SUBSCRIPTION_ID, - ); - expect(result.current.outcome).toEqual(MOCK_OUTCOME); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); }); - it('sets hasError and clears outcome when the fetch throws', async () => { - setupSelectors(); - mockCall.mockRejectedValue(new Error('fetch failed')); + it('returns the result from the generic hook', () => { + const mockOutcome = { + subscriptionId: 'sub-1', + outcomeStatus: 'pending' as const, + winnerVerificationCode: 'CODE', + }; + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: mockOutcome, + isLoading: false, + hasError: false, + }); - const { result, waitForNextUpdate } = renderHook(() => + const { result } = renderHook(() => useOndoCampaignParticipantOutcome(CAMPAIGN_ID), ); - await act(async () => { - await waitForNextUpdate(); - }); - - expect(result.current.outcome).toBeNull(); + expect(result.current.outcome).toEqual(mockOutcome); expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(true); + expect(result.current.hasError).toBe(false); }); }); diff --git a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts index eaabab7f48b..017c62bfc41 100644 --- a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts +++ b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts @@ -1,58 +1,19 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { selectCampaignParticipantStatus } from '../../../../reducers/rewards/selectors'; import type { OndoGmCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { + useCampaignParticipantOutcome, + type UseCampaignParticipantOutcomeResult, +} from './useCampaignParticipantOutcome'; -export interface UseOndoCampaignParticipantOutcomeResult { - outcome: OndoGmCampaignParticipantOutcomeDto | null; - isLoading: boolean; - hasError: boolean; -} +export type UseOndoCampaignParticipantOutcomeResult = + UseCampaignParticipantOutcomeResult; export function useOndoCampaignParticipantOutcome( campaignId: string | undefined, ): UseOndoCampaignParticipantOutcomeResult { - const subscriptionId = useSelector(selectRewardsSubscriptionId); - const isOptedIn = - useSelector(selectCampaignParticipantStatus(subscriptionId, campaignId)) - ?.optedIn === true; - const [outcome, setOutcome] = - useState(null); - const [isLoading, setIsLoading] = useState(false); - const [hasError, setHasError] = useState(false); - - const fetchOutcome = useCallback(async (): Promise => { - if (!subscriptionId || !campaignId || !isOptedIn) { - setOutcome(null); - setIsLoading(false); - setHasError(false); - return; - } - - try { - setIsLoading(true); - setHasError(false); - const result = await Engine.controllerMessenger.call( - 'RewardsController:getOndoCampaignParticipantOutcome', - campaignId, - subscriptionId, - ); - setOutcome(result); - } catch { - setOutcome(null); - setHasError(true); - } finally { - setIsLoading(false); - } - }, [campaignId, subscriptionId, isOptedIn]); - - useEffect(() => { - fetchOutcome(); - }, [fetchOutcome]); - - return { outcome, isLoading, hasError }; + return useCampaignParticipantOutcome( + campaignId, + { messengerAction: 'RewardsController:getOndoCampaignParticipantOutcome' }, + ); } export default useOndoCampaignParticipantOutcome; diff --git a/app/components/UI/Rewards/hooks/useOndoLeaderboardPositionDisplay.test.ts b/app/components/UI/Rewards/hooks/useOndoLeaderboardPositionDisplay.test.ts new file mode 100644 index 00000000000..78dc8e9dac3 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useOndoLeaderboardPositionDisplay.test.ts @@ -0,0 +1,284 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useOndoLeaderboardPositionDisplay } from './useOndoLeaderboardPositionDisplay'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; +import { isCampaignIneligible } from '../utils/ondoCampaignConstants'; +import type { + CampaignDto, + CampaignLeaderboardPositionDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('../components/Campaigns/CampaignTile.utils', () => ({ + getCampaignStatus: jest.fn(), +})); + +jest.mock('../utils/ondoCampaignConstants', () => ({ + isCampaignIneligible: jest.fn(), +})); + +jest.mock('../components/Campaigns/OndoLeaderboard.utils', () => ({ + formatTierDisplayName: (tier: string) => `${tier}_DISPLAY`, +})); + +jest.mock('../utils/formatUtils', () => ({ + formatPercentChange: (value: string) => `+${value}%`, + getPortfolioReturnColor: (pnl?: string) => + pnl && parseFloat(pnl) < 0 ? 'text-error-default' : 'text-default', +})); + +const mockGetCampaignStatus = getCampaignStatus as jest.MockedFunction< + typeof getCampaignStatus +>; +const mockIsCampaignIneligible = isCampaignIneligible as jest.MockedFunction< + typeof isCampaignIneligible +>; + +const buildCampaign = (overrides: Partial = {}): CampaignDto => ({ + id: 'campaign-1', + type: 'ONDO_HOLDING' as never, + name: 'Test Campaign', + startDate: '2024-01-01T00:00:00Z', + endDate: '2099-12-31T23:59:59Z', + termsAndConditions: null, + excludedRegions: [], + details: { + howItWorks: { title: '', description: '', steps: [] }, + tiers: [{ name: 'STARTER', minNetDeposit: 500 }], + }, + featured: false, + showUpcomingDate: false, + ...overrides, +}); + +const buildPosition = ( + overrides: Partial = {}, +): CampaignLeaderboardPositionDto => ({ + rank: 5, + projectedTier: 'STARTER', + qualified: true, + qualifiedDays: 10, + totalInTier: 100, + rateOfReturn: 0.1, + currentUsdValue: 12500, + totalUsdDeposited: 10000, + netDeposit: 8500, + neighbors: [], + computedAt: '2024-01-01T00:00:00Z', + ...overrides, +}); + +describe('useOndoLeaderboardPositionDisplay', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetCampaignStatus.mockReturnValue('active'); + mockIsCampaignIneligible.mockReturnValue(false); + }); + + describe('isCampaignComplete', () => { + it('returns true when getCampaignStatus returns complete', () => { + mockGetCampaignStatus.mockReturnValue('complete'); + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ + campaign: buildCampaign(), + position: null, + }), + ); + expect(result.current.isCampaignComplete).toBe(true); + }); + + it('returns false when getCampaignStatus returns active', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ + campaign: buildCampaign(), + position: null, + }), + ); + expect(result.current.isCampaignComplete).toBe(false); + }); + + it('returns false when campaign is null', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ campaign: null, position: null }), + ); + expect(result.current.isCampaignComplete).toBe(false); + }); + }); + + describe('isPending and isQualified', () => { + it('isPending is true when position.qualified is false', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ + campaign: null, + position: buildPosition({ qualified: false }), + }), + ); + expect(result.current.isPending).toBe(true); + expect(result.current.isQualified).toBe(false); + }); + + it('isQualified is true when position.qualified is true', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ + campaign: null, + position: buildPosition({ qualified: true }), + }), + ); + expect(result.current.isPending).toBe(false); + expect(result.current.isQualified).toBe(true); + }); + + it('both are false when position is null', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ campaign: null, position: null }), + ); + expect(result.current.isPending).toBe(false); + expect(result.current.isQualified).toBe(false); + }); + }); + + describe('isIneligible', () => { + it('delegates to isCampaignIneligible', () => { + mockIsCampaignIneligible.mockReturnValue(true); + const campaign = buildCampaign(); + const position = buildPosition({ qualified: false }); + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ campaign, position }), + ); + expect(result.current.isIneligible).toBe(true); + expect(mockIsCampaignIneligible).toHaveBeenCalledWith( + campaign, + position.qualified, + ); + }); + + it('returns false when isCampaignIneligible returns false', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ + campaign: buildCampaign(), + position: buildPosition(), + }), + ); + expect(result.current.isIneligible).toBe(false); + }); + }); + + describe('rankValue', () => { + it('returns dash when position is null', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ campaign: null, position: null }), + ); + expect(result.current.rankValue).toBe('-'); + }); + + it('returns dash when isIneligible is true', () => { + mockIsCampaignIneligible.mockReturnValue(true); + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ + campaign: buildCampaign(), + position: buildPosition({ rank: 3 }), + }), + ); + expect(result.current.rankValue).toBe('-'); + }); + + it('returns zero-padded rank when eligible with position', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ + campaign: buildCampaign(), + position: buildPosition({ rank: 7 }), + }), + ); + expect(result.current.rankValue).toBe('07'); + }); + + it('does not pad two-digit ranks', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ + campaign: buildCampaign(), + position: buildPosition({ rank: 42 }), + }), + ); + expect(result.current.rankValue).toBe('42'); + }); + }); + + describe('tierValue', () => { + it('returns dash when position is null', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ campaign: null, position: null }), + ); + expect(result.current.tierValue).toBe('-'); + }); + + it('returns dash when isIneligible is true', () => { + mockIsCampaignIneligible.mockReturnValue(true); + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ + campaign: buildCampaign(), + position: buildPosition({ projectedTier: 'MID' }), + }), + ); + expect(result.current.tierValue).toBe('-'); + }); + + it('returns formatted tier display name when eligible with position', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ + campaign: buildCampaign(), + position: buildPosition({ projectedTier: 'MID' }), + }), + ); + expect(result.current.tierValue).toBe('MID_DISPLAY'); + }); + }); + + describe('returnValue', () => { + it('returns undefined when portfolioPnlPercent is undefined', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ campaign: null, position: null }), + ); + expect(result.current.returnValue).toBeUndefined(); + }); + + it('returns formatted value when portfolioPnlPercent is provided', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ + campaign: null, + position: null, + portfolioPnlPercent: '0.05', + }), + ); + expect(result.current.returnValue).toBe('+0.05%'); + }); + }); + + describe('returnColor', () => { + it('returns TextDefault when portfolioPnlPercent is undefined', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ campaign: null, position: null }), + ); + expect(result.current.returnColor).toBe('text-default'); + }); + + it('returns ErrorDefault when portfolioPnlPercent is negative', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ + campaign: null, + position: null, + portfolioPnlPercent: '-0.05', + }), + ); + expect(result.current.returnColor).toBe('text-error-default'); + }); + + it('returns TextDefault when portfolioPnlPercent is positive', () => { + const { result } = renderHook(() => + useOndoLeaderboardPositionDisplay({ + campaign: null, + position: null, + portfolioPnlPercent: '0.10', + }), + ); + expect(result.current.returnColor).toBe('text-default'); + }); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useOndoLeaderboardPositionDisplay.ts b/app/components/UI/Rewards/hooks/useOndoLeaderboardPositionDisplay.ts new file mode 100644 index 00000000000..2f793d0be41 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useOndoLeaderboardPositionDisplay.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; +import { TextColor } from '@metamask/design-system-react-native'; +import type { + CampaignDto, + CampaignLeaderboardPositionDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { formatTierDisplayName } from '../components/Campaigns/OndoLeaderboard.utils'; +import { + formatPercentChange, + getPortfolioReturnColor, +} from '../utils/formatUtils'; +import { isCampaignIneligible } from '../utils/ondoCampaignConstants'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; + +interface UseOndoLeaderboardPositionDisplayParams { + campaign: CampaignDto | null; + position: CampaignLeaderboardPositionDto | null; + portfolioPnlPercent?: string; +} + +interface UseOndoLeaderboardPositionDisplayResult { + isCampaignComplete: boolean; + isPending: boolean; + isQualified: boolean; + isIneligible: boolean; + rankValue: string; + tierValue: string; + returnValue: string | undefined; + returnColor: TextColor; +} + +export const useOndoLeaderboardPositionDisplay = ({ + campaign, + position, + portfolioPnlPercent, +}: UseOndoLeaderboardPositionDisplayParams): UseOndoLeaderboardPositionDisplayResult => { + const isCampaignComplete = + campaign != null && getCampaignStatus(campaign) === 'complete'; + const isPending = position != null && !position.qualified; + const isQualified = position != null && position.qualified; + const isIneligible = useMemo( + () => isCampaignIneligible(campaign, position?.qualified), + [campaign, position], + ); + const rankValue = + isIneligible || !position ? '-' : String(position.rank).padStart(2, '0'); + const tierValue = + isIneligible || !position + ? '-' + : formatTierDisplayName(position.projectedTier); + const returnValue = portfolioPnlPercent + ? formatPercentChange(portfolioPnlPercent) + : undefined; + const returnColor = getPortfolioReturnColor(portfolioPnlPercent); + return { + isCampaignComplete, + isPending, + isQualified, + isIneligible, + rankValue, + tierValue, + returnValue, + returnColor, + }; +}; diff --git a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts index 73d74fc6fa7..217d284cb64 100644 --- a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts +++ b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts @@ -1,563 +1,98 @@ import { renderHook } from '@testing-library/react-hooks'; -import { useContext } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { useFocusEffect, useNavigation } from '@react-navigation/native'; -import { - playSuccessNotification, - playWarningNotification, -} from '../../../../util/haptics'; import { useOndoOutcomeToast } from './useOndoOutcomeToast'; -import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards'; -import { - selectCampaigns, - selectDismissedCampaignOutcomeToasts, -} from '../../../../reducers/rewards/selectors'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; import { useOndoCampaignParticipantOutcome } from './useOndoCampaignParticipantOutcome'; import { CampaignType, - type OndoGmCampaignParticipantOutcomeDto, + type CampaignDto, } from '../../../../core/Engine/controllers/rewards-controller/types'; import Routes from '../../../../constants/navigation/Routes'; -import { - ToastVariants, - ButtonIconVariant, -} from '../../../../component-library/components/Toast/Toast.types'; -import { IconName } from '../../../../component-library/components/Icons/Icon'; - -jest.mock('react', () => ({ - ...jest.requireActual('react'), - useContext: jest.fn(), - useCallback: jest.fn((fn) => fn), - useMemo: jest.fn((fn) => fn()), -})); - -jest.mock('react-redux', () => ({ - useDispatch: jest.fn(), - useSelector: jest.fn(), -})); - -jest.mock('@react-navigation/native', () => ({ - useFocusEffect: jest.fn(), - useNavigation: jest.fn(), -})); - -jest.mock('../../../../util/haptics', () => ({ - playSuccessNotification: jest.fn(), - playWarningNotification: jest.fn(), -})); - -jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string, params?: Record) => { - if (params?.campaignName) return `${key}:${params.campaignName}`; - return key; - }), -})); - -jest.mock('../../../../util/theme', () => { - const actual = jest.requireActual('../../../../util/theme'); - return { - ...actual, - useAppThemeFromContext: () => actual.mockTheme, - }; -}); -jest.mock('../../../../reducers/rewards', () => ({ - dismissCampaignOutcomeToast: jest.fn(), -})); - -jest.mock('../../../../reducers/rewards/selectors', () => ({ - selectCampaigns: jest.fn(), - selectDismissedCampaignOutcomeToasts: jest.fn(), -})); - -jest.mock('../../../../selectors/rewards', () => ({ - selectRewardsSubscriptionId: jest.fn(), +jest.mock('./useCampaignOutcomeToast', () => ({ + useCampaignOutcomeToast: jest.fn(), })); jest.mock('./useOndoCampaignParticipantOutcome', () => ({ useOndoCampaignParticipantOutcome: jest.fn(), })); -const mockDispatch = jest.fn(); -const mockNavigate = jest.fn(); -const mockShowToast = jest.fn(); -const mockCloseToast = jest.fn(); -const mockToastRef = { - current: { showToast: mockShowToast, closeToast: mockCloseToast }, -}; - -const mockUseDispatch = useDispatch as jest.MockedFunction; -const mockUseSelector = useSelector as jest.MockedFunction; -const mockUseFocusEffect = useFocusEffect as jest.MockedFunction< - typeof useFocusEffect ->; -const mockUseNavigation = useNavigation as jest.MockedFunction< - typeof useNavigation ->; -const mockUseOndoCampaignParticipantOutcome = - useOndoCampaignParticipantOutcome as jest.MockedFunction< - typeof useOndoCampaignParticipantOutcome +const mockUseCampaignOutcomeToast = + useCampaignOutcomeToast as jest.MockedFunction< + typeof useCampaignOutcomeToast >; -const mockDismissCampaignOutcomeToast = - dismissCampaignOutcomeToast as jest.MockedFunction< - typeof dismissCampaignOutcomeToast - >; - -const SUBSCRIPTION_ID = 'sub-123'; -const CAMPAIGN_ID = 'campaign-456'; -const CAMPAIGN_NAME = 'Ondo Test Campaign'; -function makeParticipantOutcome( - options: Pick & { - winnerVerificationCode?: string | null; - subscriptionId?: string; - }, -): OndoGmCampaignParticipantOutcomeDto { - return { - subscriptionId: options.subscriptionId ?? SUBSCRIPTION_ID, - outcomeStatus: options.outcomeStatus, - winnerVerificationCode: options.winnerVerificationCode, - }; -} +const CAMPAIGN_ID = 'campaign-123'; +const CAMPAIGN_NAME = 'Ondo Campaign'; -const makeCompletedOndoCampaign = ( - id = CAMPAIGN_ID, - endDate = '2025-01-01T00:00:00Z', -) => ({ +const makeCampaign = (id = CAMPAIGN_ID): CampaignDto => ({ id, name: CAMPAIGN_NAME, type: CampaignType.ONDO_HOLDING, - endDate, - startDate: '2024-01-01T00:00:00Z', + endDate: '2025-01-01', + startDate: '2024-01-01', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: false, + showUpcomingDate: false, }); -function setupDefaults({ - campaigns = [], - dismissed = {}, - subscriptionId = SUBSCRIPTION_ID, - outcome = null, -}: { - campaigns?: ReturnType[] | undefined; - dismissed?: Record; - subscriptionId?: string | null; - outcome?: OndoGmCampaignParticipantOutcomeDto | null; -} = {}) { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectCampaigns) return campaigns ?? []; - if (selector === selectDismissedCampaignOutcomeToasts) return dismissed; - if (selector === selectRewardsSubscriptionId) return subscriptionId; - return undefined; - }); - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome, - isLoading: false, - hasError: false, - }); -} - describe('useOndoOutcomeToast', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseDispatch.mockReturnValue(mockDispatch); - mockUseNavigation.mockReturnValue({ navigate: mockNavigate } as never); - (useContext as jest.Mock).mockReturnValue({ toastRef: mockToastRef }); - mockUseFocusEffect.mockImplementation((cb) => { - cb(); - }); - mockDismissCampaignOutcomeToast.mockReturnValue({ - type: 'rewards/dismissCampaignOutcomeToast', - } as never); - }); - - describe('targetCampaign selection', () => { - it('passes undefined to useOndoCampaignParticipantOutcome when no campaigns', () => { - setupDefaults({ campaigns: [] }); - renderHook(() => useOndoOutcomeToast()); - expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith( - undefined, - ); - }); - - it('passes undefined to useOndoCampaignParticipantOutcome when campaigns are missing from persisted state', () => { - setupDefaults({ campaigns: undefined }); - renderHook(() => useOndoOutcomeToast()); - expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith( - undefined, - ); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('passes completed ONDO campaign id to useOndoCampaignParticipantOutcome', () => { - const campaign = makeCompletedOndoCampaign(); - setupDefaults({ campaigns: [campaign] }); - renderHook(() => useOndoOutcomeToast()); - expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith( - CAMPAIGN_ID, - ); - }); - - it('selects the most recently ended campaign when multiple exist', () => { - const older = makeCompletedOndoCampaign( - 'campaign-old', - '2024-06-01T00:00:00Z', - ); - const newer = makeCompletedOndoCampaign( - 'campaign-new', - '2025-01-01T00:00:00Z', - ); - setupDefaults({ campaigns: [older, newer] }); - renderHook(() => useOndoOutcomeToast()); - expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith( - 'campaign-new', - ); - }); - }); - - describe('variant derivation', () => { - it('does not show toast when outcome is null', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: null, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('derives winner_verify when outcome has verification code and is not finalized', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'WINNER-XYZ', - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ iconName: IconName.Star }), - ); - }); - - it('derives participant_no_winner when outcome is finalized with no verification code', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ iconName: IconName.Info }), - ); - }); - - it('does not show toast when outcome is finalized with a verification code', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: 'WINNER-XYZ', - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('does not show toast when outcome is pending with no verification code', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); }); - describe('dismissal check', () => { - it('does not show toast when winner_verify toast was previously dismissed', () => { - const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:winner_verify`; - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - dismissed: { [key]: true }, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('does not show toast when participant_no_winner toast was previously dismissed', () => { - const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:participant_no_winner`; - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - dismissed: { [key]: true }, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('shows toast when a different variant was dismissed', () => { - const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:participant_no_winner`; - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - dismissed: { [key]: true }, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).toHaveBeenCalled(); - }); + it('calls useCampaignOutcomeToast with ONDO_HOLDING campaign type', () => { + renderHook(() => useOndoOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + campaignType: CampaignType.ONDO_HOLDING, + }), + ); }); - describe('toast configuration', () => { - it('shows winner_verify toast with correct config', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ - variant: ToastVariants.Icon, - iconName: IconName.Star, - backgroundColor: 'transparent', - hasNoTimeout: true, - labelOptions: [ - { - label: 'rewards.ondo_outcome_toast.winner_verify.title', - isBold: true, - }, - ], - descriptionOptions: { - description: `rewards.ondo_outcome_toast.winner_verify.description:${CAMPAIGN_NAME}`, - }, - linkButtonOptions: expect.objectContaining({ - label: 'rewards.ondo_outcome_toast.winner_verify.cta', - }), - closeButtonOptions: expect.objectContaining({ - variant: ButtonIconVariant.Icon, - iconName: IconName.Close, - }), - }), - ); - }); - - it('shows participant_no_winner toast with correct config', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ - variant: ToastVariants.Icon, - iconName: IconName.Info, - backgroundColor: 'transparent', - hasNoTimeout: true, - labelOptions: [ - { - label: 'rewards.ondo_outcome_toast.participant_no_winner.title', - isBold: true, - }, - ], - descriptionOptions: { - description: `rewards.ondo_outcome_toast.participant_no_winner.description:${CAMPAIGN_NAME}`, - }, - linkButtonOptions: expect.objectContaining({ - label: 'rewards.ondo_outcome_toast.participant_no_winner.cta', - }), - }), - ); - }); - - it('fires Success haptic for winner_verify', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(playSuccessNotification).toHaveBeenCalled(); - }); - - it('fires Warning haptic for participant_no_winner', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(playWarningNotification).toHaveBeenCalled(); - }); + it('passes useOndoCampaignParticipantOutcome as the useOutcome function', () => { + renderHook(() => useOndoOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + useOutcome: useOndoCampaignParticipantOutcome, + }), + ); }); - describe('cleanup on blur', () => { - it('calls closeToast in the cleanup function when screen blurs', () => { - let cleanupFn: (() => void) | undefined; - mockUseFocusEffect.mockImplementation((cb) => { - const cleanup = cb(); - if (typeof cleanup === 'function') cleanupFn = cleanup; - }); - - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - expect(cleanupFn).toBeDefined(); - cleanupFn?.(); - expect(mockCloseToast).toHaveBeenCalledTimes(1); - }); - - it('does not return a cleanup function when variant is null', () => { - let cleanupFn: (() => void) | undefined; - mockUseFocusEffect.mockImplementation((cb) => { - const result = cb(); - if (typeof result === 'function') cleanupFn = result; - }); - - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: null, - }); - renderHook(() => useOndoOutcomeToast()); - - expect(cleanupFn).toBeUndefined(); - expect(mockCloseToast).not.toHaveBeenCalled(); + it('getWinnerNavigation returns ONDO winning view route with campaignId and campaignName', () => { + renderHook(() => useOndoOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, + params: { campaignId: CAMPAIGN_ID, campaignName: CAMPAIGN_NAME }, }); }); - describe('handleDismiss', () => { - it('dispatches dismissCampaignOutcomeToast and closes toast when close button is pressed', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - const closeButtonOptions = - mockShowToast.mock.calls[0][0].closeButtonOptions; - closeButtonOptions.onPress(); - - expect(mockDispatch).toHaveBeenCalledWith( - mockDismissCampaignOutcomeToast.mock.results[0]?.value, - ); - expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ - campaignId: CAMPAIGN_ID, - subscriptionId: SUBSCRIPTION_ID, - variant: 'winner_verify', - }); - expect(mockCloseToast).toHaveBeenCalled(); + it('getWinnerNavigation uses empty string for campaignName when name is null', () => { + renderHook(() => useOndoOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation({ + ...makeCampaign(), + name: null as unknown as string, }); - }); - - describe('handleCta', () => { - it('navigates to winning view and dismisses for winner_verify', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - const linkButtonOptions = - mockShowToast.mock.calls[0][0].linkButtonOptions; - linkButtonOptions.onPress(); - - expect(mockNavigate).toHaveBeenCalledWith( - Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, - { campaignId: CAMPAIGN_ID }, - ); - expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ - campaignId: CAMPAIGN_ID, - subscriptionId: SUBSCRIPTION_ID, - variant: 'winner_verify', - }); - }); - - it('navigates to campaign details view and dismisses for participant_no_winner', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - - const linkButtonOptions = - mockShowToast.mock.calls[0][0].linkButtonOptions; - linkButtonOptions.onPress(); - - expect(mockNavigate).toHaveBeenCalledWith( - Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, - { campaignId: CAMPAIGN_ID }, - ); - expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ - campaignId: CAMPAIGN_ID, - subscriptionId: SUBSCRIPTION_ID, - variant: 'participant_no_winner', - }); + expect(nav.params).toEqual({ + campaignId: CAMPAIGN_ID, + campaignName: '', }); }); - describe('edge cases', () => { - it('does not show toast when subscriptionId is missing', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - subscriptionId: null, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('handles null toastRef gracefully', () => { - (useContext as jest.Mock).mockReturnValue({ toastRef: null }); - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - expect(() => renderHook(() => useOndoOutcomeToast())).not.toThrow(); + it('getNonWinnerNavigation returns ONDO campaign details route', () => { + renderHook(() => useOndoOutcomeToast()); + const { getNonWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getNonWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: CAMPAIGN_ID }, }); }); }); diff --git a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts index 87c295cf1f7..2a736f931c8 100644 --- a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts +++ b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts @@ -1,160 +1,21 @@ -import { useCallback, useContext, useMemo } from 'react'; -import { useFocusEffect, useNavigation } from '@react-navigation/native'; -import { useDispatch, useSelector } from 'react-redux'; -import { - playSuccessNotification, - playWarningNotification, -} from '../../../../util/haptics'; -import Routes from '../../../../constants/navigation/Routes'; -import { strings } from '../../../../../locales/i18n'; -import { ToastContext } from '../../../../component-library/components/Toast'; -import { - ButtonIconVariant, - ToastVariants, -} from '../../../../component-library/components/Toast/Toast.types'; -import { IconName } from '../../../../component-library/components/Icons/Icon'; -import { useAppThemeFromContext } from '../../../../util/theme'; import { CampaignType } from '../../../../core/Engine/controllers/rewards-controller/types'; -import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; -import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards'; -import { - selectCampaigns, - selectDismissedCampaignOutcomeToasts, -} from '../../../../reducers/rewards/selectors'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import Routes from '../../../../constants/navigation/Routes'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; import { useOndoCampaignParticipantOutcome } from './useOndoCampaignParticipantOutcome'; -export type OutcomeToastVariant = 'winner_verify' | 'participant_no_winner'; - export function useOndoOutcomeToast(): void { - const dispatch = useDispatch(); - const { toastRef } = useContext(ToastContext); - const theme = useAppThemeFromContext(); - const navigation = useNavigation(); - - const subscriptionId = useSelector(selectRewardsSubscriptionId); - const campaigns = useSelector(selectCampaigns); - const dismissed = useSelector(selectDismissedCampaignOutcomeToasts); - - const targetCampaign = useMemo(() => { - const completed = campaigns - .filter( - (c) => - c.type === CampaignType.ONDO_HOLDING && - getCampaignStatus(c) === 'complete', - ) - .sort( - (a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime(), - ); - return completed[0] ?? null; - }, [campaigns]); - - const { outcome } = useOndoCampaignParticipantOutcome(targetCampaign?.id); - - const variant = useMemo((): OutcomeToastVariant | null => { - if (!outcome) return null; - if ( - outcome.winnerVerificationCode && - outcome.outcomeStatus !== 'finalized' - ) { - return 'winner_verify'; - } - if ( - outcome.outcomeStatus === 'finalized' && - !outcome.winnerVerificationCode - ) { - return 'participant_no_winner'; - } - return null; - }, [outcome]); - - const isDismissed = useMemo(() => { - if (!variant || !targetCampaign || !subscriptionId) return true; - const key = `${targetCampaign.id}:${subscriptionId}:${variant}`; - return dismissed[key] === true; - }, [variant, targetCampaign, subscriptionId, dismissed]); - - const handleDismiss = useCallback(() => { - if (!variant || !targetCampaign || !subscriptionId) return; - dispatch( - dismissCampaignOutcomeToast({ - campaignId: targetCampaign.id, - subscriptionId, - variant, - }), - ); - toastRef?.current?.closeToast(); - }, [variant, targetCampaign, subscriptionId, dispatch, toastRef]); - - const handleCta = useCallback(() => { - if (!targetCampaign || !variant) return; - handleDismiss(); - if (variant === 'winner_verify') { - navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, { - campaignId: targetCampaign.id, - }); - } else { - navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, { - campaignId: targetCampaign.id, - }); - } - }, [variant, targetCampaign, handleDismiss, navigation]); - - useFocusEffect( - useCallback(() => { - if (!variant || isDismissed || !targetCampaign) return; - - const isWinner = variant === 'winner_verify'; - toastRef?.current?.showToast({ - variant: ToastVariants.Icon, - iconName: isWinner ? IconName.Star : IconName.Info, - iconColor: isWinner - ? theme.colors.warning.default - : theme.colors.success.default, - backgroundColor: 'transparent', - hasNoTimeout: true, - labelOptions: [ - { - label: strings(`rewards.ondo_outcome_toast.${variant}.title`), - isBold: true, - }, - ], - descriptionOptions: { - description: strings( - `rewards.ondo_outcome_toast.${variant}.description`, - { campaignName: targetCampaign.name }, - ), - }, - linkButtonOptions: { - label: strings(`rewards.ondo_outcome_toast.${variant}.cta`), - onPress: handleCta, - }, - closeButtonOptions: { - variant: ButtonIconVariant.Icon, - iconName: IconName.Close, - onPress: handleDismiss, - }, - }); - if (isWinner) { - playSuccessNotification(); - } else { - playWarningNotification(); - } - - return () => { - toastRef?.current?.closeToast(); - }; - }, [ - variant, - isDismissed, - targetCampaign, - toastRef, - theme.colors.warning.default, - theme.colors.success.default, - handleCta, - handleDismiss, - ]), - ); + useCampaignOutcomeToast({ + campaignType: CampaignType.ONDO_HOLDING, + useOutcome: useOndoCampaignParticipantOutcome, + getWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, + params: { campaignId: campaign.id, campaignName: campaign.name ?? '' }, + }), + getNonWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: campaign.id }, + }), + }); } export default useOndoOutcomeToast; diff --git a/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts b/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts index c94087723b0..e97add67a7e 100644 --- a/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts +++ b/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts @@ -1,10 +1,12 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useOptInToCampaign } from './useOptInToCampaign'; import Engine from '../../../../core/Engine'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { setCampaignParticipantStatus } from '../../../../reducers/rewards'; jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), useSelector: jest.fn(), })); @@ -16,10 +18,22 @@ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), })); +jest.mock('../../../../reducers/rewards', () => ({ + setCampaignParticipantStatus: jest.fn((payload) => ({ + type: 'rewards/setCampaignParticipantStatus', + payload, + })), +})); + const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< typeof Engine.controllerMessenger.call >; const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockSetCampaignParticipantStatus = + setCampaignParticipantStatus as unknown as jest.MockedFunction< + typeof setCampaignParticipantStatus + >; const SUB_ID = 'sub-123'; const CAMPAIGN_ID = 'camp-456'; @@ -33,8 +47,11 @@ function setupSelectors(subscriptionId: string | null) { } describe('useOptInToCampaign', () => { + const mockDispatch = jest.fn(); + beforeEach(() => { jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); }); it('returns null when subscriptionId is missing', async () => { @@ -63,6 +80,18 @@ describe('useOptInToCampaign', () => { CAMPAIGN_ID, SUB_ID, ); + expect(mockDispatch).toHaveBeenCalledWith( + setCampaignParticipantStatus({ + subscriptionId: SUB_ID, + campaignId: CAMPAIGN_ID, + status: STATUS, + }), + ); + expect(mockSetCampaignParticipantStatus).toHaveBeenCalledWith({ + subscriptionId: SUB_ID, + campaignId: CAMPAIGN_ID, + status: STATUS, + }); expect(returnValue).toEqual(STATUS); expect(result.current.isOptingIn).toBe(false); expect(result.current.optInError).toBeUndefined(); diff --git a/app/components/UI/Rewards/hooks/useOptInToCampaign.ts b/app/components/UI/Rewards/hooks/useOptInToCampaign.ts index b3852646997..a0533e7889f 100644 --- a/app/components/UI/Rewards/hooks/useOptInToCampaign.ts +++ b/app/components/UI/Rewards/hooks/useOptInToCampaign.ts @@ -1,7 +1,8 @@ import { useCallback, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { setCampaignParticipantStatus } from '../../../../reducers/rewards'; import type { CampaignParticipantStatusDto } from '../../../../core/Engine/controllers/rewards-controller/types'; export interface UseOptInToCampaignResult { @@ -22,6 +23,7 @@ export interface UseOptInToCampaignResult { */ export const useOptInToCampaign = (): UseOptInToCampaignResult => { const subscriptionId = useSelector(selectRewardsSubscriptionId); + const dispatch = useDispatch(); const [isOptingIn, setIsOptingIn] = useState(false); const [optInError, setOptInError] = useState(undefined); @@ -36,11 +38,19 @@ export const useOptInToCampaign = (): UseOptInToCampaignResult => { try { setIsOptingIn(true); setOptInError(undefined); - return await Engine.controllerMessenger.call( + const result = await Engine.controllerMessenger.call( 'RewardsController:optInToCampaign', campaignId, subscriptionId, ); + dispatch( + setCampaignParticipantStatus({ + subscriptionId, + campaignId, + status: result, + }), + ); + return result; } catch (error) { const message = error instanceof Error ? error.message : 'Opt-in failed'; @@ -50,7 +60,7 @@ export const useOptInToCampaign = (): UseOptInToCampaignResult => { setIsOptingIn(false); } }, - [subscriptionId], + [dispatch, subscriptionId], ); const clearOptInError = useCallback(() => setOptInError(undefined), []); diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts new file mode 100644 index 00000000000..44c7d775a5c --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts @@ -0,0 +1,98 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { usePerpsTradingCampaignEndedOutcomeToast } from './usePerpsTradingCampaignEndedOutcomeToast'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; +import { usePerpsTradingCampaignParticipantOutcome } from './usePerpsTradingCampaignParticipantOutcome'; +import { + CampaignType, + type CampaignDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import Routes from '../../../../constants/navigation/Routes'; + +jest.mock('./useCampaignOutcomeToast', () => ({ + useCampaignOutcomeToast: jest.fn(), +})); + +jest.mock('./usePerpsTradingCampaignParticipantOutcome', () => ({ + usePerpsTradingCampaignParticipantOutcome: jest.fn(), +})); + +const mockUseCampaignOutcomeToast = + useCampaignOutcomeToast as jest.MockedFunction< + typeof useCampaignOutcomeToast + >; + +const CAMPAIGN_ID = 'campaign-xyz'; +const CAMPAIGN_NAME = 'Perps Campaign'; + +const makeCampaign = (id = CAMPAIGN_ID, name = CAMPAIGN_NAME): CampaignDto => ({ + id, + name, + type: CampaignType.PERPS_TRADING, + endDate: '2025-01-01', + startDate: '2024-01-01', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: false, + showUpcomingDate: false, +}); + +describe('usePerpsTradingCampaignEndedOutcomeToast', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls useCampaignOutcomeToast with PERPS_TRADING campaign type', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + campaignType: CampaignType.PERPS_TRADING, + }), + ); + }); + + it('passes usePerpsTradingCampaignParticipantOutcome as the useOutcome function', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + useOutcome: usePerpsTradingCampaignParticipantOutcome, + }), + ); + }); + + it('getWinnerNavigation returns Perps winning view route with campaignId and campaignName', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + params: { campaignId: CAMPAIGN_ID, campaignName: CAMPAIGN_NAME }, + }); + }); + + it('getWinnerNavigation uses empty string for campaignName when name is null', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation({ + ...makeCampaign(), + name: null as unknown as string, + }); + expect(nav.params).toEqual({ + campaignId: CAMPAIGN_ID, + campaignName: '', + }); + }); + + it('getNonWinnerNavigation returns Perps details view route', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + const { getNonWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getNonWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: CAMPAIGN_ID }, + }); + }); +}); diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts new file mode 100644 index 00000000000..ae99229b1ba --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts @@ -0,0 +1,21 @@ +import { CampaignType } from '../../../../core/Engine/controllers/rewards-controller/types'; +import Routes from '../../../../constants/navigation/Routes'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; +import { usePerpsTradingCampaignParticipantOutcome } from './usePerpsTradingCampaignParticipantOutcome'; + +export function usePerpsTradingCampaignEndedOutcomeToast(): void { + useCampaignOutcomeToast({ + campaignType: CampaignType.PERPS_TRADING, + useOutcome: usePerpsTradingCampaignParticipantOutcome, + getWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + params: { campaignId: campaign.id, campaignName: campaign.name ?? '' }, + }), + getNonWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: campaign.id }, + }), + }); +} + +export default usePerpsTradingCampaignEndedOutcomeToast; diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts new file mode 100644 index 00000000000..35795726f9f --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts @@ -0,0 +1,68 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { usePerpsTradingCampaignParticipantOutcome } from './usePerpsTradingCampaignParticipantOutcome'; +import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome'; + +jest.mock('./useCampaignParticipantOutcome', () => ({ + useCampaignParticipantOutcome: jest.fn(), +})); + +const mockUseCampaignParticipantOutcome = + useCampaignParticipantOutcome as jest.MockedFunction< + typeof useCampaignParticipantOutcome + >; + +const CAMPAIGN_ID = 'campaign-xyz'; + +describe('usePerpsTradingCampaignParticipantOutcome', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, + }); + }); + + it('delegates to useCampaignParticipantOutcome with the Perps messenger action', () => { + renderHook(() => usePerpsTradingCampaignParticipantOutcome(CAMPAIGN_ID)); + + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith( + CAMPAIGN_ID, + { + messengerAction: + 'RewardsController:getPerpsTradingCampaignParticipantOutcome', + }, + ); + }); + + it('passes undefined campaignId through to the generic hook', () => { + renderHook(() => usePerpsTradingCampaignParticipantOutcome(undefined)); + + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith(undefined, { + messengerAction: + 'RewardsController:getPerpsTradingCampaignParticipantOutcome', + }); + }); + + it('returns the result from the generic hook', () => { + const mockOutcome = { + subscriptionId: 'sub-1', + outcomeStatus: 'pending' as const, + winnerVerificationCode: 'CODE', + rank: 3, + }; + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: mockOutcome, + isLoading: false, + hasError: false, + }); + + const { result } = renderHook(() => + usePerpsTradingCampaignParticipantOutcome(CAMPAIGN_ID), + ); + + expect(result.current.outcome).toEqual(mockOutcome); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); +}); diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts new file mode 100644 index 00000000000..255885f2823 --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts @@ -0,0 +1,22 @@ +import type { PerpsTradingCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { + useCampaignParticipantOutcome, + type UseCampaignParticipantOutcomeResult, +} from './useCampaignParticipantOutcome'; + +export type UsePerpsTradingCampaignParticipantOutcomeResult = + UseCampaignParticipantOutcomeResult; + +export function usePerpsTradingCampaignParticipantOutcome( + campaignId: string | undefined, +): UsePerpsTradingCampaignParticipantOutcomeResult { + return useCampaignParticipantOutcome( + campaignId, + { + messengerAction: + 'RewardsController:getPerpsTradingCampaignParticipantOutcome', + }, + ); +} + +export default usePerpsTradingCampaignParticipantOutcome; diff --git a/app/components/UI/Rewards/hooks/useRewardsNotificationsNudge.test.tsx b/app/components/UI/Rewards/hooks/useRewardsNotificationsNudge.test.tsx new file mode 100644 index 00000000000..d406b8ff7a6 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useRewardsNotificationsNudge.test.tsx @@ -0,0 +1,656 @@ +import React from 'react'; +import { AppState } from 'react-native'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react-native'; + +jest.mock('react-native', () => ({ + ...jest.requireActual('react-native'), + AppState: { addEventListener: jest.fn() }, +})); +const mockAppStateAddEventListener = AppState.addEventListener as jest.Mock; +import { useSelector } from 'react-redux'; +import { ToastContext } from '../../../../component-library/components/Toast'; +import { useRewardsNotificationsNudge } from './useRewardsNotificationsNudge'; +import { + selectIsMetamaskNotificationsEnabled, + selectIsMetaMaskPushNotificationsEnabled, +} from '../../../../selectors/notifications'; +import { isNotificationsFeatureEnabled } from '../../../../util/notifications/constants'; + +const mockShowToast = jest.fn(); +const mockEnableNotifications = jest.fn(); +const mockOriginalCloseButtonPress = jest.fn(); +const mockEnableNotificationsNudge = jest.fn( + (linkButtonOptions: { label: string; onPress: () => Promise }) => ({ + variant: 'Plain', + hasNoTimeout: true, + linkButtonOptions, + closeButtonOptions: { + onPress: mockOriginalCloseButtonPress, + }, + }), +); +const mockLoadingToast = jest.fn((title: string, subtitle?: string) => ({ + variant: 'loading', + title, + subtitle, +})); +const mockErrorToast = jest.fn((title: string) => ({ + variant: 'error', + title, +})); +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('./useRewardsToast', () => ({ + __esModule: true, + default: jest.fn(() => ({ + showToast: mockShowToast, + RewardsToastOptions: { + enableNotificationsNudge: mockEnableNotificationsNudge, + loading: mockLoadingToast, + error: mockErrorToast, + }, + })), +})); + +jest.mock('../../../../util/notifications/hooks/useNotifications', () => ({ + useEnableNotifications: jest.fn(() => ({ + enableNotifications: mockEnableNotifications, + })), +})); + +jest.mock('../../../../util/notifications/constants', () => ({ + isNotificationsFeatureEnabled: jest.fn(() => true), +})); + +jest.mock( + '../../../../util/notifications/services/NotificationService', + () => ({ + __esModule: true, + default: { openSystemSettings: jest.fn() }, + getPushPermission: jest.fn().mockResolvedValue('authorized'), + }), +); +const mockNotificationService = jest.requireMock( + '../../../../util/notifications/services/NotificationService', +); +const mockOpenSystemSettings = mockNotificationService.default + .openSystemSettings as jest.Mock; +const mockGetPushPermission = + mockNotificationService.getPushPermission as jest.Mock; + +jest.mock('../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const translations: Record = { + 'rewards.notifications_nudge.turn_on_button': 'Turn on', + 'rewards.notifications_nudge.loading': 'Enabling notifications...', + 'rewards.notifications_nudge.loading_description': + 'This may take a moment.', + 'rewards.notifications_nudge.enable_error': + 'Failed to enable notifications', + }; + return translations[key] || key; + }, +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockCloseToast = jest.fn(); + +function mockSelectors({ + notificationsEnabled, +}: { + notificationsEnabled: boolean; +}) { + mockUseSelector.mockImplementation((selector) => { + if ( + selector === selectIsMetamaskNotificationsEnabled || + selector === selectIsMetaMaskPushNotificationsEnabled + ) { + return notificationsEnabled; + } + return undefined; + }); +} + +function renderNudgeHook(options?: { + enabled?: boolean; + onNotificationsEnabled?: () => void; +}) { + const toastRef = { + current: { + showToast: jest.fn(), + closeToast: mockCloseToast, + }, + }; + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement( + ToastContext.Provider, + { value: { toastRef } }, + children, + ); + + return renderHook(() => useRewardsNotificationsNudge(options), { wrapper }); +} + +describe('useRewardsNotificationsNudge', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockEnableNotifications.mockResolvedValue(undefined); + mockGetPushPermission.mockResolvedValue('authorized'); + mockSelectors({ notificationsEnabled: true }); + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(true); + mockAppStateAddEventListener.mockReturnValue({ remove: jest.fn() }); + }); + + it('returns enabled state when notifications and push are enabled', () => { + const { result } = renderNudgeHook(); + + expect(result.current.areNotificationsEnabled).toBe(true); + expect(result.current.canPromptToEnableNotifications).toBe(true); + expect(result.current.shouldPromptToEnableNotifications).toBe(false); + }); + + it('returns prompt state when notifications are disabled and feature flag is on', () => { + mockSelectors({ notificationsEnabled: false }); + + const { result } = renderNudgeHook(); + + expect(result.current.areNotificationsEnabled).toBe(false); + expect(result.current.canPromptToEnableNotifications).toBe(true); + expect(result.current.shouldPromptToEnableNotifications).toBe(true); + }); + + it('shows the notifications nudge and enables notifications from its CTA', async () => { + mockSelectors({ notificationsEnabled: false }); + const { result } = renderNudgeHook(); + + let didShow = false; + act(() => { + didShow = result.current.showEnableNotificationsNudge(); + }); + + expect(didShow).toBe(true); + expect(mockEnableNotificationsNudge).toHaveBeenCalledWith({ + label: 'Turn on', + onPress: expect.any(Function), + }); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: 'Plain', + closeButtonOptions: expect.objectContaining({ + onPress: expect.any(Function), + }), + }), + ); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockEnableNotifications).toHaveBeenCalledTimes(1); + expect(mockCloseToast).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenCalledTimes(2); + expect(mockShowToast).toHaveBeenLastCalledWith( + expect.objectContaining({ variant: 'loading' }), + ); + }); + + it('shows loading toast when Turn On CTA is pressed', async () => { + mockSelectors({ notificationsEnabled: false }); + const { result } = renderNudgeHook(); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockShowToast).toHaveBeenCalledTimes(2); + expect(mockShowToast).toHaveBeenLastCalledWith( + expect.objectContaining({ variant: 'loading' }), + ); + }); + + it('shows error toast if enableNotifications fails', async () => { + mockSelectors({ notificationsEnabled: false }); + mockEnableNotifications.mockRejectedValue(new Error('failed')); + const { result } = renderNudgeHook(); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockCloseToast).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenLastCalledWith( + expect.objectContaining({ variant: 'error' }), + ); + }); + + it('calls onNotificationsEnabled callback after successful enable', async () => { + mockSelectors({ notificationsEnabled: false }); + const onNotificationsEnabled = jest.fn(); + const { result } = renderNudgeHook({ onNotificationsEnabled }); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockEnableNotifications).toHaveBeenCalledTimes(1); + expect(onNotificationsEnabled).toHaveBeenCalledTimes(1); + }); + + it('does not call onNotificationsEnabled callback when enableNotifications fails', async () => { + mockSelectors({ notificationsEnabled: false }); + mockEnableNotifications.mockRejectedValue(new Error('failed')); + const onNotificationsEnabled = jest.fn(); + const { result } = renderNudgeHook({ onNotificationsEnabled }); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(onNotificationsEnabled).not.toHaveBeenCalled(); + }); + + it('does not call onNotificationsEnabled when push permission is denied', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetPushPermission.mockResolvedValue('denied'); + const onNotificationsEnabled = jest.fn(); + const { result } = renderNudgeHook({ onNotificationsEnabled }); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(onNotificationsEnabled).not.toHaveBeenCalled(); + }); + + it('runs deferred action immediately when notifications are already enabled', async () => { + const action = jest.fn(); + const { result } = renderNudgeHook(); + + let didRun = false; + await act(async () => { + didRun = await result.current.runAfterNotificationsEnabled(action); + }); + + expect(didRun).toBe(true); + expect(action).toHaveBeenCalledTimes(1); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('defers action until notifications become enabled', async () => { + let notificationsEnabled = false; + mockSelectors({ notificationsEnabled }); + const action = jest.fn(); + const { result, rerender } = renderNudgeHook(); + + let didRun = true; + await act(async () => { + didRun = await result.current.runAfterNotificationsEnabled(action); + }); + + expect(didRun).toBe(false); + expect(action).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledTimes(1); + + notificationsEnabled = true; + mockSelectors({ notificationsEnabled }); + rerender(); + + await waitFor(() => { + expect(action).toHaveBeenCalledTimes(1); + }); + expect(mockCloseToast).toHaveBeenCalledTimes(1); + }); + + it('shows loading toast before deferred action runs via effect', async () => { + let notificationsEnabled = false; + mockSelectors({ notificationsEnabled }); + const action = jest.fn(); + const { result, rerender } = renderNudgeHook(); + + await act(async () => { + await result.current.runAfterNotificationsEnabled(action); + }); + + notificationsEnabled = true; + mockSelectors({ notificationsEnabled }); + rerender(); + + await waitFor(() => { + expect(action).toHaveBeenCalledTimes(1); + }); + + // nudge (1st call) + loading in effect (2nd call) + expect(mockShowToast).toHaveBeenCalledTimes(2); + expect(mockShowToast).toHaveBeenLastCalledWith( + expect.objectContaining({ variant: 'loading' }), + ); + }); + + it('does not run or prompt when notifications are disabled and feature flag is off', async () => { + mockSelectors({ notificationsEnabled: false }); + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(false); + const action = jest.fn(); + const { result } = renderNudgeHook(); + + let didRun = true; + await act(async () => { + didRun = await result.current.runAfterNotificationsEnabled(action); + }); + + expect(didRun).toBe(false); + expect(action).not.toHaveBeenCalled(); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('returns false from showEnableNotificationsNudge when the nudge is unavailable', () => { + mockSelectors({ notificationsEnabled: false }); + (isNotificationsFeatureEnabled as jest.Mock).mockReturnValue(false); + const { result } = renderNudgeHook(); + + let didShow = true; + act(() => { + didShow = result.current.showEnableNotificationsNudge(); + }); + + expect(didShow).toBe(false); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('clears pending deferred action when the nudge is dismissed', async () => { + let notificationsEnabled = false; + mockSelectors({ notificationsEnabled }); + const action = jest.fn(); + const { result, rerender } = renderNudgeHook(); + + await act(async () => { + await result.current.runAfterNotificationsEnabled(action); + }); + + const toastConfig = mockShowToast.mock.calls[0][0] as { + closeButtonOptions: { onPress: () => void }; + }; + act(() => { + toastConfig.closeButtonOptions.onPress(); + }); + + notificationsEnabled = true; + mockSelectors({ notificationsEnabled }); + rerender(); + + expect(mockOriginalCloseButtonPress).toHaveBeenCalledTimes(1); + expect(action).not.toHaveBeenCalled(); + }); + + it('closes toast and opens Settings when push permission is denied before Turn On', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetPushPermission.mockResolvedValue('denied'); + const { result } = renderNudgeHook(); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockGetPushPermission).toHaveBeenCalledTimes(1); + expect(mockEnableNotifications).not.toHaveBeenCalled(); + expect(mockCloseToast).toHaveBeenCalledTimes(1); + expect(mockOpenSystemSettings).toHaveBeenCalledTimes(1); + expect(mockAppStateAddEventListener).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ); + }); + + it('calls enableNotifications after user returns from OS settings with permission granted', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetPushPermission + .mockResolvedValueOnce('denied') + .mockResolvedValueOnce('authorized'); + + let capturedHandler: ((state: string) => Promise) | null = null; + const mockRemove = jest.fn(); + mockAppStateAddEventListener.mockImplementation( + (_event: string, handler: (state: string) => Promise) => { + capturedHandler = handler; + return { remove: mockRemove }; + }, + ); + + const { result } = renderNudgeHook(); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockOpenSystemSettings).toHaveBeenCalledTimes(1); + expect(capturedHandler).not.toBeNull(); + expect(mockEnableNotifications).not.toHaveBeenCalled(); + + await act(async () => { + await capturedHandler?.('active'); + }); + + expect(mockRemove).toHaveBeenCalledTimes(1); + expect(mockGetPushPermission).toHaveBeenCalledTimes(2); + expect(mockEnableNotifications).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenLastCalledWith( + expect.objectContaining({ variant: 'loading' }), + ); + }); + + it('does not call enableNotifications if permission is still denied after returning from OS settings', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetPushPermission.mockResolvedValue('denied'); + + let capturedHandler: ((state: string) => Promise) | null = null; + mockAppStateAddEventListener.mockImplementation( + (_event: string, handler: (state: string) => Promise) => { + capturedHandler = handler; + return { remove: jest.fn() }; + }, + ); + + const { result } = renderNudgeHook(); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + await act(async () => { + await capturedHandler?.('active'); + }); + + expect(mockEnableNotifications).not.toHaveBeenCalled(); + }); + + it('calls onNotificationsEnabled callback after returning from OS settings with permission granted', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetPushPermission + .mockResolvedValueOnce('denied') + .mockResolvedValueOnce('authorized'); + + let capturedHandler: ((state: string) => Promise) | null = null; + mockAppStateAddEventListener.mockImplementation( + (_event: string, handler: (state: string) => Promise) => { + capturedHandler = handler; + return { remove: jest.fn() }; + }, + ); + + const onNotificationsEnabled = jest.fn(); + const { result } = renderNudgeHook({ onNotificationsEnabled }); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + await act(async () => { + await capturedHandler?.('active'); + }); + + expect(mockEnableNotifications).toHaveBeenCalledTimes(1); + expect(onNotificationsEnabled).toHaveBeenCalledTimes(1); + }); + + it('ignores non-active AppState transitions after opening OS settings', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetPushPermission.mockResolvedValue('denied'); + + let capturedHandler: ((state: string) => Promise) | null = null; + mockAppStateAddEventListener.mockImplementation( + (_event: string, handler: (state: string) => Promise) => { + capturedHandler = handler; + return { remove: jest.fn() }; + }, + ); + + const { result } = renderNudgeHook(); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + await act(async () => { + await capturedHandler?.('background'); + }); + + expect(mockGetPushPermission).toHaveBeenCalledTimes(1); + expect(mockEnableNotifications).not.toHaveBeenCalled(); + }); + + it('does not open Settings when push permission is authorized before Turn On', async () => { + mockSelectors({ notificationsEnabled: false }); + mockGetPushPermission.mockResolvedValue('authorized'); + const { result } = renderNudgeHook(); + + act(() => { + result.current.showEnableNotificationsNudge(); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockCloseToast).toHaveBeenCalledTimes(1); + expect(mockOpenSystemSettings).not.toHaveBeenCalled(); + }); + + it('closes toast even when called from runAfterNotificationsEnabled flow', async () => { + mockSelectors({ notificationsEnabled: false }); + const action = jest.fn(); + const { result } = renderNudgeHook(); + + await act(async () => { + await result.current.runAfterNotificationsEnabled(action); + }); + + const linkButtonOptions = mockEnableNotificationsNudge.mock.calls[0][0] as { + onPress: () => Promise; + }; + await act(async () => { + await linkButtonOptions.onPress(); + }); + + expect(mockCloseToast).toHaveBeenCalledTimes(1); + }); + + it('closeEnableNotificationsNudge clears pending action and closes the toast', async () => { + let notificationsEnabled = false; + mockSelectors({ notificationsEnabled }); + const action = jest.fn(); + const { result, rerender } = renderNudgeHook(); + + await act(async () => { + await result.current.runAfterNotificationsEnabled(action); + }); + + act(() => { + result.current.closeEnableNotificationsNudge(); + }); + + notificationsEnabled = true; + mockSelectors({ notificationsEnabled }); + rerender(); + + expect(mockCloseToast).toHaveBeenCalledTimes(1); + expect(action).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useRewardsNotificationsNudge.ts b/app/components/UI/Rewards/hooks/useRewardsNotificationsNudge.ts new file mode 100644 index 00000000000..979066f98f0 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useRewardsNotificationsNudge.ts @@ -0,0 +1,259 @@ +import { useCallback, useContext, useEffect, useRef } from 'react'; +import { AppState } from 'react-native'; +import { useSelector } from 'react-redux'; +import { ToastContext } from '../../../../component-library/components/Toast'; +import { strings } from '../../../../../locales/i18n'; +import { + selectIsMetamaskNotificationsEnabled, + selectIsMetaMaskPushNotificationsEnabled, +} from '../../../../selectors/notifications'; +import { isNotificationsFeatureEnabled } from '../../../../util/notifications/constants'; +import { useEnableNotifications } from '../../../../util/notifications/hooks/useNotifications'; +import NotificationService, { + getPushPermission, +} from '../../../../util/notifications/services/NotificationService'; +import useRewardsToast from './useRewardsToast'; + +type NotificationsEnabledAction = () => Promise | void; + +interface UseRewardsNotificationsNudgeOptions { + enabled?: boolean; + onNotificationsEnabled?: () => void; +} + +export interface UseRewardsNotificationsNudgeReturn { + areNotificationsEnabled: boolean; + canPromptToEnableNotifications: boolean; + shouldPromptToEnableNotifications: boolean; + showEnableNotificationsNudge: () => boolean; + closeEnableNotificationsNudge: () => void; + runAfterNotificationsEnabled: ( + action: NotificationsEnabledAction, + ) => Promise; +} + +/** + * Shared Rewards notification nudge flow. + * + * Screens can either show the nudge directly, or defer an action until + * MetaMask notifications and push notifications are both enabled. + */ +export function useRewardsNotificationsNudge( + options: UseRewardsNotificationsNudgeOptions = {}, +): UseRewardsNotificationsNudgeReturn { + const { enabled = true, onNotificationsEnabled } = options; + const onNotificationsEnabledRef = useRef(onNotificationsEnabled); + onNotificationsEnabledRef.current = onNotificationsEnabled; + const isMetamaskNotificationsEnabled = useSelector( + selectIsMetamaskNotificationsEnabled, + ); + const isMetaMaskPushNotificationsEnabled = useSelector( + selectIsMetaMaskPushNotificationsEnabled, + ); + const { toastRef } = useContext(ToastContext); + const { showToast, RewardsToastOptions } = useRewardsToast(); + const { enableNotifications } = useEnableNotifications({ + nudgeEnablePush: true, + }); + const notificationsEnableInFlightRef = useRef(false); + const pendingActionRef = useRef(null); + const appStateSubscriptionRef = useRef | null>(null); + + const canPromptToEnableNotifications = isNotificationsFeatureEnabled(); + const areNotificationsEnabled = + isMetamaskNotificationsEnabled && isMetaMaskPushNotificationsEnabled; + const shouldPromptToEnableNotifications = + enabled && canPromptToEnableNotifications && !areNotificationsEnabled; + + // Kept in sync on every render so closeEnableNotificationsNudge can read the + // latest value without being recreated whenever areNotificationsEnabled changes. + const areNotificationsEnabledRef = useRef(areNotificationsEnabled); + areNotificationsEnabledRef.current = areNotificationsEnabled; + + const closeEnableNotificationsNudge = useCallback(() => { + pendingActionRef.current = null; + // If notifications are now enabled the nudge is already gone (replaced by + // loading → success toast). Calling closeToast here would kill the success + // toast that was just shown, so we skip it. + if (!areNotificationsEnabledRef.current) { + toastRef?.current?.closeToast(); + } + }, [toastRef]); + + const handleTurnOnNotifications = useCallback(async () => { + if (!enabled || notificationsEnableInFlightRef.current) { + return; + } + notificationsEnableInFlightRef.current = true; + showToast( + RewardsToastOptions.loading( + strings('rewards.notifications_nudge.loading'), + strings('rewards.notifications_nudge.loading_description'), + ), + ); + try { + const permission = await getPushPermission(); + if (permission === 'denied') { + toastRef?.current?.closeToast(); + NotificationService.openSystemSettings(); + appStateSubscriptionRef.current = AppState.addEventListener( + 'change', + async (nextState) => { + if (nextState !== 'active') return; + appStateSubscriptionRef.current?.remove(); + appStateSubscriptionRef.current = null; + const retryPermission = await getPushPermission(); + if (retryPermission === 'denied') return; + notificationsEnableInFlightRef.current = true; + showToast( + RewardsToastOptions.loading( + strings('rewards.notifications_nudge.loading'), + strings('rewards.notifications_nudge.loading_description'), + ), + ); + try { + await enableNotifications(); + toastRef?.current?.closeToast(); + onNotificationsEnabledRef.current?.(); + } catch { + toastRef?.current?.closeToast(); + showToast( + RewardsToastOptions.error( + strings('rewards.notifications_nudge.enable_error'), + ), + ); + } finally { + notificationsEnableInFlightRef.current = false; + } + }, + ); + return; + } + await enableNotifications(); + toastRef?.current?.closeToast(); + onNotificationsEnabledRef.current?.(); + } catch { + toastRef?.current?.closeToast(); + showToast( + RewardsToastOptions.error( + strings('rewards.notifications_nudge.enable_error'), + ), + ); + } finally { + notificationsEnableInFlightRef.current = false; + } + }, [enableNotifications, enabled, toastRef, showToast, RewardsToastOptions]); + + const showEnableNotificationsNudge = useCallback(() => { + if (!shouldPromptToEnableNotifications) { + return false; + } + + const nudgeConfig = RewardsToastOptions.enableNotificationsNudge({ + label: strings('rewards.notifications_nudge.turn_on_button'), + onPress: handleTurnOnNotifications, + }); + + showToast( + nudgeConfig.closeButtonOptions + ? { + ...nudgeConfig, + closeButtonOptions: { + ...nudgeConfig.closeButtonOptions, + onPress: () => { + pendingActionRef.current = null; + nudgeConfig.closeButtonOptions?.onPress?.(); + }, + }, + } + : nudgeConfig, + ); + return true; + }, [ + RewardsToastOptions, + handleTurnOnNotifications, + shouldPromptToEnableNotifications, + showToast, + ]); + + const runAfterNotificationsEnabled = useCallback( + async (action: NotificationsEnabledAction) => { + if (!enabled) { + return false; + } + if (areNotificationsEnabled) { + await action(); + return true; + } + if (!canPromptToEnableNotifications) { + return false; + } + + pendingActionRef.current = action; + showEnableNotificationsNudge(); + return false; + }, + [ + areNotificationsEnabled, + canPromptToEnableNotifications, + enabled, + showEnableNotificationsNudge, + ], + ); + + useEffect(() => { + if (!pendingActionRef.current || !areNotificationsEnabled || !enabled) { + return; + } + + const pendingAction = pendingActionRef.current; + pendingActionRef.current = null; + toastRef?.current?.closeToast(); + showToast( + RewardsToastOptions.loading( + strings('rewards.notifications_nudge.loading'), + strings('rewards.notifications_nudge.loading_description'), + ), + ); + Promise.resolve(pendingAction()).catch(() => { + toastRef?.current?.closeToast(); + showToast( + RewardsToastOptions.error( + strings('rewards.notifications_nudge.enable_error'), + ), + ); + }); + }, [ + areNotificationsEnabled, + enabled, + toastRef, + showToast, + RewardsToastOptions, + ]); + + useEffect(() => { + if (!enabled) { + appStateSubscriptionRef.current?.remove(); + appStateSubscriptionRef.current = null; + pendingActionRef.current = null; + } + }, [enabled]); + + useEffect( + () => () => { + appStateSubscriptionRef.current?.remove(); + }, + [], + ); + + return { + areNotificationsEnabled, + canPromptToEnableNotifications, + shouldPromptToEnableNotifications, + showEnableNotificationsNudge, + closeEnableNotificationsNudge, + runAfterNotificationsEnabled, + }; +} diff --git a/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx b/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx index f7db2fb7b6a..aa29b8cbfb2 100644 --- a/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx +++ b/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx @@ -1,5 +1,6 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useContext } from 'react'; +import { render } from '@testing-library/react-native'; +import { useContext, type ReactElement } from 'react'; import { playNotification, NotificationMoment } from '../../../../util/haptics'; import { mockTheme } from '../../../../util/theme'; import useRewardsToast, { RewardsToastOptions } from './useRewardsToast'; @@ -20,6 +21,10 @@ jest.mock('../../../../util/haptics'); jest.mock('../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { if (key === 'rewards.toast_dismiss') return 'Dismiss'; + if (key === 'rewards.notifications_nudge.title') return "Don't miss out"; + if (key === 'rewards.notifications_nudge.description') { + return 'Enable notifications to stay informed on campaigns'; + } return key; }), })); @@ -32,6 +37,29 @@ jest.mock('../../../../util/theme', () => { }; }); +jest.mock('../../../../images/rewards/notification.svg', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { testID: 'rewards-notification-svg' }), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + Box: ({ children }: { children?: React.ReactNode }) => + ReactActual.createElement( + View, + { testID: 'rewards-nudge-start-accessory-box' }, + children, + ), + }; +}); + describe('useRewardsToast', () => { let mockShowToast: jest.Mock; let mockCloseToast: jest.Mock; @@ -79,6 +107,31 @@ describe('useRewardsToast', () => { }); expect(playNotification).toHaveBeenCalledWith(NotificationMoment.Success); }); + + it('strips hapticsType from payload passed to toastRef for enableNotificationsNudge', async () => { + const { result } = renderHook(() => useRewardsToast()); + const nudgeConfig = + result.current.RewardsToastOptions.enableNotificationsNudge({ + label: 'Turn on', + onPress: jest.fn(), + }); + + await act(async () => { + result.current.showToast(nudgeConfig); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(mockShowToast).toHaveBeenCalledWith( + expect.not.objectContaining({ hapticsType: expect.anything() }), + ); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Plain, + hasNoTimeout: true, + }), + ); + expect(playNotification).toHaveBeenCalledWith(NotificationMoment.Warning); + }); }); describe('RewardsToastOptions configurations', () => { @@ -213,6 +266,122 @@ describe('useRewardsToast', () => { }); }); + it('returns loading configuration with title only', () => { + const { result } = renderHook(() => useRewardsToast()); + const config = result.current.RewardsToastOptions.loading('Loading...'); + + expect(config).toMatchObject({ + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + }); + expect(config.labelOptions).toEqual([ + { label: 'Loading...', isBold: true }, + ]); + expect(config.descriptionOptions).toBeUndefined(); + expect(config.closeButtonOptions).toMatchObject({ + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + }); + }); + + it('returns loading configuration with title and subtitle', () => { + const { result } = renderHook(() => useRewardsToast()); + const config = result.current.RewardsToastOptions.loading( + 'Loading...', + 'Please wait', + ); + + expect(config.labelOptions).toEqual([ + { label: 'Loading...', isBold: true }, + ]); + expect(config.descriptionOptions).toEqual({ description: 'Please wait' }); + }); + + it('calls closeToast when loading close button is pressed', () => { + const { result } = renderHook(() => useRewardsToast()); + const config = result.current.RewardsToastOptions.loading('Loading...'); + + config.closeButtonOptions?.onPress?.(); + + expect(mockCloseToast).toHaveBeenCalledTimes(1); + }); + + it('renders startAccessory for loading config', () => { + const { result } = renderHook(() => useRewardsToast()); + const config = result.current.RewardsToastOptions.loading('Loading...'); + + expect(config.startAccessory).toBeDefined(); + }); + + it('returns enableNotificationsNudge configuration with Plain variant', () => { + const { result } = renderHook(() => useRewardsToast()); + const onPress = jest.fn(); + const config = + result.current.RewardsToastOptions.enableNotificationsNudge({ + label: 'Turn on', + onPress, + }); + + expect(config).toMatchObject({ + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + linkButtonOptions: { label: 'Turn on', onPress }, + }); + expect(config.labelOptions).toEqual([ + { label: "Don't miss out", isBold: true }, + ]); + expect(config.descriptionOptions).toEqual({ + description: 'Enable notifications to stay informed on campaigns', + }); + expect(config.closeButtonOptions).toMatchObject({ + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + }); + }); + + it('passes linkButtonOptions through to enableNotificationsNudge config', () => { + const { result } = renderHook(() => useRewardsToast()); + const onPress = jest.fn(); + const config = + result.current.RewardsToastOptions.enableNotificationsNudge({ + label: 'Open settings', + onPress, + }); + + expect(config.linkButtonOptions).toEqual({ + label: 'Open settings', + onPress, + }); + }); + + it('renders startAccessory containing notification icon placeholder', () => { + const { result } = renderHook(() => useRewardsToast()); + const config = + result.current.RewardsToastOptions.enableNotificationsNudge({ + label: 'Turn on', + onPress: jest.fn(), + }); + + expect(config.startAccessory).toBeDefined(); + const { getByTestId } = render(config.startAccessory as ReactElement); + expect(getByTestId('rewards-notification-svg')).toBeDefined(); + }); + + it('calls closeToast when enableNotificationsNudge close button is pressed', () => { + const { result } = renderHook(() => useRewardsToast()); + const config = + result.current.RewardsToastOptions.enableNotificationsNudge({ + label: 'Turn on', + onPress: jest.fn(), + }); + + config.closeButtonOptions?.onPress?.(); + + expect(mockCloseToast).toHaveBeenCalledTimes(1); + }); + it('calls closeToast when close button is pressed', () => { const { result } = renderHook(() => useRewardsToast()); const config = result.current.RewardsToastOptions.success('Test Title'); @@ -221,6 +390,68 @@ describe('useRewardsToast', () => { expect(mockCloseToast).toHaveBeenCalledTimes(1); }); + + it('returns outcomeWinner configuration with CTA and close handlers', () => { + const { result } = renderHook(() => useRewardsToast()); + const onCta = jest.fn(); + const onClose = jest.fn(); + const config = result.current.RewardsToastOptions.outcomeWinner({ + title: 'Winner title', + description: 'Winner body', + ctaLabel: 'Next', + onCtaPress: onCta, + onClosePress: onClose, + }); + + expect(config).toMatchObject({ + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Success, + descriptionOptions: { description: 'Winner body' }, + linkButtonOptions: { label: 'Next' }, + }); + expect(config.labelOptions).toEqual([ + { label: 'Winner title', isBold: true }, + ]); + expect(config.startAccessory).toBeDefined(); + const { getByTestId } = render(config.startAccessory as ReactElement); + expect(getByTestId('rewards-nudge-start-accessory-box')).toBeDefined(); + config.linkButtonOptions?.onPress?.(); + config.closeButtonOptions?.onPress?.(); + expect(onCta).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('returns outcomeNonWinner configuration with CTA and close handlers', () => { + const { result } = renderHook(() => useRewardsToast()); + const onCta = jest.fn(); + const onClose = jest.fn(); + const config = result.current.RewardsToastOptions.outcomeNonWinner({ + title: 'Thanks title', + description: 'Thanks body', + ctaLabel: 'Done', + onCtaPress: onCta, + onClosePress: onClose, + }); + + expect(config).toMatchObject({ + variant: ToastVariants.Icon, + iconName: IconName.Confirmation, + iconColor: mockTheme.colors.success.default, + backgroundColor: 'transparent', + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + descriptionOptions: { description: 'Thanks body' }, + linkButtonOptions: { label: 'Done' }, + }); + expect(config.labelOptions).toEqual([ + { label: 'Thanks title', isBold: true }, + ]); + config.linkButtonOptions?.onPress?.(); + config.closeButtonOptions?.onPress?.(); + expect(onCta).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); }); describe('edge cases and error handling', () => { @@ -346,6 +577,17 @@ describe('useRewardsToast', () => { expect(config.hasNoTimeout).toBe(false); }); + + it('uses persistent toast timeout for enableNotificationsNudge', () => { + const { result } = renderHook(() => useRewardsToast()); + const config = + result.current.RewardsToastOptions.enableNotificationsNudge({ + label: 'Turn on', + onPress: jest.fn(), + }); + + expect(config.hasNoTimeout).toBe(true); + }); }); describe('label and description formatting', () => { diff --git a/app/components/UI/Rewards/hooks/useRewardsToast.tsx b/app/components/UI/Rewards/hooks/useRewardsToast.tsx index 1c18401ecf3..48fa2bfe57c 100644 --- a/app/components/UI/Rewards/hooks/useRewardsToast.tsx +++ b/app/components/UI/Rewards/hooks/useRewardsToast.tsx @@ -1,9 +1,12 @@ -import { useCallback, useContext, useMemo } from 'react'; +import React, { useCallback, useContext, useMemo } from 'react'; +import { ActivityIndicator } from 'react-native'; +import { Box } from '@metamask/design-system-react-native'; import { ToastContext } from '../../../../component-library/components/Toast'; import { ButtonIconVariant, ToastDescriptionOptions, ToastLabelOptions, + ToastLinkButtonOptions, ToastOptions, ToastVariants, } from '../../../../component-library/components/Toast/Toast.types'; @@ -14,15 +17,32 @@ import { NotificationMoment, type HapticNotificationMoment, } from '../../../../util/haptics'; +import { strings } from '../../../../../locales/i18n'; +import RewardsNotificationIcon from '../../../../images/rewards/notification.svg'; +import RewardsTrophyIcon from '../../../../images/rewards/trophy.svg'; export type RewardsToastOptions = ToastOptions & { hapticsType: HapticNotificationMoment; }; +export interface OutcomeCtaToastParams { + title: string; + description: string; + ctaLabel: string; + onCtaPress: () => void; + onClosePress: () => void; +} + export interface RewardsToastConfig { success: (title: string, subtitle?: string) => RewardsToastOptions; error: (title: string, subtitle?: string) => RewardsToastOptions; + loading: (title: string, subtitle?: string) => RewardsToastOptions; entriesClosed: (title: string, subtitle?: string) => RewardsToastOptions; + enableNotificationsNudge: ( + linkButtonOptions: ToastLinkButtonOptions, + ) => RewardsToastOptions; + outcomeWinner: (params: OutcomeCtaToastParams) => RewardsToastOptions; + outcomeNonWinner: (params: OutcomeCtaToastParams) => RewardsToastOptions; } const getRewardsToastLabels = (title: string): ToastLabelOptions => { @@ -104,6 +124,26 @@ const useRewardsToast = (): { }, }, }), + loading: (title: string, subtitle?: string) => ({ + ...(REWARDS_TOASTS_DEFAULT_OPTIONS as RewardsToastOptions), + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + startAccessory: ( + + + + ), + labelOptions: getRewardsToastLabels(title), + descriptionOptions: getRewardsToastDescriptionLabels(subtitle), + closeButtonOptions: { + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + onPress: () => { + toastRef?.current?.closeToast(); + }, + }, + }), entriesClosed: (title: string, subtitle?: string) => ({ ...(REWARDS_TOASTS_DEFAULT_OPTIONS as RewardsToastOptions), variant: ToastVariants.Icon, @@ -122,11 +162,102 @@ const useRewardsToast = (): { }, }, }), + enableNotificationsNudge: ( + linkButtonOptions: ToastLinkButtonOptions, + ) => ({ + ...(REWARDS_TOASTS_DEFAULT_OPTIONS as RewardsToastOptions), + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + startAccessory: ( + + + + ), + labelOptions: getRewardsToastLabels( + strings('rewards.notifications_nudge.title'), + ), + descriptionOptions: getRewardsToastDescriptionLabels( + strings('rewards.notifications_nudge.description'), + ), + linkButtonOptions, + closeButtonOptions: { + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + onPress: () => { + toastRef?.current?.closeToast(); + }, + }, + }), + outcomeWinner: ({ + title, + description, + ctaLabel, + onCtaPress, + onClosePress, + }: OutcomeCtaToastParams) => ({ + ...(REWARDS_TOASTS_DEFAULT_OPTIONS as RewardsToastOptions), + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Success, + startAccessory: ( + + + + ), + labelOptions: getRewardsToastLabels(title), + descriptionOptions: { description }, + linkButtonOptions: { + label: ctaLabel, + onPress: onCtaPress, + }, + closeButtonOptions: { + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + onPress: onClosePress, + }, + }), + outcomeNonWinner: ({ + title, + description, + ctaLabel, + onCtaPress, + onClosePress, + }: OutcomeCtaToastParams) => ({ + variant: ToastVariants.Icon, + iconName: IconName.Confirmation, + iconColor: theme.colors.success.default, + backgroundColor: 'transparent', + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + labelOptions: getRewardsToastLabels(title), + descriptionOptions: { description }, + linkButtonOptions: { + label: ctaLabel, + onPress: onCtaPress, + }, + closeButtonOptions: { + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + onPress: onClosePress, + }, + }), }), [ theme.colors.success.default, theme.colors.error.default, theme.colors.icon.default, + theme.colors.warning.default, toastRef, ], ); diff --git a/app/components/UI/Rewards/utils.ts b/app/components/UI/Rewards/utils.ts index cf902295145..fff1df35443 100644 --- a/app/components/UI/Rewards/utils.ts +++ b/app/components/UI/Rewards/utils.ts @@ -106,6 +106,7 @@ export enum RewardsMetricsButtons { VISIT_APP_STORE = 'visit_app_store', BUY_MUSD = 'buy_musd', SWAP_TO_MUSD = 'swap_to_musd', + COPY_WINNER_VERIFICATION_CODE = 'copy_winner_verification_code', } export const deriveAccountMetricProps = (account?: InternalAccount) => { diff --git a/app/components/UI/Rewards/utils/formatUtils.test.ts b/app/components/UI/Rewards/utils/formatUtils.test.ts index 5eb35ddc9e4..c78001af9c0 100644 --- a/app/components/UI/Rewards/utils/formatUtils.test.ts +++ b/app/components/UI/Rewards/utils/formatUtils.test.ts @@ -17,7 +17,6 @@ import { validateEmail, formatPercentChange, isPercentChangeNonNegative, - formatComputedAt, getChainHex, getAssetReference, parseCaip19, @@ -43,24 +42,33 @@ jest.mock('../../../../util/date', () => ({ // Mock i18n strings jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => { - const t: Record = { - 'rewards.events.to': 'to', - 'rewards.events.type.swap': 'Swap', - 'rewards.events.type.referral_action': 'Referral action', - 'rewards.events.type.sign_up_bonus': 'Sign up bonus', - 'rewards.events.type.loyalty_bonus': 'Loyalty bonus', - 'rewards.events.type.one_time_bonus': 'One-time bonus', - 'rewards.events.type.open_position': 'Opened position', - 'rewards.events.type.close_position': 'Closed position', - 'rewards.events.type.take_profit': 'Take profit', - 'rewards.events.type.stop_loss': 'Stop loss', - 'rewards.events.type.uncategorized_event': 'Uncategorized event', - 'perps.market.long': 'Long', - 'perps.market.short': 'Short', - }; - return t[key] || key; - }), + strings: jest.fn( + (key: string, params: Record | undefined) => { + const t: Record = { + 'rewards.events.to': 'to', + 'rewards.events.type.swap': 'Swap', + 'rewards.events.type.referral_action': 'Referral action', + 'rewards.events.type.sign_up_bonus': 'Sign up bonus', + 'rewards.events.type.loyalty_bonus': 'Loyalty bonus', + 'rewards.events.type.one_time_bonus': 'One-time bonus', + 'rewards.events.type.open_position': 'Opened position', + 'rewards.events.type.close_position': 'Closed position', + 'rewards.events.type.take_profit': 'Take profit', + 'rewards.events.type.stop_loss': 'Stop loss', + 'rewards.events.type.uncategorized_event': 'Uncategorized event', + 'perps.market.long': 'Long', + 'perps.market.short': 'Short', + 'rewards.perps_trading_campaign.last_updated': 'Last updated: {{time}}', + }; + let template = t[key] ?? key; + if (params) { + for (const [paramKey, value] of Object.entries(params)) { + template = template.split(`{{${paramKey}}}`).join(value); + } + } + return template; + }, + ), default: { locale: 'en-US', }, @@ -1368,6 +1376,60 @@ describe('formatUtils', () => { }); }); + describe('formatOrdinalRank', () => { + it('formats 1 as 1st', () => { + expect(formatOrdinalRank(1)).toBe('1st'); + }); + + it('formats 2 as 2nd', () => { + expect(formatOrdinalRank(2)).toBe('2nd'); + }); + + it('formats 3 as 3rd', () => { + expect(formatOrdinalRank(3)).toBe('3rd'); + }); + + it('formats 4 as 4th', () => { + expect(formatOrdinalRank(4)).toBe('4th'); + }); + + it('formats 11 as 11th', () => { + expect(formatOrdinalRank(11)).toBe('11th'); + }); + + it('formats 12 as 12th', () => { + expect(formatOrdinalRank(12)).toBe('12th'); + }); + + it('formats 13 as 13th', () => { + expect(formatOrdinalRank(13)).toBe('13th'); + }); + + it('formats 21 as 21st', () => { + expect(formatOrdinalRank(21)).toBe('21st'); + }); + + it('formats 22 as 22nd', () => { + expect(formatOrdinalRank(22)).toBe('22nd'); + }); + + it('formats 23 as 23rd', () => { + expect(formatOrdinalRank(23)).toBe('23rd'); + }); + + it('formats 111 as 111th', () => { + expect(formatOrdinalRank(111)).toBe('111th'); + }); + + it('uses absolute value for negative ranks', () => { + expect(formatOrdinalRank(-5)).toBe('5th'); + }); + + it('floors non-integer ranks', () => { + expect(formatOrdinalRank(3.7)).toBe('3rd'); + }); + }); + describe('isPercentChangeNonNegative', () => { it('returns true for positive number', () => { expect(isPercentChangeNonNegative(0.15)).toBe(true); @@ -1390,25 +1452,6 @@ describe('formatUtils', () => { }); }); - describe('formatComputedAt', () => { - it('returns empty string for null', () => { - expect(formatComputedAt(null)).toBe(''); - }); - - it('returns empty string for empty string', () => { - expect(formatComputedAt('')).toBe(''); - }); - - it('returns HH:MM:SS for a valid ISO timestamp', () => { - const result = formatComputedAt('2026-03-28T14:30:45.000Z'); - expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); - }); - - it('returns empty string for unparseable value', () => { - expect(formatComputedAt('not-a-date')).toBe(''); - }); - }); - describe('getChainHex', () => { it('extracts hex chain ID from EIP-155 CAIP-19', () => { expect(getChainHex('eip155:1/erc20:0xabc')).toBe('0x1'); @@ -1556,8 +1599,20 @@ describe('formatUtils', () => { expect(formatSignedUsd('0')).toBe('$0.00'); }); - it('returns raw string for non-numeric input', () => { - expect(formatSignedUsd('abc')).toBe('abc'); + it('prepends + for positive number input', () => { + expect(formatSignedUsd(5000)).toBe('+$5,000.00'); + }); + + it('formats negative number input', () => { + expect(formatSignedUsd(-1250.5)).toBe('$-1,250.50'); + }); + + it('returns em dash for non-numeric string', () => { + expect(formatSignedUsd('abc')).toBe('—'); + }); + + it('returns em dash for empty string', () => { + expect(formatSignedUsd('')).toBe('—'); }); }); diff --git a/app/components/UI/Rewards/utils/formatUtils.ts b/app/components/UI/Rewards/utils/formatUtils.ts index a477d85d242..d6de04f929a 100644 --- a/app/components/UI/Rewards/utils/formatUtils.ts +++ b/app/components/UI/Rewards/utils/formatUtils.ts @@ -8,7 +8,7 @@ import { parseCaipChainId, } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import I18n from '../../../../../locales/i18n'; +import I18n, { strings } from '../../../../../locales/i18n'; import { getTimeDifferenceFromNow } from '../../../../util/date'; import formatFiat from '../../../../util/formatFiat'; import { getIntlNumberFormatter } from '../../../../util/intl'; @@ -367,10 +367,10 @@ export const formatCompactUsd = (value: number): string => { * @example formatSignedUsd('-1250.50') // '-$1,250.50' * @example formatSignedUsd(null) // '—' */ -export const formatSignedUsd = (value: string | null): string => { +export const formatSignedUsd = (value: string | number | null): string => { if (value === null) return '—'; - const num = parseFloat(value); - if (Number.isNaN(num)) return value; + const num = typeof value === 'number' ? value : parseFloat(value); + if (Number.isNaN(num)) return '—'; const sign = num > 0 ? '+' : ''; return `${sign}${formatUsd(value)}`; }; @@ -425,22 +425,6 @@ export function formatOrdinalRank(rank: number): string { return `${n}${suffix}`; } -// ── Timestamp formatting ──────────────────────────────────────────────── - -/** - * Formats an ISO 8601 timestamp to `HH:MM:SS`. - * Returns '' for null or unparseable values. - */ -export const formatComputedAt = (isoString: string | null): string => { - if (!isoString) return ''; - const date = new Date(isoString); - if (isNaN(date.getTime())) return ''; - const h = date.getHours().toString().padStart(2, '0'); - const m = date.getMinutes().toString().padStart(2, '0'); - const s = date.getSeconds().toString().padStart(2, '0'); - return `${h}:${m}:${s}`; -}; - // ── CAIP-19 / address helpers ─────────────────────────────────────────── /** diff --git a/app/components/UI/Rewards/utils/perpsCampaignConstants.ts b/app/components/UI/Rewards/utils/perpsCampaignConstants.ts new file mode 100644 index 00000000000..ee988ff0387 --- /dev/null +++ b/app/components/UI/Rewards/utils/perpsCampaignConstants.ts @@ -0,0 +1,9 @@ +/** + * Notional volume (USD) required to qualify for the perps trading competition leaderboard. + * Aligns with backend / UI rules for `qualified` on leaderboard position. + */ +export const PERPS_QUALIFICATION_NOTIONAL_USD = 25_000; + +/** HyperTracker attribution URL for the perps trading campaign leaderboard. */ +export const HYPERTRACKER_ATTRIBUTION_URL = + 'https://hypertracker.io?utm_source=metamask&utm_medium=leaderboard&utm_campaign=partner-attribution'; diff --git a/app/components/UI/Rewards/utils/prizePoolUtils.test.ts b/app/components/UI/Rewards/utils/prizePoolUtils.test.ts new file mode 100644 index 00000000000..6457bf916ec --- /dev/null +++ b/app/components/UI/Rewards/utils/prizePoolUtils.test.ts @@ -0,0 +1,79 @@ +import { computePrizePoolProgress } from './prizePoolUtils'; + +describe('computePrizePoolProgress', () => { + const ondoLike = [ + { deposit: 0, prize: 25_000 }, + { deposit: 1_500_000, prize: 50_000 }, + { deposit: 3_500_000, prize: 75_000 }, + { deposit: 6_000_000, prize: 100_000 }, + ] as const; + + it('returns first-tier defaults when amount is below first threshold above zero', () => { + const result = computePrizePoolProgress( + ondoLike, + 1_000_000, + (m) => m.deposit, + ); + expect(result.currentPrize).toBe(25_000); + expect(result.nextPrize).toBe(50_000); + expect(result.nextThreshold).toBe(1_500_000); + expect(result.isMaxTier).toBe(false); + expect(result.progress).toBeCloseTo(1_000_000 / 1_500_000); + }); + + it('returns max tier when amount meets final milestone', () => { + const result = computePrizePoolProgress( + ondoLike, + 6_000_000, + (m) => m.deposit, + ); + expect(result.progress).toBe(1); + expect(result.currentPrize).toBe(100_000); + expect(result.nextPrize).toBeNull(); + expect(result.nextThreshold).toBe(6_000_000); + expect(result.isMaxTier).toBe(true); + }); + + it('interpolates progress within a tier (perps-style notionalVolume)', () => { + const perpsLike = [ + { notionalVolume: 0, prize: 10_000 }, + { notionalVolume: 5_000_000, prize: 15_000 }, + { notionalVolume: 10_000_000, prize: 20_000 }, + ] as const; + + const mid = 7_500_000; + const result = computePrizePoolProgress( + perpsLike, + mid, + (m) => m.notionalVolume, + ); + expect(result.currentPrize).toBe(15_000); + expect(result.nextPrize).toBe(20_000); + expect(result.nextThreshold).toBe(10_000_000); + expect(result.progress).toBe(0.5); + }); + + it('returns zero progress at the start of the first tier', () => { + const result = computePrizePoolProgress(ondoLike, 0, (m) => m.deposit); + expect(result.progress).toBe(0); + expect(result.currentPrize).toBe(25_000); + expect(result.nextThreshold).toBe(1_500_000); + }); + + it('returns expected currentPrize at each Ondo-style tier boundary', () => { + const cp = (amount: number) => + computePrizePoolProgress(ondoLike, amount, (m) => m.deposit).currentPrize; + + expect(cp(0)).toBe(25_000); + expect(cp(500_000)).toBe(25_000); + expect(cp(1_499_999)).toBe(25_000); + expect(cp(1_500_000)).toBe(50_000); + expect(cp(2_000_000)).toBe(50_000); + expect(cp(3_499_999)).toBe(50_000); + expect(cp(3_500_000)).toBe(75_000); + expect(cp(4_500_000)).toBe(75_000); + expect(cp(5_999_999)).toBe(75_000); + expect(cp(6_000_000)).toBe(100_000); + expect(cp(10_000_000)).toBe(100_000); + }); +}); diff --git a/app/components/UI/Rewards/utils/prizePoolUtils.ts b/app/components/UI/Rewards/utils/prizePoolUtils.ts new file mode 100644 index 00000000000..5f31c32c109 --- /dev/null +++ b/app/components/UI/Rewards/utils/prizePoolUtils.ts @@ -0,0 +1,49 @@ +export interface PrizePoolProgressResult { + progress: number; + currentPrize: number; + nextPrize: number | null; + nextThreshold: number; + isMaxTier: boolean; +} + +/** + * Computes progress toward the next prize tier from sorted milestones (ascending threshold). + */ +export function computePrizePoolProgress( + milestones: readonly T[], + totalAmount: number, + getThreshold: (m: T) => number, +): PrizePoolProgressResult { + let currentIndex = 0; + for (let i = milestones.length - 1; i >= 0; i--) { + if (totalAmount >= getThreshold(milestones[i])) { + currentIndex = i; + break; + } + } + + const current = milestones[currentIndex]; + const next = milestones[currentIndex + 1]; + + if (!next) { + return { + progress: 1, + currentPrize: current.prize, + nextPrize: null, + nextThreshold: getThreshold(current), + isMaxTier: true, + }; + } + + const rangeAmount = getThreshold(next) - getThreshold(current); + const progressInRange = totalAmount - getThreshold(current); + const progress = Math.min(progressInRange / rangeAmount, 1); + + return { + progress, + currentPrize: current.prize, + nextPrize: next.prize, + nextThreshold: getThreshold(next), + isMaxTier: false, + }; +} diff --git a/app/components/UI/SecurityOptionToggle/SecurityOptionToggle.tsx b/app/components/UI/SecurityOptionToggle/SecurityOptionToggle.tsx index be8f03f8509..85471e4f3b8 100644 --- a/app/components/UI/SecurityOptionToggle/SecurityOptionToggle.tsx +++ b/app/components/UI/SecurityOptionToggle/SecurityOptionToggle.tsx @@ -1,7 +1,6 @@ import React, { useCallback } from 'react'; -import { Platform, Switch, View } from 'react-native'; +import { Switch, View } from 'react-native'; import { createStyles } from './styles'; -import generateTestId from '../../../../wdio/utils/generateTestId'; import Text, { TextColor, TextVariant, @@ -57,7 +56,7 @@ const SecurityOptionToggle = ({ style={styles.switch} ios_backgroundColor={colors.border.muted} disabled={disabled} - {...generateTestId(Platform, testId)} + testID={testId} /> diff --git a/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx index f3aa0ed469c..d029711cd68 100644 --- a/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx +++ b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx @@ -124,9 +124,9 @@ const SecurityTrustEntryCard: React.FC = ({ )} {config.label} diff --git a/app/components/UI/SettingsDrawer/index.js b/app/components/UI/SettingsDrawer/index.js index ba16c8ad17c..57d57bf854c 100644 --- a/app/components/UI/SettingsDrawer/index.js +++ b/app/components/UI/SettingsDrawer/index.js @@ -1,9 +1,8 @@ import React from 'react'; -import { View, StyleSheet, TouchableOpacity, Platform } from 'react-native'; +import { View, StyleSheet, TouchableOpacity } from 'react-native'; import PropTypes from 'prop-types'; import { fontStyles } from '../../../styles/common'; import { useTheme } from '../../../util/theme'; -import generateTestId from '../../../../wdio/utils/generateTestId'; import Icon, { IconColor, IconName, @@ -54,10 +53,6 @@ const propTypes = { * Additional descriptive text about this option */ description: PropTypes.string, - /** - * Disable bottom border - */ - noBorder: PropTypes.bool, /** * Handler called when this drawer is pressed */ @@ -96,7 +91,7 @@ const SettingsDrawer = ({ const { colors } = useTheme(); const styles = createStyles(colors, titleColor); return ( - + diff --git a/app/components/UI/SettingsNotification/__snapshots__/index.test.tsx.snap b/app/components/UI/SettingsNotification/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 05d92c4e81d..00000000000 --- a/app/components/UI/SettingsNotification/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,90 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SettingsNotification should render correctly as notification 1`] = ` - - -  - - this is a notification - -`; - -exports[`SettingsNotification should render correctly as warning 1`] = ` - - -  - - this is a warning - -`; diff --git a/app/components/UI/SettingsNotification/index.test.tsx b/app/components/UI/SettingsNotification/index.test.tsx index 62a4d5849be..acce7596736 100644 --- a/app/components/UI/SettingsNotification/index.test.tsx +++ b/app/components/UI/SettingsNotification/index.test.tsx @@ -1,23 +1,30 @@ import React from 'react'; +import { Text } from 'react-native'; import { render } from '@testing-library/react-native'; import SettingsNotification from './'; describe('SettingsNotification', () => { - it('should render correctly as warning', () => { - const { toJSON } = render( + it('renders children in warning variant', () => { + const { getByTestId } = render( - {'this is a warning'} + this is a warning , ); - expect(toJSON()).toMatchSnapshot(); + + expect(getByTestId('settings-notification-label').props.children).toBe( + 'this is a warning', + ); }); - it('should render correctly as notification', () => { - const { toJSON } = render( + it('renders children in notification variant', () => { + const { getByTestId } = render( - {'this is a notification'} + this is a notification , ); - expect(toJSON()).toMatchSnapshot(); + + expect(getByTestId('settings-notification-label').props.children).toBe( + 'this is a notification', + ); }); }); diff --git a/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.test.tsx b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.test.tsx index 29f45f2d573..9c83e7ad90f 100644 --- a/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.test.tsx +++ b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.test.tsx @@ -1,6 +1,11 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; -import SiteRowItem, { type SiteData } from './SiteRowItem'; +import SiteRowItem, { + bookmarkUrlForRemoval, + type SiteData, +} from './SiteRowItem'; + +jest.mock('../../../WebsiteIcon', () => jest.fn(() => null)); describe('SiteRowItem', () => { const mockOnPress = jest.fn(); @@ -54,7 +59,7 @@ describe('SiteRowItem', () => { expect(image.props.source.uri).toBe('https://example.com/logo.png'); }); - it('renders fallback global icon when logoUrl is not provided', () => { + it('renders WebsiteIcon fallback when logoUrl is not provided', () => { const site = createSite({ logoUrl: undefined }); const { getByTestId, queryByTestId } = render( @@ -149,5 +154,59 @@ describe('SiteRowItem', () => { const image = getByTestId('site-logo-image'); expect(image).toBeOnTheScreen(); }); + + it('falls back to WebsiteIcon when remote logo image errors', () => { + const site = createSite({ logoUrl: 'https://example.com/broken.png' }); + + const { getByTestId, queryByTestId } = render( + , + ); + + const image = getByTestId('site-logo-image'); + fireEvent(image, 'error'); + + expect(queryByTestId('site-logo-image')).toBeNull(); + expect(getByTestId('site-logo-fallback')).toBeOnTheScreen(); + }); + + it('shows remote logo again after logoUrl changes', () => { + const siteA = createSite({ + id: 'row-1', + logoUrl: 'https://example.com/broken.png', + }); + const { getByTestId, rerender, queryByTestId } = render( + , + ); + + fireEvent(getByTestId('site-logo-image'), 'error'); + expect(queryByTestId('site-logo-image')).toBeNull(); + + const siteB = createSite({ + id: 'row-1', + logoUrl: 'https://example.com/fixed.png', + }); + rerender(); + + const image = getByTestId('site-logo-image'); + expect(image).toBeOnTheScreen(); + expect(image.props.source.uri).toBe('https://example.com/fixed.png'); + }); + }); + + describe('bookmarkUrlForRemoval', () => { + it('uses storedBookmarkUrl when set', () => { + expect( + bookmarkUrlForRemoval( + createSite({ + url: 'https://uniswap.org', + storedBookmarkUrl: 'uniswap.org', + }), + ), + ).toBe('uniswap.org'); + }); + + it('falls back to url when storedBookmarkUrl is unset', () => { + expect(bookmarkUrlForRemoval(createSite())).toBe('https://metamask.io'); + }); }); }); diff --git a/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.tsx b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.tsx index d059b368bac..250bd99fdd0 100644 --- a/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.tsx +++ b/app/components/UI/Sites/components/SiteRowItem/SiteRowItem.tsx @@ -1,5 +1,11 @@ -import React, { useState, useEffect } from 'react'; -import { TouchableOpacity, Image } from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { + TouchableOpacity, + StyleSheet, + Image, + View, + type ImageSourcePropType, +} from 'react-native'; import { Box, Text, @@ -7,15 +13,26 @@ import { Icon, IconName, IconSize, + IconColor, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import WebsiteIcon from '../../../WebsiteIcon'; export interface SiteData { id: string; name: string; url: string; displayUrl: string; + /** + * Exact bookmark URL as persisted in Redux. When set (e.g. favorites from + * `useBrowserFavoritesSites`), use {@link bookmarkUrlForRemoval} for + * `removeBookmark` so removal matches even if `url` was normalized with a protocol. + */ + storedBookmarkUrl?: string; + /** Remote URL string — passed to WebsiteIcon for favicon lookup. */ logoUrl?: string; + /** Local bundled image (require result) — rendered directly with , takes priority over logoUrl. */ + logoSource?: ImageSourcePropType; featured?: boolean; /** * When true, applies additional padding around the logo image. @@ -24,20 +41,32 @@ export interface SiteData { logoNeedsPadding?: boolean; } +/** `url` value to pass to `removeBookmark` for this site row. */ +export function bookmarkUrlForRemoval(site: SiteData): string { + return site.storedBookmarkUrl ?? site.url; +} + interface SiteRowItemProps { site: SiteData; onPress: () => void; + onRemoveFavorite?: () => void; } -const SiteRowItem = ({ site, onPress }: SiteRowItemProps) => { +const { icon: websiteIconStyle, iconWithPadding } = StyleSheet.create({ + icon: { width: 40, height: 40, borderRadius: 20 }, + iconWithPadding: { width: 40, height: 40, borderRadius: 20, padding: 6 }, +}); + +const SiteRowItem = ({ site, onPress, onRemoveFavorite }: SiteRowItemProps) => { const tw = useTailwind(); - const [imageError, setImageError] = useState(false); + const [remoteLogoLoadError, setRemoteLogoLoadError] = useState(false); - // Reset error state when site changes (for list recycling) useEffect(() => { - setImageError(false); + setRemoteLogoLoadError(false); }, [site.id, site.logoUrl]); + const showRemoteLogoImage = Boolean(site.logoUrl) && !remoteLogoLoadError; + return ( { > {/* Logo */} - {site.logoUrl && !imageError ? ( - + + {site.logoSource ? ( + + ) : showRemoteLogoImage ? ( setImageError(true)} + onError={() => setRemoteLogoLoadError(true)} /> - - ) : ( - - - - )} + ) : ( + + + + )} + {/* Site Info */} - + {site.name} - + {site.displayUrl} - {/* Arrow Icon */} - - + {/* Action Icons */} + + {onRemoveFavorite && ( + + + + )} ); diff --git a/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx deleted file mode 100644 index a9313343194..00000000000 --- a/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.test.tsx +++ /dev/null @@ -1,264 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; -import SiteRowItemWrapper from './SiteRowItemWrapper'; -import type { NavigationProp, ParamListBase } from '@react-navigation/native'; -import type { SiteData } from '../SiteRowItem/SiteRowItem'; -import Routes from '../../../../../constants/navigation/Routes'; - -// Mock the dependencies -jest.mock('../../../../Nav/Main/MainNavigator', () => ({ - updateLastTrendingScreen: jest.fn(), -})); - -jest.mock('../SiteRowItem/SiteRowItem', () => { - const { TouchableOpacity, Text } = jest.requireActual('react-native'); - - return { - __esModule: true, - default: jest.fn(({ onPress, site }) => ( - - {site.id} - {site.name} - {site.url} - {site.displayUrl} - {site.logoUrl && {site.logoUrl}} - {site.featured && Featured} - - )), - }; -}); - -describe('SiteRowItemWrapper', () => { - let mockNavigation: jest.Mocked>; - let mockSiteData: SiteData; - let dateNowSpy: jest.SpyInstance; - - beforeEach(() => { - jest.clearAllMocks(); - - mockNavigation = { - navigate: jest.fn(), - dispatch: jest.fn(), - reset: jest.fn(), - goBack: jest.fn(), - isFocused: jest.fn(), - canGoBack: jest.fn(), - getState: jest.fn(), - getParent: jest.fn(), - setParams: jest.fn(), - setOptions: jest.fn(), - addListener: jest.fn(), - removeListener: jest.fn(), - getId: jest.fn(), - } as jest.Mocked>; - - mockSiteData = { - id: '1', - name: 'Example Site', - url: 'https://example.com', - displayUrl: 'example.com', - logoUrl: 'https://example.com/logo.png', - featured: false, - }; - - dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1234567890); - }); - - afterEach(() => { - dateNowSpy.mockRestore(); - }); - - describe('Rendering', () => { - it('should render SiteRowItem with correct props', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId('site-row-item-Example Site')).toBeTruthy(); - expect(getByTestId('site-id').props.children).toBe('1'); - expect(getByTestId('site-name').props.children).toBe('Example Site'); - expect(getByTestId('site-url').props.children).toBe( - 'https://example.com', - ); - expect(getByTestId('site-display-url').props.children).toBe( - 'example.com', - ); - }); - - it('should render site with logoUrl', () => { - const { getByTestId } = render( - , - ); - - expect(getByTestId('site-logo-url').props.children).toBe( - 'https://example.com/logo.png', - ); - }); - - it('should render site without logoUrl', () => { - const siteWithoutLogo: SiteData = { - id: '2', - name: 'Site Without Logo', - url: 'https://no-logo.com', - displayUrl: 'no-logo.com', - }; - - const { queryByTestId } = render( - , - ); - - expect(queryByTestId('site-logo-url')).toBeNull(); - }); - - it('should render featured site', () => { - const featuredSite: SiteData = { - id: '3', - name: 'Featured Site', - url: 'https://featured.com', - displayUrl: 'featured.com', - featured: true, - }; - - const { getByTestId } = render( - , - ); - - expect(getByTestId('site-featured').props.children).toBe('Featured'); - }); - - it('should not render featured tag for non-featured site', () => { - const { queryByTestId } = render( - , - ); - - expect(queryByTestId('site-featured')).toBeNull(); - }); - - it('should render with different site data', () => { - const differentSiteData: SiteData = { - id: '4', - name: 'Another Site', - url: 'https://another-site.com', - displayUrl: 'another-site.com', - logoUrl: 'https://another-site.com/logo.png', - featured: true, - }; - - const { getByTestId } = render( - , - ); - - expect(getByTestId('site-id').props.children).toBe('4'); - expect(getByTestId('site-name').props.children).toBe('Another Site'); - expect(getByTestId('site-url').props.children).toBe( - 'https://another-site.com', - ); - expect(getByTestId('site-display-url').props.children).toBe( - 'another-site.com', - ); - expect(getByTestId('site-featured')).toBeTruthy(); - }); - }); - - const assertBrowserNavigation = (siteUrl?: string) => { - expect(mockNavigation.navigate).toHaveBeenCalledWith( - Routes.BROWSER.HOME, - expect.objectContaining({ - screen: Routes.BROWSER.VIEW, - params: expect.objectContaining({ - ...(siteUrl ? { newTabUrl: siteUrl } : {}), - fromTrending: true, - }), - }), - ); - }; - - describe('Navigation and Press Handling', () => { - it('should call updateLastTrendingScreen when pressed', () => { - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId('site-row-item-Example Site')); - }); - - it('should navigate to TrendingBrowser with correct params when pressed', () => { - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId('site-row-item-Example Site')); - - assertBrowserNavigation('https://example.com'); - expect(mockNavigation.navigate).toHaveBeenCalledTimes(1); - }); - - it('should navigate with correct URL for different sites', () => { - const customSite: SiteData = { - id: '5', - name: 'Custom Site', - url: 'https://custom-url.com/page', - displayUrl: 'custom-url.com', - }; - - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId('site-row-item-Custom Site')); - - assertBrowserNavigation('https://custom-url.com/page'); - }); - - it('should handle multiple presses correctly', () => { - const { getByTestId } = render( - , - ); - - const siteRowItem = getByTestId('site-row-item-Example Site'); - - fireEvent.press(siteRowItem); - fireEvent.press(siteRowItem); - fireEvent.press(siteRowItem); - expect(mockNavigation.navigate).toHaveBeenCalledTimes(3); - }); - - it('should always pass fromTrending as true', () => { - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId('site-row-item-Example Site')); - - assertBrowserNavigation(); - }); - }); - - describe('Props variations', () => { - it('should work with minimal site data', () => { - const minimalSiteData: SiteData = { - id: '6', - name: 'Minimal', - url: 'https://minimal.com', - displayUrl: 'minimal.com', - }; - - const { getByTestId } = render( - , - ); - - fireEvent.press(getByTestId('site-row-item-Minimal')); - - assertBrowserNavigation('https://minimal.com'); - }); - }); -}); diff --git a/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx b/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx deleted file mode 100644 index eabf90da6d0..00000000000 --- a/app/components/UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import SiteRowItem, { type SiteData } from '../SiteRowItem/SiteRowItem'; -import Routes from '../../../../../constants/navigation/Routes'; -import type { AppNavigationProp } from '../../../../../core/NavigationService/types'; - -interface SiteRowItemWrapperProps { - site: SiteData; - navigation: AppNavigationProp; -} - -const SiteRowItemWrapper: React.FC = ({ - site, - navigation, -}) => { - const handlePress = () => { - navigation.navigate(Routes.BROWSER.HOME, { - screen: Routes.BROWSER.VIEW, - params: { - newTabUrl: site.url, - timestamp: Date.now(), - fromTrending: true, - }, - }); - }; - - return ; -}; - -export default SiteRowItemWrapper; diff --git a/app/components/UI/Sites/components/SitesList/SitesList.test.tsx b/app/components/UI/Sites/components/SitesList/SitesList.test.tsx index d6607f79cbd..1a82cdbb907 100644 --- a/app/components/UI/Sites/components/SitesList/SitesList.test.tsx +++ b/app/components/UI/Sites/components/SitesList/SitesList.test.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { render, fireEvent } from '@testing-library/react-native'; import SitesList from './SitesList'; import { useNavigation } from '@react-navigation/native'; import type { SiteData } from '../SiteRowItem/SiteRowItem'; +import Routes from '../../../../../constants/navigation/Routes'; // Mock FlashList to render items in tests jest.mock('@shopify/flash-list', () => { @@ -45,22 +46,23 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), })); -jest.mock('../SiteRowItemWrapper/SiteRowItemWrapper', () => { - const { View, Text } = jest.requireActual('react-native'); +jest.mock('../SiteRowItem/SiteRowItem', () => { + const { View, Text, TouchableOpacity } = jest.requireActual('react-native'); return { __esModule: true, default: ({ site, - navigation, + onPress, }: { site: SiteData; - navigation: unknown; + onPress?: () => void; + onRemoveFavorite?: () => void; }) => ( - + {site.id} {site.name} - {String(!!navigation)} - + {String(!!onPress)} + ), }; }); @@ -129,12 +131,33 @@ describe('SitesList', () => { }); describe('props passthrough', () => { - it('passes navigation to SiteRowItemWrapper', () => { + it('passes onPress handler to SiteRowItem', () => { const sites = [createSite('1')]; const { getByTestId } = render(); - expect(getByTestId('has-navigation-1').props.children).toBe('true'); + expect(getByTestId('has-onpress-1').props.children).toBe('true'); + }); + }); + + describe('navigation', () => { + it('navigates to browser with site URL when row is pressed', () => { + const sites = [createSite('1', { url: 'https://example.com' })]; + + const { getByTestId } = render(); + + fireEvent.press(getByTestId('site-wrapper-1')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith( + Routes.BROWSER.HOME, + expect.objectContaining({ + screen: Routes.BROWSER.VIEW, + params: expect.objectContaining({ + newTabUrl: 'https://example.com', + fromTrending: true, + }), + }), + ); }); }); @@ -185,13 +208,13 @@ describe('SitesList', () => { expect(getByTestId('site-wrapper-49')).toBeOnTheScreen(); }); - it('renders when navigation is not provided', () => { + it('still wires onPress when navigation hook returns undefined', () => { (useNavigation as jest.Mock).mockReturnValue(undefined); const sites = [createSite('1')]; const { getByTestId } = render(); - expect(getByTestId('has-navigation-1').props.children).toBe('false'); + expect(getByTestId('has-onpress-1').props.children).toBe('true'); }); }); }); diff --git a/app/components/UI/Sites/components/SitesList/SitesList.tsx b/app/components/UI/Sites/components/SitesList/SitesList.tsx index e10a1eb1473..6398b435507 100644 --- a/app/components/UI/Sites/components/SitesList/SitesList.tsx +++ b/app/components/UI/Sites/components/SitesList/SitesList.tsx @@ -1,24 +1,49 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { FlashList } from '@shopify/flash-list'; import { useNavigation } from '@react-navigation/native'; -import SiteRowItemWrapper from '../SiteRowItemWrapper/SiteRowItemWrapper'; -import type { SiteData } from '../SiteRowItem/SiteRowItem'; +import SiteRowItem, { SiteData } from '../SiteRowItem/SiteRowItem'; +import Routes from '../../../../../constants/navigation/Routes'; +import type { AppNavigationProp } from '../../../../../core/NavigationService/types'; export interface SitesListProps { sites: SiteData[]; refreshControl?: React.ReactElement; ListFooterComponent?: React.ReactElement | null; + onRemoveFavorite?: (site: SiteData) => void; } const SitesList: React.FC = ({ sites, refreshControl, ListFooterComponent, + onRemoveFavorite, }) => { - const navigation = useNavigation(); + const navigation = useNavigation(); - const renderSiteItem = ({ item }: { item: SiteData }) => ( - + const renderSiteItem = useCallback( + ({ item }: { item: SiteData }) => { + const handlePress = () => { + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: item.url, + timestamp: Date.now(), + fromTrending: true, + }, + }); + }; + + return ( + onRemoveFavorite(item) : undefined + } + /> + ); + }, + [navigation, onRemoveFavorite], ); return ( diff --git a/app/components/UI/Sites/hooks/useBrowserFavoritesSites/useBrowserFavoritesSites.test.ts b/app/components/UI/Sites/hooks/useBrowserFavoritesSites/useBrowserFavoritesSites.test.ts new file mode 100644 index 00000000000..c259c6015c3 --- /dev/null +++ b/app/components/UI/Sites/hooks/useBrowserFavoritesSites/useBrowserFavoritesSites.test.ts @@ -0,0 +1,76 @@ +import { renderHook } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { useBrowserFavoritesSites } from './useBrowserFavoritesSites'; + +jest.mock('react-redux', () => ({ useSelector: jest.fn() })); +jest.mock('../../../../../selectors/browser', () => ({ + selectBrowserBookmarksWithType: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +const setBookmarks = (bookmarks: { url: string; name: string }[]) => { + mockUseSelector.mockReturnValue(bookmarks); +}; + +describe('useBrowserFavoritesSites', () => { + beforeEach(() => mockUseSelector.mockReset()); + + it('maps bookmarks to SiteData with prefixed URL and full display URL', () => { + setBookmarks([{ url: 'uniswap.org', name: 'Uniswap' }]); + const { result } = renderHook(() => useBrowserFavoritesSites()); + + expect(result.current.data).toHaveLength(1); + expect(result.current.data[0]).toEqual( + expect.objectContaining({ + name: 'Uniswap', + url: 'https://uniswap.org', + displayUrl: 'uniswap.org', + storedBookmarkUrl: 'uniswap.org', + }), + ); + }); + + it('shows full path in displayUrl for favorites', () => { + setBookmarks([ + { url: 'https://app.uniswap.org/swap?chain=1', name: 'Uniswap' }, + ]); + const { result } = renderHook(() => useBrowserFavoritesSites()); + expect(result.current.data[0].displayUrl).toBe( + 'app.uniswap.org/swap?chain=1', + ); + }); + + it('falls back to the display URL when the bookmark has no name', () => { + setBookmarks([{ url: 'https://example.com/path', name: '' }]); + const { result } = renderHook(() => useBrowserFavoritesSites()); + expect(result.current.data[0].name).toBe('example.com'); + }); + + it('skips entries with no URL', () => { + setBookmarks([ + { url: '', name: 'Empty' }, + { url: 'https://ok.example.com', name: 'OK' }, + ]); + const { result } = renderHook(() => useBrowserFavoritesSites()); + expect(result.current.data.map((s) => s.name)).toEqual(['OK']); + }); + + it('filters by search query against name + URL fields', () => { + setBookmarks([ + { url: 'https://uniswap.org', name: 'Uniswap' }, + { url: 'https://opensea.io', name: 'OpenSea' }, + ]); + const { result } = renderHook(() => useBrowserFavoritesSites('open')); + expect(result.current.data.map((s) => s.name)).toEqual(['OpenSea']); + }); + + it('returns the full list when the search query is whitespace', () => { + setBookmarks([ + { url: 'https://a.com', name: 'A' }, + { url: 'https://b.com', name: 'B' }, + ]); + const { result } = renderHook(() => useBrowserFavoritesSites(' ')); + expect(result.current.data).toHaveLength(2); + }); +}); diff --git a/app/components/UI/Sites/hooks/useBrowserFavoritesSites/useBrowserFavoritesSites.ts b/app/components/UI/Sites/hooks/useBrowserFavoritesSites/useBrowserFavoritesSites.ts new file mode 100644 index 00000000000..ad0cb9813b3 --- /dev/null +++ b/app/components/UI/Sites/hooks/useBrowserFavoritesSites/useBrowserFavoritesSites.ts @@ -0,0 +1,57 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { prefixUrlWithProtocol } from '../../../../../util/browser'; +import { selectBrowserBookmarksWithType } from '../../../../../selectors/browser'; +import type { SiteData } from '../../components/SiteRowItem/SiteRowItem'; +import { + extractDisplayUrl, + extractFullDisplayUrl, + matchesSiteQuery, +} from '../useSiteData/useSitesData'; + +interface BookmarkEntry { + url: string; + name: string; +} + +const toSiteData = (entry: BookmarkEntry, index: number): SiteData => { + const url = prefixUrlWithProtocol(entry.url.trim()); + return { + id: `browser-favorite-${url}-${index}`, + name: entry.name?.trim() || extractDisplayUrl(url), + url, + displayUrl: extractFullDisplayUrl(url), + storedBookmarkUrl: entry.url, + }; +}; + +/** + * Bookmarked (favorited) browser sites, mapped to {@link SiteData} for Explore/Dapps UI. + * Accepts an optional searchQuery for client-side filtering. + * Data is Redux-driven and requires no remote fetch. + */ +export const useBrowserFavoritesSites = ( + searchQuery?: string, +): { + data: SiteData[]; + isLoading: boolean; + refetch: () => Promise; +} => { + const bookmarks = useSelector( + selectBrowserBookmarksWithType, + ) as BookmarkEntry[]; + + const data = useMemo(() => { + const all = bookmarks.filter((b) => b?.url).map((b, i) => toSiteData(b, i)); + const query = searchQuery?.trim() ?? ''; + return query ? all.filter((s) => matchesSiteQuery(s, query)) : all; + }, [bookmarks, searchQuery]); + + return { + data, + isLoading: false, + refetch: async () => { + // Bookmarks are Redux-driven; no remote refetch. + }, + }; +}; diff --git a/app/components/UI/Sites/hooks/useBrowserRecentsSites/useBrowserRecentsSites.test.ts b/app/components/UI/Sites/hooks/useBrowserRecentsSites/useBrowserRecentsSites.test.ts new file mode 100644 index 00000000000..13e5acd86a2 --- /dev/null +++ b/app/components/UI/Sites/hooks/useBrowserRecentsSites/useBrowserRecentsSites.test.ts @@ -0,0 +1,100 @@ +import { renderHook } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { useBrowserRecentsSites } from './useBrowserRecentsSites'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +// Short-circuit the transitive import chain +// (browser selectors → UrlAutocomplete → entire TrendingView graph). +jest.mock('../../../../../selectors/browser', () => ({ + selectBrowserHistoryWithType: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; + +const setHistory = (history: { url: string; name: string }[]) => { + mockUseSelector.mockReturnValue(history); +}; + +describe('useBrowserRecentsSites', () => { + beforeEach(() => { + mockUseSelector.mockReset(); + }); + + it('returns an empty list when there is no history', () => { + setHistory([]); + const { result } = renderHook(() => useBrowserRecentsSites()); + expect(result.current.data).toEqual([]); + expect(result.current.isLoading).toBe(false); + }); + + it('deduplicates entries that normalize to the same URL', () => { + // Same origin+path; different trailing slash and hash should collapse. + setHistory([ + { url: 'https://uniswap.org/swap', name: 'Uniswap Swap' }, + { url: 'https://uniswap.org/swap/', name: 'Uniswap Swap dup1' }, + { url: 'https://uniswap.org/swap#fragment', name: 'Uniswap Swap dup2' }, + ]); + const { result } = renderHook(() => useBrowserRecentsSites()); + expect(result.current.data).toHaveLength(1); + expect(result.current.data[0].name).toBe('Uniswap Swap'); + }); + + it('caps results at 5 entries (latest first)', () => { + setHistory( + Array.from({ length: 10 }, (_, i) => ({ + url: `https://site${i}.example.com`, + name: `Site ${i}`, + })), + ); + const { result } = renderHook(() => useBrowserRecentsSites()); + expect(result.current.data).toHaveLength(5); + // Selector returns history reversed (latest first); dedup keeps first occurrence. + expect(result.current.data.map((s) => s.name)).toEqual([ + 'Site 0', + 'Site 1', + 'Site 2', + 'Site 3', + 'Site 4', + ]); + }); + + it('falls back gracefully when an entry has a malformed URL', () => { + // The new URL() in normalizeUrlKey throws — the catch returns the raw string. + setHistory([ + { url: 'not a real url', name: 'Bad' }, + { url: 'https://good.example.com', name: 'Good' }, + ]); + const { result } = renderHook(() => useBrowserRecentsSites()); + // Both pass; neither throws. + expect(result.current.data.map((s) => s.name)).toEqual(['Bad', 'Good']); + }); + + it('skips entries that have no URL', () => { + setHistory([ + { url: '', name: 'Empty' }, + { url: 'https://ok.example.com', name: 'OK' }, + ]); + const { result } = renderHook(() => useBrowserRecentsSites()); + expect(result.current.data.map((s) => s.name)).toEqual(['OK']); + }); + + it('uses extracted display URL when entry name is empty', () => { + setHistory([{ url: 'https://example.com/path', name: '' }]); + const { result } = renderHook(() => useBrowserRecentsSites()); + expect(result.current.data[0].name).toBe('example.com'); + expect(result.current.data[0].displayUrl).toBe('example.com/path'); + }); + + it('shows full path in displayUrl for recents', () => { + setHistory([ + { url: 'https://app.uniswap.org/swap?chain=1', name: 'Uniswap' }, + ]); + const { result } = renderHook(() => useBrowserRecentsSites()); + expect(result.current.data[0].displayUrl).toBe( + 'app.uniswap.org/swap?chain=1', + ); + }); +}); diff --git a/app/components/UI/Sites/hooks/useBrowserRecentsSites/useBrowserRecentsSites.ts b/app/components/UI/Sites/hooks/useBrowserRecentsSites/useBrowserRecentsSites.ts new file mode 100644 index 00000000000..f92f4585cc3 --- /dev/null +++ b/app/components/UI/Sites/hooks/useBrowserRecentsSites/useBrowserRecentsSites.ts @@ -0,0 +1,74 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { prefixUrlWithProtocol } from '../../../../../util/browser'; +import { selectBrowserHistoryWithType } from '../../../../../selectors/browser'; +import type { SiteData } from '../../components/SiteRowItem/SiteRowItem'; +import { + extractDisplayUrl, + extractFullDisplayUrl, +} from '../useSiteData/useSitesData'; + +const MAX_RECENT_SITES = 5; + +interface HistoryEntry { + url: string; + name: string; +} + +const normalizeUrlKey = (raw: string): string => { + try { + const u = new URL(prefixUrlWithProtocol(raw.trim())); + u.hash = ''; + const path = u.pathname.replace(/\/$/, '') || '/'; + return `${u.origin}${path}`; + } catch { + return raw; + } +}; + +const toSiteData = (entry: HistoryEntry, index: number): SiteData => { + const url = prefixUrlWithProtocol(entry.url.trim()); + return { + id: `browser-recent-${normalizeUrlKey(url)}-${index}`, + name: entry.name?.trim() || extractDisplayUrl(url), + url, + displayUrl: extractFullDisplayUrl(url), + }; +}; + +/** + * Most recent unique browser history entries, mapped to {@link SiteData} for Explore/Dapps UI. + * History is de-duplicated by normalized URL; latest visit wins. + */ +export const useBrowserRecentsSites = (): { + data: SiteData[]; + isLoading: boolean; + refetch: () => Promise; +} => { + // selectBrowserHistoryWithType returns history already reversed (most recent first) + const history = useSelector(selectBrowserHistoryWithType) as HistoryEntry[]; + + const data = useMemo(() => { + const seen = new Set(); + const out: SiteData[] = []; + + for (const entry of history) { + if (out.length >= MAX_RECENT_SITES) break; + if (!entry?.url) continue; + const key = normalizeUrlKey(entry.url); + if (seen.has(key)) continue; + seen.add(key); + out.push(toSiteData(entry, out.length)); + } + + return out; + }, [history]); + + return { + data, + isLoading: false, + refetch: async () => { + // History is driven by Redux; no remote refetch. + }, + }; +}; diff --git a/app/components/UI/Sites/hooks/useSiteData/useSitesData.test.ts b/app/components/UI/Sites/hooks/useSiteData/useSitesData.test.ts index 4be690d798b..c9d2fe53052 100644 --- a/app/components/UI/Sites/hooks/useSiteData/useSitesData.test.ts +++ b/app/components/UI/Sites/hooks/useSiteData/useSitesData.test.ts @@ -1,5 +1,11 @@ import { renderHook, waitFor } from '@testing-library/react-native'; -import { useSitesData, clearSitesCache } from './useSitesData'; +import { + useSitesData, + clearSitesCache, + matchesSiteQuery, + extractFullDisplayUrl, +} from './useSitesData'; +import type { SiteData } from '../../components/SiteRowItem/SiteRowItem'; import Logger from '../../../../../util/Logger'; // Mock dependencies @@ -330,3 +336,50 @@ describe('useSitesData', () => { }); }); }); + +describe('extractFullDisplayUrl', () => { + it('strips the protocol and keeps path + query', () => { + expect(extractFullDisplayUrl('https://app.uniswap.org/swap?chain=1')).toBe( + 'app.uniswap.org/swap?chain=1', + ); + }); + + it('strips www prefix', () => { + expect(extractFullDisplayUrl('https://www.example.com/path')).toBe( + 'example.com/path', + ); + }); + + it('returns just the hostname when there is no path', () => { + expect(extractFullDisplayUrl('https://uniswap.org')).toBe('uniswap.org'); + }); + + it('returns the raw string for invalid URLs', () => { + expect(extractFullDisplayUrl('not a url')).toBe('not a url'); + }); +}); + +describe('matchesSiteQuery', () => { + const site: SiteData = { + id: '1', + name: 'Uniswap', + url: 'https://uniswap.org/swap', + displayUrl: 'uniswap.org', + }; + + it('matches case-insensitively against the name', () => { + expect(matchesSiteQuery(site, 'UNI')).toBe(true); + }); + + it('matches against the displayUrl', () => { + expect(matchesSiteQuery(site, 'uniswap.org')).toBe(true); + }); + + it('matches a path fragment present only in the full url', () => { + expect(matchesSiteQuery(site, '/swap')).toBe(true); + }); + + it('returns false when no field contains the query', () => { + expect(matchesSiteQuery(site, 'opensea')).toBe(false); + }); +}); diff --git a/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts b/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts index 7fd4bae8c26..0287cf61ecf 100644 --- a/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts +++ b/app/components/UI/Sites/hooks/useSiteData/useSitesData.ts @@ -1,5 +1,6 @@ import { useEffect, useState, useCallback, useMemo } from 'react'; import Logger from '../../../../../util/Logger'; +import { prefixUrlWithProtocol } from '../../../../../util/browser'; import type { SiteData } from '../../components/SiteRowItem/SiteRowItem'; interface ApiDappResponse { @@ -60,9 +61,10 @@ const PORTFOLIO_SITE: SiteData = { }; /** - * Helper function to extract display URL from full URL + * Returns just the hostname (no www., no path) — used for curated site cards + * where a clean domain label is preferred. */ -const extractDisplayUrl = (url: string): string => { +export const extractDisplayUrl = (url: string): string => { try { const urlObj = new URL(url); return urlObj.hostname.replace('www.', ''); @@ -71,14 +73,35 @@ const extractDisplayUrl = (url: string): string => { } }; +/** + * Returns the full URL without the protocol (e.g. `uniswap.org/swap?chain=1`). + * Used for user-generated entries (recents, favorites) where the full path + * provides meaningful context. + */ +export const extractFullDisplayUrl = (url: string): string => { + try { + const urlObj = new URL(url); + const withoutProtocol = url.slice(urlObj.protocol.length + 2); // strip "scheme://" + return withoutProtocol.replace(/^www\./, ''); + } catch { + return url; + } +}; + +export const matchesSiteQuery = (site: SiteData, query: string): boolean => { + const q = query.toLowerCase(); + return ( + site.name.toLowerCase().includes(q) || + site.displayUrl.toLowerCase().includes(q) || + site.url.toLowerCase().includes(q) + ); +}; + const isPortfolioSiteUrl = (url: string): boolean => { try { - const trimmedUrl = url.trim(); - const normalizedUrl = - trimmedUrl.startsWith('http://') || trimmedUrl.startsWith('https://') - ? trimmedUrl - : `https://${trimmedUrl}`; - return new URL(normalizedUrl).hostname === PORTFOLIO_HOSTNAME; + return ( + new URL(prefixUrlWithProtocol(url.trim())).hostname === PORTFOLIO_HOSTNAME + ); } catch { return false; } @@ -174,17 +197,10 @@ export const useSitesData = (searchQuery?: string): UseSitesDataResult => { // Filter sites locally based on search query const sites = useMemo(() => { - if (!searchQuery?.trim()) { - return allSites; - } - - const query = searchQuery.toLowerCase().trim(); - return allSites.filter( - (site) => - site.name.toLowerCase().includes(query) || - site.displayUrl.toLowerCase().includes(query) || - site.url.toLowerCase().includes(query), - ); + const query = searchQuery?.trim() ?? ''; + return query + ? allSites.filter((site) => matchesSiteQuery(site, query)) + : allSites; }, [allSites, searchQuery]); return { sites, isLoading, error, refetch }; diff --git a/app/components/UI/SkipAccountSecurityModal/index.js b/app/components/UI/SkipAccountSecurityModal/index.js index a46800d47a5..77391bebb7b 100644 --- a/app/components/UI/SkipAccountSecurityModal/index.js +++ b/app/components/UI/SkipAccountSecurityModal/index.js @@ -11,7 +11,6 @@ import Text, { } from '../../../component-library/components/Texts/Text'; import PropTypes from 'prop-types'; import { useTheme } from '../../../util/theme'; -import generateTestId from '../../../../wdio/utils/generateTestId'; import { SkipAccountSecurityModalSelectorsIDs } from './SkipAccountSecurityModal.testIds'; import BottomSheet from '../../../component-library/components/BottomSheets/BottomSheet'; import Checkbox from '../../../component-library/components/Checkbox'; @@ -102,7 +101,7 @@ const SkipAccountSecurityModal = ({ route }) => { name={IconName.Danger} size={IconSize.Lg} style={styles.imageWarning} - {...generateTestId(Platform, 'skip-backup-warning')} + testID="skip-backup-warning" /> diff --git a/app/components/UI/StyledButton/__snapshots__/index.test.tsx.snap b/app/components/UI/StyledButton/__snapshots__/index.test.tsx.snap deleted file mode 100644 index ff106165532..00000000000 --- a/app/components/UI/StyledButton/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,1035 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`StyledButton should render correctly on Android the button with type cancel 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type confirm 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type danger 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type info 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type inverse-transparent 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type normal 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type onOverlay 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type orange 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type secondary 1`] = ` - -`; - -exports[`StyledButton should render correctly on Android the button with type transparent 1`] = ` - -`; - -exports[`StyledButton should render correctly on iOS the button with type cancel 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type confirm 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type danger 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type info 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type inverse-transparent 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type normal 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type onOverlay 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type orange 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type secondary 1`] = ` - - - -`; - -exports[`StyledButton should render correctly on iOS the button with type transparent 1`] = ` - - - -`; diff --git a/app/components/UI/StyledButton/index.test.tsx b/app/components/UI/StyledButton/index.test.tsx index 68fb06c73aa..f604f389e25 100644 --- a/app/components/UI/StyledButton/index.test.tsx +++ b/app/components/UI/StyledButton/index.test.tsx @@ -20,23 +20,23 @@ describe('StyledButton', () => { buttonTypes.forEach((type) => { it(`should render correctly on iOS the button with type ${type}`, () => { - const { toJSON } = render( + const { getByRole } = render( , ); - expect(toJSON()).toMatchSnapshot(); + expect(getByRole('button')).toBeOnTheScreen(); }); }); buttonTypes.forEach((type) => { it(`should render correctly on Android the button with type ${type}`, () => { - const { toJSON } = render( + const { getByRole } = render( , ); - expect(toJSON()).toMatchSnapshot(); + expect(getByRole('button')).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Tabs/TabCountIcon/__snapshots__/index.test.tsx.snap b/app/components/UI/Tabs/TabCountIcon/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 9d95d9bb11d..00000000000 --- a/app/components/UI/Tabs/TabCountIcon/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,34 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TabCountIcon should render correctly 1`] = ` - - - 1 - - -`; diff --git a/app/components/UI/Tabs/TabCountIcon/index.test.tsx b/app/components/UI/Tabs/TabCountIcon/index.test.tsx index 89ff6fe900c..7c8c02f8a03 100644 --- a/app/components/UI/Tabs/TabCountIcon/index.test.tsx +++ b/app/components/UI/Tabs/TabCountIcon/index.test.tsx @@ -4,6 +4,7 @@ import configureMockStore from 'redux-mock-store'; import { render } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import { ThemeContext, mockTheme } from '../../../../util/theme'; +import { BrowserViewSelectorsIDs } from '../../../Views/BrowserTab/BrowserView.testIds'; const mockStore = configureMockStore(); const initialState = { @@ -14,15 +15,16 @@ const initialState = { const store = mockStore(initialState); describe('TabCountIcon', () => { - it('should render correctly', () => { - // eslint-disable-next-line react/jsx-no-bind - const { toJSON } = render( + it('shows the tab count from Redux state', () => { + const { getByTestId } = render( , ); - expect(toJSON()).toMatchSnapshot(); + const countLabel = getByTestId(BrowserViewSelectorsIDs.TABS_NUMBER); + expect(countLabel).toBeOnTheScreen(); + expect(countLabel.props.children).toBe(1); }); }); diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx index 2851f3f6246..71d10b709cb 100644 --- a/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.tsx @@ -44,8 +44,15 @@ import Balance from '../../AssetOverview/Balance'; import TokenDetails from '../../AssetOverview/TokenDetails'; import { TokenDetailsActions } from './TokenDetailsActions'; import AssetOverviewClaimBonus from '../../Earn/components/AssetOverviewClaimBonus'; +import MoneyConvertStablecoins from '../../Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins'; +import { MONEY_EVENTS_CONSTANTS } from '../../Money/constants/moneyEvents'; import { isTokenEligibleForMerklRewards } from '../../Earn/components/MerklRewards/hooks/useMerklRewards'; -import { selectMerklCampaignClaimingEnabledFlag } from '../../Earn/selectors/featureFlags'; +import { isMusdToken } from '../../Earn/constants/musd'; +import { + selectIsMusdConversionFlowEnabledFlag, + selectMerklCampaignClaimingEnabledFlag, +} from '../../Earn/selectors/featureFlags'; +import { useMusdConversionEligibility } from '../../Earn/hooks/useMusdConversionEligibility'; import PerpsDiscoveryBanner from '../../Perps/components/PerpsDiscoveryBanner'; import { isTokenTrustworthyForPerps } from '../../Perps/constants/perpsConfig'; import { selectTokenOverviewAdvancedChartEnabled } from '../../../../selectors/featureFlagController/tokenOverviewAdvancedChart'; @@ -341,6 +348,15 @@ const AssetOverviewContent: React.FC = ({ [isMerklClaimingEnabled, token.chainId, token.address], ); + const isMusdConversionFlowEnabled = useSelector( + selectIsMusdConversionFlowEnabledFlag, + ); + const { isEligible: isMusdGeoEligible } = useMusdConversionEligibility(); + const showMusdConvertSection = + isMusdToken(token.address) && + isMusdConversionFlowEnabled && + isMusdGeoEligible; + const securityConfig = useMemo( () => getResultTypeConfig(securityData?.resultType), [securityData?.resultType], @@ -748,6 +764,11 @@ const AssetOverviewContent: React.FC = ({ {isTokenEligibleForMerklClaim && ( )} + {showMusdConvertSection && ( + + )} { ///: BEGIN:ONLY_INCLUDE_IF(tron) tronNativeToken && ( diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.view.test.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.view.test.tsx new file mode 100644 index 00000000000..0c3bc858d9e --- /dev/null +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.view.test.tsx @@ -0,0 +1,231 @@ +import '../../../../../tests/component-view/mocks'; +import React from 'react'; +import { Text } from 'react-native'; +import { createStackNavigator } from '@react-navigation/stack'; +import { merge } from 'lodash'; +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; + +import renderWithProvider, { + type ProviderValues, +} from '../../../../util/test/renderWithProvider'; + +import AssetOverviewContent, { + type AssetOverviewContentProps, +} from './AssetOverviewContent'; +import { TokenI } from '../../Tokens/types'; +import { TimePeriod } from '../../../hooks/useTokenHistoricalPrices'; +import { TokenOverviewSelectorsIDs } from '../../AssetOverview/TokenOverview.testIds'; +import { MarketInsightsSelectorsIDs } from '../../MarketInsights/MarketInsights.testIds'; +import { remoteFeatureFlagMarketInsightsEnabled } from '../../../../../tests/api-mocking/mock-responses/feature-flags-mocks'; +import { + MOCK_PERPS_MARKET_INSIGHTS_REPORT, + setupMarketInsightsEngineMock, +} from '../../../../../tests/component-view/fixtures/perpsMarketInsights'; +import { initialStateAssetDetails } from '../../../../../tests/component-view/presets/assetDetails'; +import { + fiatOrdersRampRoutingSupported, + initialStateMarketInsightsView, +} from '../../../../../tests/component-view/presets/marketInsightsView'; +import { describeForPlatforms } from '../../../../../tests/component-view/platform'; +import Routes from '../../../../constants/navigation/Routes'; +import MarketInsightsView from '../../MarketInsights/Views/MarketInsightsView/MarketInsightsView'; +import { AccessRestrictedProvider } from '../../Compliance'; + +const ETH_NATIVE = '0x0000000000000000000000000000000000000000'; + +const ethMainnetToken: TokenI = { + address: ETH_NATIVE, + chainId: '0x1', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + balance: '1', + balanceFiat: '$2000', + logo: '', + image: '', + isETH: true, + isNative: true, + hasBalanceError: false, + aggregators: [], +}; + +const baseOverviewProps: AssetOverviewContentProps = { + token: ethMainnetToken, + balance: '1', + mainBalance: '$2,000.00', + secondaryBalance: '1 ETH', + currentPrice: 2000, + priceDiff: 0, + comparePrice: 2000, + prices: [], + isLoading: false, + timePeriod: '1d' as TimePeriod, + setTimePeriod: () => undefined, + chartNavigationButtons: ['1d', '1w', '1m', '3m', '1y', '3y'], + isPerpsEnabled: false, + currentCurrency: 'USD', + onBuy: () => undefined, + onSend: async () => undefined, + onReceive: () => undefined, +}; + +function AssetOverviewContentHarness() { + return ; +} + +function renderAssetOverviewMarketInsightsStack( + extraRoutes: { + name: string; + Component?: React.ComponentType; + }[], + providerValues: ProviderValues, +) { + const Stack = createStackNavigator(); + + const DefaultRouteProbe = + (routeName: string): React.FC => + () => {routeName}; + + return renderWithProvider( + + + + {extraRoutes.map(({ name, Component: Extra }) => ( + + ))} + + , + providerValues, + ); +} + +/** + * Bridge + ramps + multichain balances (MarketInsightsView preset) merged with Asset + * Details preset so `AssetOverviewContent` selectors (Earn, staking, etc.) resolve. + */ +function buildTokenDetailsMarketInsightsState( + marketInsightsFlagEnabled: boolean, +) { + return merge( + {}, + initialStateAssetDetails({ deterministicFiat: true }).build(), + initialStateMarketInsightsView() + .withOverrides(fiatOrdersRampRoutingSupported) + .build(), + { + engine: { + backgroundState: { + TokenListController: { + tokensChainsCache: {}, + }, + RemoteFeatureFlagController: { + remoteFeatureFlags: remoteFeatureFlagMarketInsightsEnabled( + marketInsightsFlagEnabled, + ), + }, + EarnController: { + pooled_staking: { isEligible: false }, + lending: { positions: [], markets: [] }, + }, + }, + }, + }, + ); +} + +describeForPlatforms( + 'AssetOverviewContent (Market Insights entry card)', + () => { + it('does not show entry card or skeleton after fetch when API returns no report', async () => { + setupMarketInsightsEngineMock(null); + + renderAssetOverviewMarketInsightsStack( + [ + { + name: Routes.MARKET_INSIGHTS.VIEW, + Component: + MarketInsightsView as unknown as React.ComponentType, + }, + ], + { state: buildTokenDetailsMarketInsightsState(true) }, + ); + + await waitFor( + () => { + expect( + screen.queryByTestId( + MarketInsightsSelectorsIDs.ENTRY_CARD_SKELETON, + ), + ).toBeNull(); + }, + { timeout: 15000 }, + ); + expect( + screen.queryByTestId(MarketInsightsSelectorsIDs.ENTRY_CARD), + ).toBeNull(); + }); + + it('does not show entry card when market insights feature flag is off', async () => { + setupMarketInsightsEngineMock(MOCK_PERPS_MARKET_INSIGHTS_REPORT); + + renderAssetOverviewMarketInsightsStack( + [ + { + name: Routes.MARKET_INSIGHTS.VIEW, + Component: + MarketInsightsView as unknown as React.ComponentType, + }, + ], + { state: buildTokenDetailsMarketInsightsState(false) }, + ); + + expect( + await screen.findByTestId(TokenOverviewSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + + await waitFor(() => { + expect( + screen.queryByTestId(MarketInsightsSelectorsIDs.ENTRY_CARD_SKELETON), + ).toBeNull(); + }); + expect( + screen.queryByTestId(MarketInsightsSelectorsIDs.ENTRY_CARD), + ).toBeNull(); + }); + + it('shows entry card when report exists and opens Market Insights on press', async () => { + setupMarketInsightsEngineMock(MOCK_PERPS_MARKET_INSIGHTS_REPORT); + + renderAssetOverviewMarketInsightsStack( + [ + { + name: Routes.MARKET_INSIGHTS.VIEW, + Component: + MarketInsightsView as unknown as React.ComponentType, + }, + { name: Routes.BRIDGE.ROOT }, + { name: Routes.RAMP.TOKEN_SELECTION }, + ], + { state: buildTokenDetailsMarketInsightsState(true) }, + ); + + const entryCard = await screen.findByTestId( + MarketInsightsSelectorsIDs.ENTRY_CARD, + {}, + { timeout: 15000 }, + ); + fireEvent.press(entryCard); + + expect( + await screen.findByTestId(MarketInsightsSelectorsIDs.VIEW_CONTAINER), + ).toBeOnTheScreen(); + }); + }, +); diff --git a/app/components/UI/TokenDetails/constants/constants.ts b/app/components/UI/TokenDetails/constants/constants.ts index bd773a049a1..d2f2de6a6fc 100644 --- a/app/components/UI/TokenDetails/constants/constants.ts +++ b/app/components/UI/TokenDetails/constants/constants.ts @@ -14,6 +14,14 @@ export enum TokenDetailsSource { HomeSection = 'home_section', /** Trending tokens section (e.g. Explore tab) */ Trending = 'trending', + /** Explore Now tab — crypto movers pills */ + ExploreNowMovers = 'explore_now_movers', + /** Explore Now tab — stocks list */ + ExploreNowStocks = 'explore_now_stocks', + /** Explore Crypto tab — trending tokens list */ + ExploreCryptoTrending = 'explore_crypto_trending', + /** Explore RWAs tab — stocks list */ + ExploreRwasStocks = 'explore_rwas_stocks', /** Trending tokens section on the Swaps / Bridge view */ TrendingSwaps = 'trending-swaps', /** Dedicated homepage trending-tokens section (A/B treatment layout) */ diff --git a/app/components/UI/TokenDetails/hooks/useTokenTransactions.test.ts b/app/components/UI/TokenDetails/hooks/useTokenTransactions.test.ts new file mode 100644 index 00000000000..91e087d8c7c --- /dev/null +++ b/app/components/UI/TokenDetails/hooks/useTokenTransactions.test.ts @@ -0,0 +1,331 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { useTokenTransactions } from './useTokenTransactions'; +import { TokenI } from '../../Tokens/types'; +import { TX_CONFIRMED } from '../../../../constants/transaction'; +import { + selectTransactions, + selectSwapsTransactions, +} from '../../../../selectors/transactionController'; +import { selectTokens } from '../../../../selectors/tokensController'; +import { selectSelectedInternalAccount } from '../../../../selectors/accountsController'; +import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; +import { + selectConversionRate, + selectCurrentCurrency, +} from '../../../../selectors/currencyRateController'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('@metamask/bridge-controller', () => ({ + formatChainIdToCaip: jest.fn((chainId: string) => `eip155:${chainId}`), +})); + +jest.mock('../../../../selectors/tokensController', () => ({ + selectTokens: jest.fn(), +})); + +jest.mock('../../../../selectors/transactionController', () => ({ + selectTransactions: jest.fn(), + selectSwapsTransactions: jest.fn(), +})); + +jest.mock('../../../../selectors/accountsController', () => ({ + selectSelectedInternalAccount: jest.fn(), + selectSelectedInternalAccountAddress: jest.fn(), +})); + +jest.mock('../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(), +})); + +jest.mock('../../../../selectors/currencyRateController', () => ({ + selectConversionRate: jest.fn(), + selectCurrentCurrency: jest.fn(), +})); + +jest.mock('../../../../util/activity', () => ({ + sortTransactions: jest.fn((txs: unknown[]) => txs), +})); + +jest.mock('../../../../util/transactions', () => ({ + addAccountTimeFlagFilter: jest.fn(() => false), +})); + +jest.mock('../../../../util/transaction-controller', () => ({ + updateIncomingTransactions: jest.fn(), +})); + +jest.mock('../../../../core/Multichain/utils', () => ({ + isNonEvmChainId: jest.fn(() => false), +})); + +jest.mock('../../Earn/utils/musd', () => ({ + isMusdClaimForCurrentView: jest.fn(() => false), +})); + +jest.mock('../../../../selectors/multichain', () => ({ + selectNonEvmTransactionsForSelectedAccountGroup: jest.fn(), +})); + +jest.mock('../../../../selectors/accountTrackerController', () => ({ + selectAccounts: jest.fn(), + selectAccountsByChainId: jest.fn(), +})); + +jest.mock('../../../UI/TransactionElement/utils', () => ({ + TOKEN_CATEGORY_HASH: { + tokenMethodApprove: true, + tokenMethodSetApprovalForAll: true, + tokenMethodTransfer: true, + tokenMethodTransferFrom: true, + tokenMethodIncreaseAllowance: true, + }, +})); + +jest.mock('../../../../store', () => ({ + store: { + getState: () => ({ + inpageProvider: { networkId: '1' }, + }), + }, +})); + +const MOCK_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678'; +const MOCK_RECIPIENT = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; +const MONAD_CHAIN_ID = '0x279f'; +const ETH_CHAIN_ID = '0x1'; +const MOCK_TOKEN_ADDRESS = '0x6b175474e89094c44da98b954eedeac495271d0f'; + +const mockUseSelector = jest.mocked(useSelector); + +const createMockTransaction = (overrides: Record = {}) => ({ + id: 'tx-1', + chainId: ETH_CHAIN_ID, + status: TX_CONFIRMED, + time: Date.now(), + txParams: { + from: MOCK_ADDRESS, + to: MOCK_RECIPIENT, + }, + isTransfer: false, + ...overrides, +}); + +const createAsset = (overrides: Partial = {}): TokenI => ({ + address: '', + decimals: 18, + image: '', + name: 'Ether', + symbol: 'ETH', + balance: '1000000000000000000', + logo: undefined, + isETH: true, + chainId: ETH_CHAIN_ID, + isNative: true, + ...overrides, +}); + +const setupMocks = (transactions: unknown[] = []) => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectTransactions) return transactions; + if (selector === selectSwapsTransactions) return {}; + if (selector === selectTokens) return []; + if (selector === selectSelectedInternalAccount) { + return { address: MOCK_ADDRESS, metadata: { importTime: 0 } }; + } + if (selector === selectSelectedInternalAccountByScope) { + return () => ({ address: MOCK_ADDRESS }); + } + if (selector === selectConversionRate) return 1; + if (selector === selectCurrentCurrency) return 'usd'; + // Inline selector for selectedAddressForAsset + return MOCK_ADDRESS; + }); +}; + +describe('useTokenTransactions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('filter selection for native tokens', () => { + it('includes native send transaction for ETH (isETH=true)', async () => { + const tx = createMockTransaction({ chainId: ETH_CHAIN_ID }); + setupMocks([tx]); + + const asset = createAsset({ + symbol: 'ETH', + isETH: true, + isNative: true, + address: '', + chainId: ETH_CHAIN_ID, + }); + + const { result } = renderHook(() => useTokenTransactions(asset)); + + await waitFor(() => { + expect(result.current.transactionsUpdated).toBe(true); + }); + + expect(result.current.confirmedTxs.length).toBe(1); + }); + + it('includes native send transaction for non-ETH native token (MON on Monad)', async () => { + const tx = createMockTransaction({ + chainId: MONAD_CHAIN_ID, + txParams: { from: MOCK_ADDRESS, to: MOCK_RECIPIENT }, + }); + setupMocks([tx]); + + const asset = createAsset({ + symbol: 'MON', + isETH: false, + isNative: true, + address: '0x0000000000000000000000000000000000000000', + chainId: MONAD_CHAIN_ID, + }); + + const { result } = renderHook(() => useTokenTransactions(asset)); + + await waitFor(() => { + expect(result.current.transactionsUpdated).toBe(true); + }); + + // Core regression test: MON native sends must appear + expect(result.current.confirmedTxs.length).toBe(1); + }); + + it('excludes token-category transactions from native token view', async () => { + const tx = createMockTransaction({ + chainId: MONAD_CHAIN_ID, + type: 'tokenMethodTransfer', + txParams: { from: MOCK_ADDRESS, to: MOCK_TOKEN_ADDRESS }, + }); + setupMocks([tx]); + + const asset = createAsset({ + symbol: 'MON', + isETH: false, + isNative: true, + address: '0x0000000000000000000000000000000000000000', + chainId: MONAD_CHAIN_ID, + }); + + const { result } = renderHook(() => useTokenTransactions(asset)); + + await waitFor(() => { + expect(result.current.transactionsUpdated).toBe(true); + }); + + expect(result.current.confirmedTxs.length).toBe(0); + expect(result.current.transactions.length).toBe(0); + }); + + it('includes ERC20 token transaction in token-specific view', async () => { + const tx = createMockTransaction({ + chainId: ETH_CHAIN_ID, + txParams: { from: MOCK_ADDRESS, to: MOCK_TOKEN_ADDRESS }, + }); + setupMocks([tx]); + + const asset = createAsset({ + symbol: 'DAI', + isETH: false, + isNative: false, + address: MOCK_TOKEN_ADDRESS, + chainId: ETH_CHAIN_ID, + }); + + const { result } = renderHook(() => useTokenTransactions(asset)); + + await waitFor(() => { + expect(result.current.transactionsUpdated).toBe(true); + }); + + expect(result.current.confirmedTxs.length).toBe(1); + }); + + it('excludes unrelated transactions from ERC20 token view', async () => { + const tx = createMockTransaction({ + chainId: ETH_CHAIN_ID, + txParams: { from: MOCK_ADDRESS, to: MOCK_RECIPIENT }, + }); + setupMocks([tx]); + + const asset = createAsset({ + symbol: 'DAI', + isETH: false, + isNative: false, + address: MOCK_TOKEN_ADDRESS, + chainId: ETH_CHAIN_ID, + }); + + const { result } = renderHook(() => useTokenTransactions(asset)); + + await waitFor(() => { + expect(result.current.transactionsUpdated).toBe(true); + }); + + expect(result.current.confirmedTxs.length).toBe(0); + }); + + it('includes gas-sponsored native send for non-ETH chain', async () => { + const tx = createMockTransaction({ + chainId: MONAD_CHAIN_ID, + txParams: { + from: MOCK_ADDRESS, + to: MOCK_RECIPIENT, + gasPrice: '0x0', + maxFeePerGas: '0x0', + }, + }); + setupMocks([tx]); + + const asset = createAsset({ + symbol: 'MON', + isETH: false, + isNative: true, + address: '0x0000000000000000000000000000000000000000', + chainId: MONAD_CHAIN_ID, + }); + + const { result } = renderHook(() => useTokenTransactions(asset)); + + await waitFor(() => { + expect(result.current.transactionsUpdated).toBe(true); + }); + + // Gas-sponsored (zero gas fee) native sends must still appear + expect(result.current.confirmedTxs.length).toBe(1); + }); + }); + + describe('cross-chain filtering', () => { + it('excludes transactions from a different chain', async () => { + const tx = createMockTransaction({ + chainId: ETH_CHAIN_ID, + txParams: { from: MOCK_ADDRESS, to: MOCK_RECIPIENT }, + }); + setupMocks([tx]); + + const asset = createAsset({ + symbol: 'MON', + isETH: false, + isNative: true, + address: '0x0000000000000000000000000000000000000000', + chainId: MONAD_CHAIN_ID, + }); + + const { result } = renderHook(() => useTokenTransactions(asset)); + + await waitFor(() => { + expect(result.current.transactionsUpdated).toBe(true); + }); + + expect(result.current.confirmedTxs.length).toBe(0); + }); + }); +}); diff --git a/app/components/UI/TokenDetails/hooks/useTokenTransactions.ts b/app/components/UI/TokenDetails/hooks/useTokenTransactions.ts index df04c39c438..90794897a8d 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenTransactions.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenTransactions.ts @@ -374,12 +374,13 @@ export const useTokenTransactions = ( ); // Determine which filter to use + const isNativeToken = asset.isNative || asset.isETH; const filter = useMemo(() => { - if (navSymbol.toUpperCase() !== 'ETH' && navAddress !== '') { - return noEthFilter; + if (isNativeToken) { + return ethFilter; } - return ethFilter; - }, [navSymbol, navAddress, ethFilter, noEthFilter]); + return noEthFilter; + }, [isNativeToken, ethFilter, noEthFilter]); // Check if pending tx statuses changed const didTxStatusesChange = useCallback( diff --git a/app/components/UI/TokenImage/__snapshots__/index.test.tsx.snap b/app/components/UI/TokenImage/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 15964de2db8..00000000000 --- a/app/components/UI/TokenImage/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,19 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TokenImage should render correctly 1`] = ` - -`; diff --git a/app/components/UI/TokenImage/index.test.tsx b/app/components/UI/TokenImage/index.test.tsx deleted file mode 100644 index 3108a01f37d..00000000000 --- a/app/components/UI/TokenImage/index.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import TokenImage from './'; -import configureMockStore from 'redux-mock-store'; -import { Provider } from 'react-redux'; -import { backgroundState } from '../../../util/test/initial-root-state'; - -const mockStore = configureMockStore(); -const initialState = { - engine: { - backgroundState, - }, - settings: { - primaryCurrency: 'usd', - }, -}; -const store = mockStore(initialState); - -describe('TokenImage', () => { - it('should render correctly', () => { - const { toJSON } = render( - - - , - ); - expect(toJSON()).toMatchSnapshot(); - }); -}); diff --git a/app/components/UI/Tokens/TokenList/TokenList.tsx b/app/components/UI/Tokens/TokenList/TokenList.tsx index 584dc59f229..3ce8a4ac964 100644 --- a/app/components/UI/Tokens/TokenList/TokenList.tsx +++ b/app/components/UI/Tokens/TokenList/TokenList.tsx @@ -40,6 +40,7 @@ interface TokenListProps { setShowScamWarningModal: (chainId: string | null) => void; maxItems?: number; isFullView?: boolean; + listHeaderComponent?: React.ReactElement; listFooterComponent?: React.ReactElement; /** * Optional external RefreshControl. When provided, overrides the internal @@ -47,8 +48,18 @@ interface TokenListProps { * refresh orchestrator (e.g. Money Hub). */ refreshControl?: React.ReactElement; + /** + * When true, mUSD rows render only the native balance on the secondary row + * (no token price / 24h change). Used by the Money Hub. + */ + hideSecondaryPriceRow?: boolean; } +const wrapEdgeNode = ( + node: React.ReactElement | undefined, + isFullView: boolean, +) => (isFullView && node ? {node} : node); + const TokenListComponent = ({ tokenKeys, refreshing, @@ -58,8 +69,10 @@ const TokenListComponent = ({ setShowScamWarningModal, maxItems, isFullView = false, + listHeaderComponent, listFooterComponent, refreshControl, + hideSecondaryPriceRow = false, }: TokenListProps) => { const { colors } = useTheme(); const tw = useTailwind(); @@ -155,6 +168,7 @@ const TokenListComponent = ({ showPercentageChange={showPercentageChange} isFullView={isFullView} shouldShowTokenListItemCta={shouldShowTokenListItemCta} + hideSecondaryPriceRow={hideSecondaryPriceRow} /> ), [ @@ -164,6 +178,7 @@ const TokenListComponent = ({ showPercentageChange, isFullView, shouldShowTokenListItemCta, + hideSecondaryPriceRow, ], ); @@ -172,6 +187,7 @@ const TokenListComponent = ({ twClassName={'bg-default'} testID={WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST} > + {listHeaderComponent} {displayTokenKeys.map((item, index) => ( ))} {shouldShowViewAllButton && ( @@ -218,13 +235,8 @@ const TokenListComponent = ({ } extraData={{ isTokenNetworkFilterEqualCurrentNetwork }} contentContainerStyle={!isFullView ? undefined : tw`px-4`} - ListFooterComponent={ - isFullView && listFooterComponent ? ( - {listFooterComponent} - ) : ( - listFooterComponent - ) - } + ListHeaderComponent={wrapEdgeNode(listHeaderComponent, isFullView)} + ListFooterComponent={wrapEdgeNode(listFooterComponent, isFullView)} /> ); diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index 2d119d4a36f..5b5f1a5e7e2 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -30,7 +30,6 @@ import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTo import { useMusdConversionEligibility } from '../../../Earn/hooks/useMusdConversionEligibility'; import { selectIsMusdConversionFlowEnabledFlag, - selectMusdQuickConvertEnabledFlag, selectStablecoinLendingEnabledFlag, } from '../../../Earn/selectors/featureFlags'; import { @@ -38,7 +37,6 @@ import { MUSD_TOKEN_ADDRESS, } from '../../../Earn/constants/musd'; import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences'; -import { MUSD_CONVERSION_NAVIGATION_OVERRIDE } from '../../../Earn/types/musd.types'; jest.mock('../../../Stake/components/StakeButton', () => ({ __esModule: true, @@ -201,7 +199,6 @@ jest.mock('../../../Earn/selectors/featureFlags', () => ({ selectPooledStakingEnabledFlag: jest.fn(() => true), selectStablecoinLendingEnabledFlag: jest.fn(() => false), selectIsMusdConversionFlowEnabledFlag: jest.fn(() => false), - selectMusdQuickConvertEnabledFlag: jest.fn(() => false), selectMusdConversionPaymentTokensAllowlist: jest.fn(() => ({})), })); @@ -215,11 +212,6 @@ const mockSelectStablecoinLendingEnabledFlag = typeof selectStablecoinLendingEnabledFlag >; -const mockSelectMusdQuickConvertEnabledFlag = - selectMusdQuickConvertEnabledFlag as jest.MockedFunction< - typeof selectMusdQuickConvertEnabledFlag - >; - jest.mock('../../util/deriveBalanceFromAssetMarketDetails', () => ({ deriveBalanceFromAssetMarketDetails: jest.fn(() => ({ balanceFiat: '$100.00', @@ -341,7 +333,6 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { asset?: TokenI; pricePercentChange1d?: number; isMusdConversionEnabled?: boolean; - isQuickConvertEnabled?: boolean; isTokenWithCta?: boolean; isGeoEligible?: boolean; isStockToken?: boolean; @@ -359,7 +350,6 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { asset, pricePercentChange1d = 5.67, isMusdConversionEnabled = false, - isQuickConvertEnabled = false, isTokenWithCta = false, isGeoEligible = true, isStockToken = false, @@ -396,9 +386,6 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { mockSelectIsMusdConversionFlowEnabledFlag.mockReturnValue( isMusdConversionEnabled, ); - mockSelectMusdQuickConvertEnabledFlag.mockReturnValue( - isQuickConvertEnabled, - ); mockUseMusdConversionTokens.mockReturnValue({ isConversionToken: jest.fn().mockReturnValue(false), hasConvertibleTokensByChainId: jest.fn().mockReturnValue(false), @@ -428,10 +415,6 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { return isStablecoinLendingEnabled; } - if (selector === selectMusdQuickConvertEnabledFlag) { - return isQuickConvertEnabled; - } - if (selector === selectTokenMarketData) { return tokenMarketData ?? {}; } @@ -812,7 +795,6 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { chainId: toHex('0x1'), }, navigationStack: Routes.EARN.ROOT, - navigationOverride: MUSD_CONVERSION_NAVIGATION_OVERRIDE.QUICK_CONVERT, }); }); }, 10000); @@ -962,80 +944,6 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { expect(mockTrackEvent).toHaveBeenCalledTimes(1); expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); }, 10000); - - it('tracks mUSD conversion CTA clicked event with quick convert redirect when education screen has been seen and quick convert is enabled', async () => { - // Arrange - mockHasSeenConversionEducationScreen = true; - prepareMocks({ - asset: usdcAsset, - isMusdConversionEnabled: true, - isQuickConvertEnabled: true, - isTokenWithCta: true, - }); - - const convertAssetKey: FlashListAssetKey = { - address: usdcAsset.address, - chainId: usdcAsset.chainId, - isStaked: false, - }; - - const { getByTestId, getByText } = renderWithProvider( - , - ); - - await waitFor( - () => { - expect(getByText('Get 3% mUSD bonus')).toBeOnTheScreen(); - }, - { timeout: 3000 }, - ); - - mockTrackEvent.mockClear(); - mockCreateEventBuilder.mockClear(); - mockAddProperties.mockClear(); - mockBuild.mockClear(); - - // Act - await act(async () => { - fireEvent.press(getByTestId(SECONDARY_BALANCE_BUTTON_TEST_ID)); - }); - - // Assert - await waitFor( - () => { - expect(mockCreateEventBuilder).toHaveBeenCalledTimes(1); - }, - { timeout: 3000 }, - ); - const { MetaMetricsEvents } = jest.requireActual( - '../../../../hooks/useMetrics', - ); - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.MUSD_CONVERSION_CTA_CLICKED, - ); - - expect(mockAddProperties).toHaveBeenCalledTimes(1); - expect(mockAddProperties).toHaveBeenCalledWith({ - location: 'token_list_item', - redirects_to: 'quick_convert_home_screen', - cta_type: 'musd_conversion_secondary_cta', - cta_text: strings('earn.musd_conversion.get_a_percentage_musd_bonus', { - percentage: MUSD_CONVERSION_APY, - }), - network_chain_id: usdcAsset.chainId, - network_name: 'Ethereum Mainnet', - asset_symbol: usdcAsset.symbol, - }); - - expect(mockTrackEvent).toHaveBeenCalledTimes(1); - expect(mockTrackEvent).toHaveBeenCalledWith({ name: 'mock-built-event' }); - }, 10000); }); describe('Stock Badge', () => { @@ -1230,6 +1138,56 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { }); }); + describe('hideSecondaryPriceRow (Money Hub compact mUSD layout)', () => { + const musdAsset = { + ...defaultAsset, + address: MUSD_TOKEN_ADDRESS, + symbol: 'mUSD', + name: 'MetaMask USD', + isNative: false, + balance: '1280.34', + balanceFiat: '$1,280.34', + }; + const musdKey: FlashListAssetKey = { + address: MUSD_TOKEN_ADDRESS, + chainId: '0x1', + isStaked: false, + }; + const renderCompact = (key: FlashListAssetKey) => + renderWithProvider( + , + ); + + it('renders compact mUSD layout and navigates on press', () => { + prepareMocks({ asset: musdAsset }); + const { getByText } = renderCompact(musdKey); + expect(getByText('MetaMask USD')).toBeOnTheScreen(); + expect(getByText('1280.34 mUSD')).toBeOnTheScreen(); + fireEvent.press(getByText('MetaMask USD')); + expect(mockNavigate).toHaveBeenCalledWith( + 'Asset', + expect.objectContaining({ symbol: 'mUSD' }), + ); + }); + + it('does not affect non-mUSD rows', () => { + prepareMocks({ asset: defaultAsset }); + const { getByText } = renderCompact({ + address: '0x456', + chainId: '0x1', + isStaked: false, + }); + expect(getByText('Test Token')).toBeOnTheScreen(); + }); + }); + describe('mUSD Bonus Row', () => { const claimableAsset = { ...defaultAsset, @@ -1243,14 +1201,14 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { isStaked: false, }; - it('shows green "3% bonus" on mUSD rows when conversion is enabled', () => { + it('does not render the "3% bonus" label on mUSD rows (MUSD-729)', () => { prepareMocks({ asset: claimableAsset, pricePercentChange1d: 5.0, isMusdConversionEnabled: true, }); - const { getByText, queryByText } = renderWithProvider( + const { queryByText, getByText } = renderWithProvider( { ); expect( - getByText( + queryByText( strings('earn.musd_conversion.percentage_bonus', { percentage: MUSD_CONVERSION_APY, }), ), - ).toBeOnTheScreen(); - expect(queryByText('+5.00%')).toBeNull(); - // Price rail must stay hidden on mUSD bonus rows per Figma. - expect(queryByText(/\u2022/)).toBeNull(); + ).toBeNull(); + // Without the bonus label or a Convert CTA, the row falls back to the + // standard percentage-change rail. + expect(getByText('+5.00%')).toBeOnTheScreen(); }); it('shows normal percentage when mUSD but conversion flow is disabled', () => { diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx index 8c5ab617e8a..1f1dd585081 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { CaipAssetType, Hex } from '@metamask/utils'; import { useNavigation } from '@react-navigation/native'; import React, { useCallback, useMemo } from 'react'; -import { Platform, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { StyleSheet, TouchableOpacity, View } from 'react-native'; import { useSelector } from 'react-redux'; import Badge, { BadgeVariant, @@ -21,12 +21,7 @@ import { TokenI } from '../../types'; import { ScamWarningIcon } from './ScamWarningIcon/ScamWarningIcon'; import useIsOriginalNativeTokenSymbol from '../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol'; import { FlashListAssetKey } from '../TokenList'; -import { - selectIsMusdConversionFlowEnabledFlag, - selectMusdQuickConvertEnabledFlag, - selectStablecoinLendingEnabledFlag, -} from '../../../Earn/selectors/featureFlags'; -import { useMusdConversionEligibility } from '../../../Earn/hooks/useMusdConversionEligibility'; +import { selectStablecoinLendingEnabledFlag } from '../../../Earn/selectors/featureFlags'; import { useTokenPricePercentageChange } from '../../hooks/useTokenPricePercentageChange'; import { selectAsset } from '../../../../../selectors/assets/assets-list'; import Tag from '../../../../../component-library/components/Tags/Tag'; @@ -77,8 +72,7 @@ import { } from '@metamask/assets-controllers'; import { formatPriceWithSubscriptNotation } from '../../../Predict/utils/format'; import { safeToChecksumAddress } from '../../../../../util/address'; -import generateTestId from '../../../../../../wdio/utils/generateTestId'; -import { getAssetTestId } from '../../../../../../wdio/screen-objects/testIDs/Screens/WalletView.testIds'; +import { getAssetTestId } from '../../../../../../tests/selectors/Wallet/WalletView.selectors'; import SkeletonText from '../../../Ramp/Aggregator/components/SkeletonText'; import { TOKEN_BALANCE_LOADING, @@ -92,6 +86,7 @@ import { } from '../../../AssetElement/index.constants'; import { Box, + BoxAlignItems, BoxFlexDirection, BoxJustifyContent, FontWeight, @@ -99,7 +94,6 @@ import { TextColor, TextVariant, } from '@metamask/design-system-react-native'; -import { MUSD_CONVERSION_NAVIGATION_OVERRIDE } from '../../../Earn/types/musd.types'; import TokenListSecurityBadge from '../../components/TokenListSecurityBadge/TokenListSecurityBadge'; import { tokenListSecurityBadgeKeys } from '../../queries/tokenSecurityBadgeKeys'; import { getCaipAssetIdForToken } from '../../util/getCaipAssetIdForToken'; @@ -156,6 +150,11 @@ interface TokenListItemProps { showPercentageChange?: boolean; isFullView?: boolean; shouldShowTokenListItemCta: (asset?: TokenI) => boolean; + /** + * When true, mUSD rows render only the native balance on the secondary row + * (no token price / 24h change). Used by the Money Hub. + */ + hideSecondaryPriceRow?: boolean; } export const TokenListItem = React.memo( @@ -167,6 +166,7 @@ export const TokenListItem = React.memo( showPercentageChange = true, isFullView = false, shouldShowTokenListItemCta, + hideSecondaryPriceRow = false, }: TokenListItemProps) => { const { trackEvent, createEventBuilder } = useAnalytics(); const navigation = useNavigation(); @@ -250,15 +250,6 @@ export const TokenListItem = React.memo( selectStablecoinLendingEnabledFlag, ); - const isQuickConvertEnabled = useSelector( - selectMusdQuickConvertEnabledFlag, - ); - - const isMusdConversionFlowEnabled = useSelector( - selectIsMusdConversionFlowEnabledFlag, - ); - const { isEligible: isMusdGeoEligible } = useMusdConversionEligibility(); - const { getEarnToken } = useEarnTokens(); const earnToken = getEarnToken(asset as TokenI); @@ -272,8 +263,6 @@ export const TokenListItem = React.memo( ); const isMusdAsset = !!asset && isMusdToken(asset.address); - const showMusdBonusRow = - isMusdAsset && isMusdConversionFlowEnabled && isMusdGeoEligible; const pricePercentChange1d = useTokenPricePercentageChange(asset); @@ -347,9 +336,7 @@ export const TokenListItem = React.memo( return EVENT_LOCATIONS.CONVERSION_EDUCATION_SCREEN; } - return isQuickConvertEnabled - ? EVENT_LOCATIONS.QUICK_CONVERT_HOME_SCREEN - : EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN; + return EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN; }; trackEvent( @@ -387,7 +374,6 @@ export const TokenListItem = React.memo( chainId: assetChainId, }, navigationStack: Routes.EARN.ROOT, - navigationOverride: MUSD_CONVERSION_NAVIGATION_OVERRIDE.QUICK_CONVERT, }); } catch (error) { Logger.error( @@ -403,7 +389,6 @@ export const TokenListItem = React.memo( createEventBuilder, hasSeenConversionEducationScreen, initiateCustomConversion, - isQuickConvertEnabled, networkName, trackEvent, ]); @@ -451,16 +436,6 @@ export const TokenListItem = React.memo( }); const secondaryBalanceDisplay = useMemo(() => { - if (showMusdBonusRow) { - return { - text: strings('earn.musd_conversion.percentage_bonus', { - percentage: MUSD_CONVERSION_APY, - }), - color: CLTextColor.Success, - onPress: undefined, - }; - } - if (shouldShowConvertToMusdCta) { return { text: strings('earn.musd_conversion.get_a_percentage_musd_bonus', { @@ -503,7 +478,6 @@ export const TokenListItem = React.memo( return { text, color, onPress: undefined }; }, [ - showMusdBonusRow, shouldShowConvertToMusdCta, isStablecoinLendingEnabled, earnToken?.experience?.type, @@ -562,6 +536,68 @@ export const TokenListItem = React.memo( fiatBalanceDisplay = fiatBalance; } + // Money Hub compact mUSD layout: name vertically centered, fiat over + // native on the right, no price/24h-change row. + if (hideSecondaryPriceRow && isMusdAsset) { + return ( + onItemPress?.(asset)} + style={styles.itemWrapper} + testID={getAssetTestId(asset.symbol)} + > + + ) + } + > + + + + + {asset.name || asset.symbol} + + + + {fiatBalanceDisplay} + + + {tokenBalance} + + + + + ); + } + return ( { @@ -573,7 +609,7 @@ export const TokenListItem = React.memo( onLongPress?.(asset); }} style={styles.itemWrapper} - {...generateTestId(Platform, getAssetTestId(asset.symbol))} + testID={getAssetTestId(asset.symbol)} > {/* Column: 1 - Token logo */} - {showMusdBonusRow ? ( - <> - - - {tokenBalance} - - - - + + {tokenPriceInFiat && !hideFiatForScamWarning + ? formatPriceWithSubscriptNotation( + tokenPriceInFiat, + currentCurrency, + ) + : '-'} + {' \u2022 '} + + + {hideFiatForScamWarning ? ( + + {'-'} + + ) : ( + - {secondaryBalanceDisplay.text} - - - ) : ( - <> - {/* Token price and percentage change */} - - - {tokenPriceInFiat && !hideFiatForScamWarning - ? formatPriceWithSubscriptNotation( - tokenPriceInFiat, - currentCurrency, - ) - : '-'} - {' \u2022 '} - - - {hideFiatForScamWarning ? ( - - {'-'} - - ) : ( - - - {secondaryBalanceDisplay.text || '-'} - - - )} - - - {/* Token balance */} - - {tokenBalance} + {secondaryBalanceDisplay.text || '-'} - - - )} + + )} + + + {/* Token balance */} + + + {tokenBalance} + + diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx index 9446ad1f1d1..ed10fae377e 100644 --- a/app/components/UI/Tokens/index.tsx +++ b/app/components/UI/Tokens/index.tsx @@ -56,6 +56,7 @@ interface TokensProps { * (e.g. network filter set to a chain without mUSD). */ hasMusdBalanceOnAnyChain?: boolean; + listHeaderComponent?: React.ReactElement; listFooterComponent?: React.ReactElement; /** * Optional external RefreshControl. When provided, overrides the internal @@ -69,6 +70,11 @@ interface TokensProps { * already handles its own loading state (e.g. CashTokensFullView). */ hideLoadingSkeleton?: boolean; + /** + * When true, mUSD rows render only the native balance on the secondary row + * (no token price / 24h change). Used by the Money Hub. + */ + hideSecondaryPriceRow?: boolean; } const Tokens = forwardRef( @@ -77,9 +83,11 @@ const Tokens = forwardRef( isFullView = false, showOnlyMusd = false, hasMusdBalanceOnAnyChain: hasMusdBalanceOnAnyChainProp, + listHeaderComponent, listFooterComponent, refreshControl, hideLoadingSkeleton = false, + hideSecondaryPriceRow = false, }, ref, ) => { @@ -269,8 +277,10 @@ const Tokens = forwardRef( setShowScamWarningModal={handleScamWarningModal} maxItems={maxItems} isFullView={isFullView} + listHeaderComponent={listHeaderComponent} listFooterComponent={listFooterComponent} refreshControl={refreshControl} + hideSecondaryPriceRow={hideSecondaryPriceRow} /> ); @@ -278,9 +288,9 @@ const Tokens = forwardRef( const cashEmptyDescription = showOnlyMusd && hasMusdBalanceOnAnyChainProp - ? strings('homepage.sections.cash_empty_description_network_filter') + ? strings('homepage.sections.money_empty_description_network_filter') : showOnlyMusd - ? strings('homepage.sections.cash_empty_description') + ? strings('homepage.sections.money_empty_description') : undefined; const emptyState = ( @@ -293,13 +303,14 @@ const Tokens = forwardRef( ); - if (listFooterComponent || refreshControl) { + if (listHeaderComponent || listFooterComponent || refreshControl) { return ( + {listHeaderComponent} {emptyState} {listFooterComponent} @@ -322,8 +333,10 @@ const Tokens = forwardRef( handleScamWarningModal, maxItems, isGeoEligible, + listHeaderComponent, listFooterComponent, refreshControl, + hideSecondaryPriceRow, ]); return ( diff --git a/app/components/UI/TransactionElement/TransactionDetails/index.js b/app/components/UI/TransactionElement/TransactionDetails/index.js index 9460de845b1..fdd740f4bac 100644 --- a/app/components/UI/TransactionElement/TransactionDetails/index.js +++ b/app/components/UI/TransactionElement/TransactionDetails/index.js @@ -58,6 +58,7 @@ import { AvatarAccountType } from '../../../../component-library/components/Avat import { WalletViewSelectorsIDs } from '../../../Views/Wallet/WalletView.testIds'; import { TransactionType } from '@metamask/transaction-controller'; import TagBase from '../../../../component-library/base-components/TagBase'; +import { isHardwareAccount } from '../../../../util/address'; const createStyles = (colors) => StyleSheet.create({ @@ -333,6 +334,10 @@ class TransactionDetails extends PureComponent { ); const { updatedTransactionDetails } = this.state; const styles = this.getStyles(); + const fromAddress = txParams?.from; + const isHardwareWallet = Boolean( + fromAddress && isHardwareAccount(fromAddress), + ); const isBridgeTransaction = transactionObject?.type === TransactionType.bridge; const renderTxActions = @@ -474,7 +479,9 @@ class TransactionDetails extends PureComponent { gasEstimationReady transactionType={updatedTransactionDetails.transactionType} chainId={chainId} - isGasFeeSponsored={transactionObject.isGasFeeSponsored} + isGasFeeSponsored={ + transactionObject.isGasFeeSponsored && !isHardwareWallet + } /> {updatedTransactionDetails.hash && diff --git a/app/components/UI/TransactionElement/TransactionDetails/index.test.tsx b/app/components/UI/TransactionElement/TransactionDetails/index.test.tsx index 7bac09b5ca8..4e677958653 100644 --- a/app/components/UI/TransactionElement/TransactionDetails/index.test.tsx +++ b/app/components/UI/TransactionElement/TransactionDetails/index.test.tsx @@ -8,6 +8,7 @@ import renderWithProvider from '../../../../util/test/renderWithProvider'; import { createStackNavigator } from '@react-navigation/stack'; import { mockNetworkState } from '../../../../util/test/network'; import type { NetworkState } from '@metamask/network-controller'; +import { isHardwareAccount } from '../../../../util/address'; const Stack = createStackNavigator(); const mockEthQuery = { @@ -71,6 +72,11 @@ jest.mock('../../../../util/networks/global-network', () => ({ getGlobalEthQuery: jest.fn(() => mockEthQuery), })); +jest.mock('../../../../util/address', () => ({ + ...jest.requireActual('../../../../util/address'), + isHardwareAccount: jest.fn(), +})); + jest.mock('@metamask/controller-utils', () => ({ ...jest.requireActual('@metamask/controller-utils'), query: jest.fn(), @@ -511,4 +517,19 @@ describe('TransactionDetails', () => { expect(screen.queryByText('Paid by MetaMask')).not.toBeOnTheScreen(); }); + + it('does not show "Paid by MetaMask" for hardware wallet even when isGasFeeSponsored is true', () => { + jest.mocked(isHardwareAccount).mockReturnValue(true); + + renderComponent({ + state: initialState, + transactionObj: { + isGasFeeSponsored: true, + txParams: { from: '0xHardwareAddress' }, + }, + }); + + expect(screen.queryByTestId('paid-by-metamask')).not.toBeOnTheScreen(); + expect(screen.queryByText('Paid by MetaMask')).not.toBeOnTheScreen(); + }); }); diff --git a/app/components/UI/TransactionElement/index.js b/app/components/UI/TransactionElement/index.js index e0a35415e5b..55bbda471ab 100644 --- a/app/components/UI/TransactionElement/index.js +++ b/app/components/UI/TransactionElement/index.js @@ -26,7 +26,10 @@ import { } from '@metamask/transaction-controller'; import { ThemeContext, mockTheme } from '../../../util/theme'; import { selectTickerByChainId } from '../../../selectors/networkController'; -import { selectSelectedInternalAccount } from '../../../selectors/accountsController'; +import { + selectSelectedInternalAccount, + selectSelectedInternalAccountAddress, +} from '../../../selectors/accountsController'; import { selectSelectedAccountGroupInternalAccounts } from '../../../selectors/multichainAccounts/accountTreeController'; import { selectPrimaryCurrency } from '../../../selectors/settings'; import { @@ -46,6 +49,7 @@ import BadgeWrapper from '../../../component-library/components/Badges/BadgeWrap import Badge, { BadgeVariant, } from '../../../component-library/components/Badges/Badge'; +import { AvatarSize } from '../../../component-library/components/Avatars/Avatar'; import { NetworkBadgeSource } from '../AssetOverview/Balance/Balance'; import { getFontFamily, @@ -56,7 +60,7 @@ import { selectCurrencyRates, } from '../../../selectors/currencyRateController'; import { selectContractExchangeRatesByChainId } from '../../../selectors/tokenRatesController'; -import { selectTokensByChainIdAndAddress } from '../../../selectors/tokensController'; +import { selectTokensByChainIdAndWalletAddress } from '../../../selectors/tokensController'; import Routes from '../../../constants/navigation/Routes'; import { hasGasFeeTokenSelected, @@ -98,6 +102,10 @@ const createStyles = (colors, typography) => width: 32, height: 32, }, + iconBadgePosition: { + bottom: -4, + right: -4, + }, importText: { color: colors.text.alternative, fontSize: 14, @@ -224,6 +232,10 @@ class TransactionElement extends PureComponent { * Chain Id */ txChainId: PropTypes.string, + /** + * Selected wallet address for decoding and token map (optional override from parent) + */ + selectedAddress: PropTypes.string, /** * Ticker */ @@ -264,13 +276,13 @@ class TransactionElement extends PureComponent { mounted = false; componentDidMount = async () => { + this.mounted = true; const [transactionElement, transactionDetails] = await decodeTransaction({ ...this.props, swapsTransactions: this.props.swapsTransactions, assetSymbol: this.props.assetSymbol, ticker: this.props.ticker, }); - this.mounted = true; this.mounted && this.setState({ transactionElement, transactionDetails }); }; @@ -278,7 +290,8 @@ class TransactionElement extends PureComponent { componentDidUpdate(prevProps) { if ( prevProps.txChainId !== this.props.txChainId || - prevProps.swapsTransactions !== this.props.swapsTransactions + prevProps.swapsTransactions !== this.props.swapsTransactions || + prevProps.selectedAddress !== this.props.selectedAddress ) { this.componentDidMount(); } @@ -461,10 +474,13 @@ class TransactionElement extends PureComponent { return ( } > @@ -707,6 +723,35 @@ class TransactionElement extends PureComponent { ); }; + renderPendingElement = () => { + const { i, tx } = this.props; + const { colors, typography } = this.context || mockTheme; + const styles = createStyles(colors, typography); + + return ( + + + {this.renderTxTime()} + + + + + + + + ... + + + + + + ); + }; + render() { const { tx, selectedInternalAccount } = this.props; const { transactionElement, transactionDetails } = this.state; @@ -714,7 +759,7 @@ class TransactionElement extends PureComponent { const { colors, typography } = this.context || mockTheme; const styles = createStyles(colors, typography); - if (!transactionElement || !transactionDetails) return null; + const isReady = Boolean(transactionElement && transactionDetails); const accountImportTime = selectedInternalAccount?.metadata.importTime; const { time } = tx; @@ -726,11 +771,13 @@ class TransactionElement extends PureComponent { style={ this.props.showBottomBorder ? styles.rowWithBorder : styles.row } - onPress={this.onPressItem} + onPress={isReady ? this.onPressItem : undefined} underlayColor={colors.background.alternative} activeOpacity={1} > - {this.renderTxElement(transactionElement)} + {isReady + ? this.renderTxElement(transactionElement) + : this.renderPendingElement()} {accountImportTime <= time && this.renderImportTime()} @@ -738,21 +785,29 @@ class TransactionElement extends PureComponent { } } -const mapStateToProps = (state, ownProps) => ({ - selectedInternalAccount: selectSelectedInternalAccount(state), - selectSelectedAccountGroupInternalAccounts: - selectSelectedAccountGroupInternalAccounts(state), - primaryCurrency: selectPrimaryCurrency(state), - swapsTransactions: selectSwapsTransactions(state), - ticker: selectTickerByChainId(state, ownProps.txChainId), - conversionRate: selectConversionRateByChainId(state, ownProps.txChainId), - currencyRates: selectCurrencyRates(state), - contractExchangeRates: selectContractExchangeRatesByChainId( - state, - ownProps.txChainId, - ), - tokens: selectTokensByChainIdAndAddress(state, ownProps.txChainId), -}); +const mapStateToProps = (state, ownProps) => { + const walletAddressForTokens = + ownProps.selectedAddress ?? selectSelectedInternalAccountAddress(state); + return { + selectedInternalAccount: selectSelectedInternalAccount(state), + selectSelectedAccountGroupInternalAccounts: + selectSelectedAccountGroupInternalAccounts(state), + primaryCurrency: selectPrimaryCurrency(state), + swapsTransactions: selectSwapsTransactions(state), + ticker: selectTickerByChainId(state, ownProps.txChainId), + conversionRate: selectConversionRateByChainId(state, ownProps.txChainId), + currencyRates: selectCurrencyRates(state), + contractExchangeRates: selectContractExchangeRatesByChainId( + state, + ownProps.txChainId, + ), + tokens: selectTokensByChainIdAndWalletAddress( + state, + ownProps.txChainId, + walletAddressForTokens, + ), + }; +}; TransactionElement.contextType = ThemeContext; diff --git a/app/components/UI/Trending/components/FilterBar/FilterBar.tsx b/app/components/UI/Trending/components/FilterBar/FilterBar.tsx index 470c1ae25a1..9f2a24d9640 100644 --- a/app/components/UI/Trending/components/FilterBar/FilterBar.tsx +++ b/app/components/UI/Trending/components/FilterBar/FilterBar.tsx @@ -17,6 +17,8 @@ export interface FilterButtonProps { ellipsizeMode?: 'tail' | 'head' | 'middle' | 'clip'; /** Optional Tailwind class overrides for layout in custom contexts */ twClassName?: string; + /** Optional icon name to show before the label (e.g., for sort direction indicators) */ + iconName?: IconName; } export const FilterButton: React.FC = ({ @@ -27,6 +29,7 @@ export const FilterButton: React.FC = ({ numberOfLines, ellipsizeMode, twClassName, + iconName, }) => { const tw = useTailwind(); @@ -43,10 +46,13 @@ export const FilterButton: React.FC = ({ disabled={disabled} > + {iconName && ( + + )} {label} @@ -64,6 +70,8 @@ export interface FilterBarProps { priceChangeButtonText: string; onPriceChangePress: () => void; isPriceChangeDisabled?: boolean; + /** Optional icon name for the price change button */ + priceChangeIconName?: IconName; networkName: string; onNetworkPress: () => void; @@ -81,6 +89,7 @@ const FilterBar: React.FC = ({ priceChangeButtonText, onPriceChangePress, isPriceChangeDisabled = false, + priceChangeIconName, networkName, onNetworkPress, extraFilters, @@ -95,6 +104,7 @@ const FilterBar: React.FC = ({ label={priceChangeButtonText} onPress={onPriceChangePress} disabled={isPriceChangeDisabled} + iconName={priceChangeIconName} /> = ({ priceChangeButtonText={filters.priceChangeButtonText} onPriceChangePress={filters.handlePriceChangePress} isPriceChangeDisabled={searchResults.length === 0} + priceChangeIconName={filters.priceChangeSortDirectionIcon} networkName={filters.selectedNetworkName} onNetworkPress={filters.handleAllNetworksPress} extraFilters={extraFilters} diff --git a/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.tsx b/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.tsx index 3e139988ffc..e8571a73196 100644 --- a/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.tsx +++ b/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.tsx @@ -3,10 +3,10 @@ import { useNavigation } from '@react-navigation/native'; import { HeaderSearch, HeaderSearchVariant, + HeaderStandard, IconName as DSIconName, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import type { TrendingListHeaderProps } from './TrendingListHeader.types'; /** @@ -98,7 +98,7 @@ const TrendingListHeader: React.FC = ({ } return ( - { - const { namespace, reference } = parseCaipChainId(caipChainId); - return namespace === 'eip155' - ? (`0x${Number(reference).toString(16)}` as Hex) - : (caipChainId as Hex); -}; +import { + caipChainIdToHex, + getCaipChainIdFromAssetId, + getNetworkBadgeSource, + formatMarketStats, + getPriceChangeFieldKey, +} from './utils'; import { NATIVE_SWAPS_TOKEN_ADDRESS } from '../../../../../constants/bridge'; import type { TransactionActiveAbTestEntry } from '../../../../../util/transactions/transaction-active-ab-test-attribution-registry'; -import { - getDefaultNetworkByChainId, - getTestNetImageByChainId, - isTestNet, -} from '../../../../../util/networks'; -import { - CustomNetworkImgMapping, - PopularList, - UnpopularNetworkList, - getNonEvmNetworkImageSourceByChainId, -} from '../../../../../util/networks/customNetworks'; import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; -import { formatMarketStats, getPriceChangeFieldKey } from './utils'; import { formatPriceWithSubscriptNotation } from '../../../Predict/utils/format'; -import { TimeOption, PriceChangeOption } from '../TrendingTokensBottomSheet'; -import { selectNetworkConfigurationsByCaipChainId } from '../../../../../selectors/networkController'; +import { TimeOption } from '../TrendingTokensBottomSheet'; import { getTrendingTokenImageUrl } from '../../utils/getTrendingTokenImageUrl'; -import { useAddPopularNetwork } from '../../../../hooks/useAddPopularNetwork'; -import TrendingFeedSessionManager from '../../services/TrendingFeedSessionManager'; import type { TrendingFilterContext } from '../TrendingTokensList/TrendingTokensList'; import { TokenDetailsSource } from '../../../TokenDetails/constants/constants'; - -/** - * Extracts CAIP chain ID from asset ID - */ -const getCaipChainIdFromAssetId = (assetId: string): CaipChainId => - assetId.split('/')[0] as CaipChainId; - -/** - * Gets network badge image source for a given CAIP chain ID - */ -const getNetworkBadgeSource = ( - caipChainId: CaipChainId, -): ImageSourcePropType | undefined => { - const hexChainId = caipChainIdToHex(caipChainId); - - if (isTestNet(hexChainId)) { - return getTestNetImageByChainId(hexChainId); - } - - const defaultNetwork = getDefaultNetworkByChainId(hexChainId) as - | { imageSource: ImageSourcePropType } - | undefined; - - if (defaultNetwork) { - return defaultNetwork.imageSource; - } - - const unpopularNetwork = UnpopularNetworkList.find( - (networkConfig) => networkConfig.chainId === hexChainId, - ); - - const customNetworkImg = CustomNetworkImgMapping[hexChainId]; - - const popularNetwork = PopularList.find( - (networkConfig) => networkConfig.chainId === hexChainId, - ); - - const network = unpopularNetwork || popularNetwork; - if (network) { - return network.rpcPrefs.imageSource; - } - if (isCaipChainId(caipChainId)) { - return getNonEvmNetworkImageSourceByChainId(caipChainId); - } - if (customNetworkImg) { - return customNetworkImg as ImageSourcePropType; - } - - return undefined; -}; +import { useTrendingTokenPress } from '../../hooks/useTrendingTokenPress/useTrendingTokenPress'; +import SecurityTrustInlineBadge from '../../../SecurityTrust/components/SecurityTrustInlineBadge/SecurityTrustInlineBadge'; /** * Gets the text color for price percentage change @@ -146,12 +72,22 @@ interface TrendingTokenRowItemProps { * asset details screen (including network-add logic and analytics tracking). */ onPress?: (token: TrendingAsset) => void; + /** + * Called synchronously before the card's press handler fires. + * Useful for injecting analytics without overriding navigation. + */ + onCardPress?: () => void; + /** + * When the same token row appears in multiple Explore sections, set this to keep + * `testID` (and E2E selectors) unique per instance. + */ + testIdInstanceKey?: string; } /** * Converts a TrendingAsset to Asset navigation params */ -const getAssetNavigationParams = ( +export const getAssetNavigationParams = ( token: TrendingAsset, source: TokenDetailsSource, transactionActiveAbTests?: TransactionActiveAbTestEntry[], @@ -195,31 +131,16 @@ const TrendingTokenRowItem = ({ tokenDetailsSource = TokenDetailsSource.Trending, transactionActiveAbTests, onPress, + onCardPress, + testIdInstanceKey, }: TrendingTokenRowItemProps) => { const { styles } = useStyles(styleSheet, {}); - const navigation = useNavigation(); - const networkConfigurations = useSelector( - selectNetworkConfigurationsByCaipChainId, - ); - const { addPopularNetwork } = useAddPopularNetwork(); - const sessionManager = TrendingFeedSessionManager.getInstance(); - // Memoize derived values const caipChainId = useMemo( () => getCaipChainIdFromAssetId(token.assetId), [token.assetId], ); - const assetParams = useMemo( - () => - getAssetNavigationParams( - token, - tokenDetailsSource, - transactionActiveAbTests, - ), - [token, tokenDetailsSource, transactionActiveAbTests], - ); - const networkBadgeImageSource = useMemo( () => getNetworkBadgeSource(caipChainId), [caipChainId], @@ -230,89 +151,43 @@ const TrendingTokenRowItem = ({ [token.securityData?.resultType], ); - // Parse price change percentage from API (comes as string like "-3.44" or "+0.456") - // Use the correct field based on selected time option const priceChangeFieldKey = getPriceChangeFieldKey(selectedTimeOption); const pricePercentChangeString = token.priceChangePct?.[priceChangeFieldKey]; const pricePercentChange = pricePercentChangeString ? parseFloat(pricePercentChangeString) : undefined; - // Determine the color for percentage change - // Handle 0 as neutral (not positive or negative) const hasPercentageChange = pricePercentChange !== undefined && !isNaN(pricePercentChange); const isPositiveChange = hasPercentageChange && pricePercentChange > 0; + const { onPress: defaultOnPress } = useTrendingTokenPress({ + token, + index: position, + filterContext, + tokenDetailsSource, + transactionActiveAbTests, + selectedTimeOption, + }); + const handlePress = useCallback(async () => { + onCardPress?.(); if (onPress) { onPress(token); return; } + await defaultOnPress(); + }, [onPress, onCardPress, token, defaultOnPress]); - if (!assetParams) return; - - // Track token click event BEFORE navigation to ensure capture - if (position !== undefined && filterContext) { - sessionManager.trackTokenClick({ - token_symbol: token.symbol, - token_address: assetParams.address, - token_name: token.name, - chain_id: assetParams.chainId, - position, - price_usd: parseFloat(token.price) || 0, - price_change_pct: pricePercentChange ?? 0, - time_filter: filterContext.timeFilter, - sort_option: filterContext.sortOption || PriceChangeOption.PriceChange, - network_filter: filterContext.networkFilter, - is_search_result: filterContext.isSearchResult, - }); - } - - const isNetworkAdded = Boolean(networkConfigurations[caipChainId]); - - if (!isNetworkAdded) { - const popularNetwork = PopularList.find( - (network) => network.chainId === assetParams.chainId, - ); - - if (popularNetwork) { - // Add the network directly without showing confirmation modal - // addPopularNetwork handles both enabling the network in the filter - // and switching to it (shouldSwitchNetwork defaults to true) - try { - await addPopularNetwork(popularNetwork); - } catch (error) { - // If network addition fails, don't navigate - console.error('Failed to add network:', error); - return; - } - } - } - - // Use push so we always open a new Asset screen for the tapped token. - // This prevents issues such as dismissing screens like Bridge instead - // of navigating forward to the new token. - navigation.dispatch(StackActions.push('Asset', assetParams)); - }, [ - onPress, - assetParams, - caipChainId, - navigation, - networkConfigurations, - addPopularNetwork, - position, - filterContext, - pricePercentChange, - token, - sessionManager, - ]); + const rowTestId = testIdInstanceKey + ? `trending-token-row-item-${testIdInstanceKey}-${token.assetId}` + : `trending-token-row-item-${token.assetId}`; return ( { describe('Billions formatting', () => { @@ -364,3 +373,69 @@ describe('formatMarketStats', () => { }); }); }); + +describe('getCaipChainIdFromAssetId', () => { + it('extracts the chain prefix from a CAIP-19 asset id', () => { + expect(getCaipChainIdFromAssetId('eip155:1/erc20:0xabc')).toBe('eip155:1'); + }); + + it('returns the input untouched when there is no slash separator', () => { + expect(getCaipChainIdFromAssetId('eip155:1')).toBe('eip155:1'); + }); +}); + +describe('caipChainIdToHex', () => { + it('converts an eip155 reference to a hex chain id', () => { + expect(caipChainIdToHex('eip155:1' as CaipChainId)).toBe('0x1'); + expect(caipChainIdToHex('eip155:137' as CaipChainId)).toBe('0x89'); + }); + + it('passes non-eip155 CAIP ids through unchanged', () => { + expect( + caipChainIdToHex( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, + ), + ).toBe('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'); + }); +}); + +describe('getNetworkBadgeSource', () => { + it('returns an image source for a known popular EVM network (mainnet)', () => { + const src = getNetworkBadgeSource('eip155:1' as CaipChainId); + expect(src).toBeDefined(); + }); + + it('returns undefined for an unknown EVM chain', () => { + const src = getNetworkBadgeSource('eip155:99999999' as CaipChainId); + expect(src).toBeUndefined(); + }); + + it('returns CustomNetworkImgMapping image for EVM chain not in default/popular lists', () => { + // Flare mainnet (14) — in CustomNetworkImgMapping as 0xe, not covered by earlier lookups. + const src = getNetworkBadgeSource('eip155:14' as CaipChainId); + expect(src).toBeDefined(); + }); + + it('does not throw for a non-EVM CAIP chain id', () => { + // Whether the badge resolves to a real image depends on customNetworks data; + // we just assert the lookup pipeline handles the non-eip155 branch cleanly. + expect(() => + getNetworkBadgeSource('solana:mainnet' as CaipChainId), + ).not.toThrow(); + }); +}); + +describe('getPriceChangeFieldKey', () => { + it('maps each TimeOption to its priceChangePct field key', () => { + expect(getPriceChangeFieldKey(TimeOption.TwentyFourHours)).toBe('h24'); + expect(getPriceChangeFieldKey(TimeOption.SixHours)).toBe('h6'); + expect(getPriceChangeFieldKey(TimeOption.OneHour)).toBe('h1'); + expect(getPriceChangeFieldKey(TimeOption.FiveMinutes)).toBe('m5'); + }); + + it('defaults to h24 for an unknown TimeOption', () => { + expect(getPriceChangeFieldKey('unknown' as unknown as TimeOption)).toBe( + 'h24', + ); + }); +}); diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/utils.ts b/app/components/UI/Trending/components/TrendingTokenRowItem/utils.ts index c2305f19580..79cb5870f24 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/utils.ts +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/utils.ts @@ -1,5 +1,55 @@ +import { ImageSourcePropType } from 'react-native'; +import { + CaipChainId, + isCaipChainId, + KnownCaipNamespace, + parseCaipChainId, +} from '@metamask/utils'; +import { + getDefaultNetworkByChainId, + getTestNetImageByChainId, + isTestNet, +} from '../../../../../util/networks'; +import { + CustomNetworkImgMapping, + PopularList, + UnpopularNetworkList, + getNonEvmNetworkImageSourceByChainId, +} from '../../../../../util/networks/customNetworks'; +import { caipChainIdToHex } from '../../../Rewards/utils/formatUtils'; import { TimeOption } from '../TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet'; +export const getCaipChainIdFromAssetId = (assetId: string): CaipChainId => + assetId.split('/')[0] as CaipChainId; + +export { caipChainIdToHex }; + +export const getNetworkBadgeSource = ( + caipChainId: CaipChainId, +): ImageSourcePropType | undefined => { + const hexChainId = caipChainIdToHex(caipChainId); + if (isTestNet(hexChainId)) return getTestNetImageByChainId(hexChainId); + const defaultNetwork = getDefaultNetworkByChainId(hexChainId) as + | { imageSource: ImageSourcePropType } + | undefined; + if (defaultNetwork) return defaultNetwork.imageSource; + const unpopularNetwork = UnpopularNetworkList.find( + (n) => n.chainId === hexChainId, + ); + const popularNetwork = PopularList.find((n) => n.chainId === hexChainId); + const network = unpopularNetwork || popularNetwork; + if (network) return network.rpcPrefs.imageSource; + if ( + isCaipChainId(caipChainId) && + parseCaipChainId(caipChainId).namespace !== KnownCaipNamespace.Eip155 + ) { + return getNonEvmNetworkImageSourceByChainId(caipChainId); + } + const customNetworkImg = CustomNetworkImgMapping[hexChainId]; + if (customNetworkImg) return customNetworkImg as ImageSourcePropType; + return undefined; +}; + /** * Formats a number as compact USD currency string * @param value - The number to format diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx index 48e18a54b9d..61acf04b382 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx @@ -1,9 +1,9 @@ import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { HeaderStandard } from '@metamask/design-system-react-native'; import { StyleSheet, ScrollView } from 'react-native'; import BottomSheet, { BottomSheetRef, } from '../../../../../component-library/components/BottomSheets/BottomSheet'; -import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import Icon, { IconName, IconSize, @@ -115,7 +115,7 @@ const TrendingTokenNetworkBottomSheet: React.FC< onClose={handleSheetClose} testID="trending-token-network-bottom-sheet" > - - - ({ @@ -284,6 +285,50 @@ describe('useTokenListFilters', () => { }); }); + describe('priceChangeSortDirectionIcon', () => { + it('returns Arrow2Down for descending sort direction', () => { + const { result } = renderFilters(); + + expect(result.current.priceChangeSortDirectionIcon).toBe( + IconName.Arrow2Down, + ); + }); + + it('returns Arrow2Up for ascending sort direction', () => { + const { result } = renderFilters(); + + act(() => + result.current.handlePriceChangeSelect( + PriceChangeOption.Volume, + SortDirection.Ascending, + ), + ); + + expect(result.current.priceChangeSortDirectionIcon).toBe( + IconName.Arrow2Up, + ); + }); + + it('updates when sort direction changes', () => { + const { result } = renderFilters(); + + expect(result.current.priceChangeSortDirectionIcon).toBe( + IconName.Arrow2Down, + ); + + act(() => + result.current.handlePriceChangeSelect( + PriceChangeOption.PriceChange, + SortDirection.Ascending, + ), + ); + + expect(result.current.priceChangeSortDirectionIcon).toBe( + IconName.Arrow2Up, + ); + }); + }); + describe('filterContext', () => { it('reflects current filter state', () => { const { result } = renderFilters(); diff --git a/app/components/UI/Trending/hooks/useTokenListFilters/useTokenListFilters.ts b/app/components/UI/Trending/hooks/useTokenListFilters/useTokenListFilters.ts index 8dce1bcf7f9..f9867d1bc4d 100644 --- a/app/components/UI/Trending/hooks/useTokenListFilters/useTokenListFilters.ts +++ b/app/components/UI/Trending/hooks/useTokenListFilters/useTokenListFilters.ts @@ -11,6 +11,7 @@ import { import type { TrendingFilterContext } from '../../components/TrendingTokensList/TrendingTokensList'; import TrendingFeedSessionManager from '../../services/TrendingFeedSessionManager'; import { useNetworkName } from '../useNetworkName/useNetworkName'; +import { IconName } from '../../../../../component-library/components/Icons/Icon'; interface UseTokenListFiltersOptions { /** @@ -49,6 +50,7 @@ export interface TokenListFilters { ) => void; handlePriceChangePress: () => void; priceChangeButtonText: string; + priceChangeSortDirectionIcon: IconName; // Time selectedTimeOption: TimeOption; @@ -189,6 +191,14 @@ export const useTokenListFilters = ( } }, [selectedPriceChangeOption]); + const priceChangeSortDirectionIcon = useMemo( + () => + priceChangeSortDirection === SortDirection.Ascending + ? IconName.Arrow2Up + : IconName.Arrow2Down, + [priceChangeSortDirection], + ); + const filterContext: TrendingFilterContext = useMemo( () => ({ timeFilter: selectedTimeOption, @@ -226,6 +236,7 @@ export const useTokenListFilters = ( handlePriceChangeSelect, handlePriceChangePress, priceChangeButtonText, + priceChangeSortDirectionIcon, selectedTimeOption, setSelectedTimeOption, refreshing, diff --git a/app/components/UI/Trending/hooks/useTrendingTokenPress/useTrendingTokenPress.test.ts b/app/components/UI/Trending/hooks/useTrendingTokenPress/useTrendingTokenPress.test.ts new file mode 100644 index 00000000000..de39077c2ca --- /dev/null +++ b/app/components/UI/Trending/hooks/useTrendingTokenPress/useTrendingTokenPress.test.ts @@ -0,0 +1,176 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { StackActions, useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import { useTrendingTokenPress } from './useTrendingTokenPress'; +import { useAddPopularNetwork } from '../../../../hooks/useAddPopularNetwork'; +import TrendingFeedSessionManager from '../../services/TrendingFeedSessionManager'; +import { PopularList } from '../../../../../util/networks/customNetworks'; +import { + TimeOption, + PriceChangeOption, +} from '../../components/TrendingTokensBottomSheet'; +import type { TrendingFilterContext } from '../../components/TrendingTokensList/TrendingTokensList'; + +jest.mock('@react-navigation/native', () => ({ + StackActions: { push: jest.fn((route, params) => ({ route, params })) }, + useNavigation: jest.fn(), +})); +jest.mock('react-redux', () => ({ useSelector: jest.fn() })); +jest.mock('../../../../hooks/useAddPopularNetwork'); +// Short-circuit selector module — useSelector is mocked, so we don't need real selectors. +jest.mock('../../../../../selectors/networkController', () => ({ + selectNetworkConfigurationsByCaipChainId: jest.fn(), +})); +jest.mock('../../services/TrendingFeedSessionManager', () => ({ + __esModule: true, + default: { getInstance: jest.fn() }, +})); + +const mockNavigationDispatch = jest.fn(); +const mockAddPopularNetwork = jest.fn(); +const mockTrackTokenClick = jest.fn(); +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockUseAddPopularNetwork = useAddPopularNetwork as jest.MockedFunction< + typeof useAddPopularNetwork +>; +const mockGetInstance = + TrendingFeedSessionManager.getInstance as jest.MockedFunction< + typeof TrendingFeedSessionManager.getInstance + >; + +// EVM token assetId — caipChainIdToHex maps `eip155:43114` → `0xa86a`. +const avalancheNetwork = PopularList.find((n) => n.nickname === 'Avalanche'); +if (!avalancheNetwork) throw new Error('Avalanche missing from PopularList'); +const AVAX_CHAIN_ID = avalancheNetwork.chainId; +const ETH_TOKEN: TrendingAsset = { + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + price: '1', + priceChangePct: { h24: '2.5' }, +} as unknown as TrendingAsset; + +const AVAX_TOKEN: TrendingAsset = { + assetId: `eip155:43114/erc20:0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7`, + symbol: 'WAVAX', + name: 'Wrapped AVAX', + decimals: 18, + price: '20', +} as unknown as TrendingAsset; + +describe('useTrendingTokenPress', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseNavigation.mockReturnValue({ + dispatch: mockNavigationDispatch, + } as never); + mockUseAddPopularNetwork.mockReturnValue({ + addPopularNetwork: mockAddPopularNetwork, + } as never); + mockGetInstance.mockReturnValue({ + trackTokenClick: mockTrackTokenClick, + } as never); + }); + + it('navigates without adding a network when the chain is already configured', async () => { + // Network is already added — no call to addPopularNetwork. + mockUseSelector.mockReturnValue({ 'eip155:1': { name: 'Mainnet' } }); + + const { result } = renderHook(() => + useTrendingTokenPress({ token: ETH_TOKEN }), + ); + await act(async () => { + await result.current.onPress(); + }); + + expect(mockAddPopularNetwork).not.toHaveBeenCalled(); + expect(mockNavigationDispatch).toHaveBeenCalledTimes(1); + expect(StackActions.push).toHaveBeenCalledWith( + 'Asset', + expect.objectContaining({ symbol: 'USDC', chainId: '0x1' }), + ); + }); + + it('adds the network from PopularList before navigating when the chain is not configured', async () => { + mockUseSelector.mockReturnValue({}); // no networks configured + mockAddPopularNetwork.mockResolvedValueOnce(undefined); + + const { result } = renderHook(() => + useTrendingTokenPress({ token: AVAX_TOKEN }), + ); + await act(async () => { + await result.current.onPress(); + }); + + expect(mockAddPopularNetwork).toHaveBeenCalledWith( + expect.objectContaining({ chainId: AVAX_CHAIN_ID }), + ); + expect(mockNavigationDispatch).toHaveBeenCalledTimes(1); + }); + + it('aborts navigation when addPopularNetwork throws', async () => { + mockUseSelector.mockReturnValue({}); + mockAddPopularNetwork.mockRejectedValueOnce(new Error('boom')); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { + /* silence */ + }); + + const { result } = renderHook(() => + useTrendingTokenPress({ token: AVAX_TOKEN }), + ); + await act(async () => { + await result.current.onPress(); + }); + + expect(mockAddPopularNetwork).toHaveBeenCalled(); + expect(mockNavigationDispatch).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it('tracks an analytics event when index and filterContext are provided', async () => { + mockUseSelector.mockReturnValue({ 'eip155:1': {} }); + const filterContext: TrendingFilterContext = { + timeFilter: TimeOption.TwentyFourHours, + sortOption: PriceChangeOption.PriceChange, + networkFilter: 'all', + isSearchResult: false, + }; + + const { result } = renderHook(() => + useTrendingTokenPress({ + token: ETH_TOKEN, + index: 4, + filterContext, + }), + ); + await act(async () => { + await result.current.onPress(); + }); + + expect(mockTrackTokenClick).toHaveBeenCalledTimes(1); + expect(mockTrackTokenClick).toHaveBeenCalledWith( + expect.objectContaining({ + token_symbol: 'USDC', + position: 4, + price_change_pct: 2.5, + is_search_result: false, + }), + ); + }); + + it('skips analytics when index or filterContext is missing', async () => { + mockUseSelector.mockReturnValue({ 'eip155:1': {} }); + const { result } = renderHook(() => + useTrendingTokenPress({ token: ETH_TOKEN }), + ); + await act(async () => { + await result.current.onPress(); + }); + expect(mockTrackTokenClick).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Trending/hooks/useTrendingTokenPress/useTrendingTokenPress.ts b/app/components/UI/Trending/hooks/useTrendingTokenPress/useTrendingTokenPress.ts new file mode 100644 index 00000000000..c4640787f1f --- /dev/null +++ b/app/components/UI/Trending/hooks/useTrendingTokenPress/useTrendingTokenPress.ts @@ -0,0 +1,104 @@ +import { useCallback } from 'react'; +import { StackActions, useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { isCaipChainId, type CaipChainId } from '@metamask/utils'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import type { TransactionActiveAbTestEntry } from '../../../../../util/transactions/transaction-active-ab-test-attribution-registry'; +import { getAssetNavigationParams } from '../../components/TrendingTokenRowItem/TrendingTokenRowItem'; +import { getPriceChangeFieldKey } from '../../components/TrendingTokenRowItem/utils'; +import TrendingFeedSessionManager from '../../services/TrendingFeedSessionManager'; +import { useAddPopularNetwork } from '../../../../hooks/useAddPopularNetwork'; +import { PopularList } from '../../../../../util/networks/customNetworks'; +import { TokenDetailsSource } from '../../../TokenDetails/constants/constants'; +import { selectNetworkConfigurationsByCaipChainId } from '../../../../../selectors/networkController'; +import { + TimeOption, + PriceChangeOption, +} from '../../components/TrendingTokensBottomSheet'; +import type { TrendingFilterContext } from '../../components/TrendingTokensList/TrendingTokensList'; +import type { AppNavigationProp } from '../../../../../core/NavigationService/types'; + +export const useTrendingTokenPress = ({ + token, + index, + filterContext, + tokenDetailsSource = TokenDetailsSource.Trending, + transactionActiveAbTests, + selectedTimeOption = TimeOption.TwentyFourHours, +}: { + token: TrendingAsset; + index?: number; + filterContext?: TrendingFilterContext; + tokenDetailsSource?: TokenDetailsSource; + transactionActiveAbTests?: TransactionActiveAbTestEntry[]; + selectedTimeOption?: TimeOption; +}): { onPress: () => Promise } => { + const navigation = useNavigation(); + const networkConfigurations = useSelector( + selectNetworkConfigurationsByCaipChainId, + ); + const { addPopularNetwork } = useAddPopularNetwork(); + + const onPress = useCallback(async () => { + const assetParams = getAssetNavigationParams( + token, + tokenDetailsSource, + transactionActiveAbTests, + ); + if (!assetParams) return; + + const caipChainId = token.assetId.split('/')[0]; + + if (index !== undefined && filterContext) { + const key = getPriceChangeFieldKey(selectedTimeOption); + const rawPct = token.priceChangePct?.[key]; + const pricePercentChange = rawPct ? parseFloat(String(rawPct)) : 0; + + TrendingFeedSessionManager.getInstance().trackTokenClick({ + token_symbol: token.symbol, + token_address: assetParams.address, + token_name: token.name, + chain_id: assetParams.chainId, + position: index, + price_usd: parseFloat(token.price) || 0, + price_change_pct: pricePercentChange, + time_filter: filterContext.timeFilter, + sort_option: filterContext.sortOption ?? PriceChangeOption.PriceChange, + network_filter: filterContext.networkFilter, + is_search_result: filterContext.isSearchResult, + }); + } + + const isNetworkAdded = isCaipChainId(caipChainId) + ? Boolean(networkConfigurations[caipChainId as CaipChainId]) + : true; + + if (!isNetworkAdded) { + const popularNetwork = PopularList.find( + (n) => n.chainId === assetParams.chainId, + ); + if (popularNetwork) { + try { + await addPopularNetwork(popularNetwork); + } catch (error) { + console.error('Failed to add network:', error); + return; + } + } + } + + navigation.dispatch(StackActions.push('Asset', assetParams)); + }, [ + token, + index, + filterContext, + tokenDetailsSource, + transactionActiveAbTests, + selectedTimeOption, + navigation, + networkConfigurations, + addPopularNetwork, + ]); + + return { onPress }; +}; diff --git a/app/components/UI/UrlAutocomplete/Result.tsx b/app/components/UI/UrlAutocomplete/Result.tsx index 9c7483266d4..c78453bcf70 100644 --- a/app/components/UI/UrlAutocomplete/Result.tsx +++ b/app/components/UI/UrlAutocomplete/Result.tsx @@ -4,7 +4,7 @@ import { useTheme } from '../../../util/theme'; import { getHost } from '../../../util/browser'; import WebsiteIcon from '../WebsiteIcon'; import ButtonIcon from '../../../component-library/components/Buttons/ButtonIcon'; -import { deleteFavoriteTestId } from '../../../../wdio/screen-objects/testIDs/BrowserScreen/UrlAutocomplete.testIds'; +import { deleteFavoriteTestId } from './UrlAutocomplete.testIds'; import { Box, Icon, diff --git a/app/components/UI/UrlAutocomplete/UrlAutocomplete.constants.ts b/app/components/UI/UrlAutocomplete/UrlAutocomplete.constants.ts index 21f1681445d..915df4b8af9 100644 --- a/app/components/UI/UrlAutocomplete/UrlAutocomplete.constants.ts +++ b/app/components/UI/UrlAutocomplete/UrlAutocomplete.constants.ts @@ -1,5 +1,5 @@ import { UrlAutocompleteCategory } from './types'; -import type { SectionId } from '../../Views/TrendingView/sections.config'; +import { SearchFeedId } from '../../Views/TrendingView/search/useExploreSearch'; export const MAX_RECENTS = 5; @@ -43,7 +43,7 @@ export const ORDERED_CATEGORIES = [ * Section order for browser search (Sites first, then other omni-search sections) * This order is passed to useExploreSearch to display Sites before tokens/perps/predictions */ -export const BROWSER_SEARCH_SECTIONS_ORDER: SectionId[] = [ +export const BROWSER_SEARCH_SECTIONS_ORDER: SearchFeedId[] = [ 'sites', 'tokens', 'perps', diff --git a/wdio/screen-objects/testIDs/BrowserScreen/UrlAutocomplete.testIds.ts b/app/components/UI/UrlAutocomplete/UrlAutocomplete.testIds.ts similarity index 81% rename from wdio/screen-objects/testIDs/BrowserScreen/UrlAutocomplete.testIds.ts rename to app/components/UI/UrlAutocomplete/UrlAutocomplete.testIds.ts index fbceab555d1..3cb21f0a3d8 100644 --- a/wdio/screen-objects/testIDs/BrowserScreen/UrlAutocomplete.testIds.ts +++ b/app/components/UI/UrlAutocomplete/UrlAutocomplete.testIds.ts @@ -1 +1 @@ -export const deleteFavoriteTestId = (url: string) => `delete-favorite-${url}`; \ No newline at end of file +export const deleteFavoriteTestId = (url: string) => `delete-favorite-${url}`; diff --git a/app/components/UI/UrlAutocomplete/index.test.tsx b/app/components/UI/UrlAutocomplete/index.test.tsx index 28d7a494fb5..3b43cee2db0 100644 --- a/app/components/UI/UrlAutocomplete/index.test.tsx +++ b/app/components/UI/UrlAutocomplete/index.test.tsx @@ -91,17 +91,35 @@ const mockPredictionsData = { }; const mockUseExploreSearchReturn = { - data: { - sites: [{ name: 'Uniswap', url: 'https://uniswap.org' }], - tokens: [mockTokenData], - perps: [mockPerpsData], - predictions: [mockPredictionsData], - }, - isLoading: { sites: false, tokens: false, perps: false, predictions: false }, - sectionsOrder: ['sites', 'tokens', 'perps', 'predictions'], + sections: [ + { + feedId: 'tokens' as const, + title: '', + items: [mockTokenData], + isLoading: false, + }, + { + feedId: 'perps' as const, + title: '', + items: [mockPerpsData], + isLoading: false, + }, + { + feedId: 'predictions' as const, + title: '', + items: [mockPredictionsData], + isLoading: false, + }, + { + feedId: 'sites' as const, + title: '', + items: [{ name: 'Uniswap', url: 'https://uniswap.org' }], + isLoading: false, + }, + ], }; -jest.mock('../../Views/TrendingView/hooks/useExploreSearch', () => ({ +jest.mock('../../Views/TrendingView/search/useExploreSearch', () => ({ useExploreSearch: jest.fn(() => mockUseExploreSearchReturn), })); jest.mock('../Perps/providers/PerpsConnectionProvider', () => ({ @@ -125,7 +143,7 @@ jest.mock('../Bridge/hooks/useSwapBridgeNavigation', () => ({ import React from 'react'; import UrlAutocomplete, { UrlAutocompleteRef } from './'; -import { deleteFavoriteTestId } from '../../../../wdio/screen-objects/testIDs/BrowserScreen/UrlAutocomplete.testIds'; +import { deleteFavoriteTestId } from './UrlAutocomplete.testIds'; import { act, fireEvent, screen, waitFor } from '@testing-library/react-native'; import renderWithProvider, { DeepPartial, diff --git a/app/components/UI/UrlAutocomplete/index.tsx b/app/components/UI/UrlAutocomplete/index.tsx index a2607f29845..0ba6d28edb8 100644 --- a/app/components/UI/UrlAutocomplete/index.tsx +++ b/app/components/UI/UrlAutocomplete/index.tsx @@ -48,8 +48,10 @@ import { useSwapBridgeNavigation, } from '../Bridge/hooks/useSwapBridgeNavigation'; import { BridgeToken } from '../Bridge/types'; -import { useExploreSearch } from '../../Views/TrendingView/hooks/useExploreSearch'; -import { type SectionId } from '../../Views/TrendingView/sections.config'; +import { + useExploreSearch, + type SearchFeedId, +} from '../../Views/TrendingView/search/useExploreSearch'; import type { TrendingAsset } from '@metamask/assets-controllers'; import { type PerpsMarketData } from '@metamask/perps-controller'; import type { PredictMarket } from '../Predict/types'; @@ -81,19 +83,22 @@ const getTrendingTokenImageUrl = (assetId: string): string => `https://token.api.cx.metamask.io/assets/${assetId}/logo.png`; interface ResultsWithCategory { - category: UrlAutocompleteCategory | SectionId; + category: UrlAutocompleteCategory | SearchFeedId; data: AutocompleteSearchResult[]; isLoading?: boolean; } /** - * Helper to map SectionId to UrlAutocompleteCategory for display + * Helper to map search feed id to UrlAutocompleteCategory for display */ -const sectionIdToCategory = (sectionId: SectionId): UrlAutocompleteCategory => { +const sectionIdToCategory = ( + sectionId: SearchFeedId, +): UrlAutocompleteCategory => { switch (sectionId) { case 'sites': return UrlAutocompleteCategory.Sites; case 'tokens': + case 'stocks': return UrlAutocompleteCategory.Tokens; case 'perps': return UrlAutocompleteCategory.Perps; @@ -199,14 +204,42 @@ const SearchContent: React.FC = ({ selectBasicFunctionalityEnabled, ); - // Use omni-search hook with browser-specific section order (Sites first) + // Bridge Explore `useExploreSearch` ({ sections }) to the record shape this UI expects + const exploreResult = useExploreSearch(searchQuery); const { data: omniSearchData, isLoading: omniSearchLoading, sectionsOrder, - } = useExploreSearch(searchQuery, { - sectionsOrder: BROWSER_SEARCH_SECTIONS_ORDER, - }); + } = useMemo(() => { + const order = BROWSER_SEARCH_SECTIONS_ORDER; + const byFeedId = new Map(exploreResult.sections.map((s) => [s.feedId, s])); + + const data: Partial> = {}; + const isLoading: Partial> = {}; + + for (const sectionId of order) { + if (sectionId === 'tokens') { + const tokensSec = byFeedId.get('tokens'); + const stocksSec = byFeedId.get('stocks'); + data.tokens = [ + ...((tokensSec?.items as TrendingAsset[]) ?? []), + ...((stocksSec?.items as TrendingAsset[]) ?? []), + ]; + isLoading.tokens = + Boolean(tokensSec?.isLoading) || Boolean(stocksSec?.isLoading); + continue; + } + const sec = byFeedId.get(sectionId); + data[sectionId] = sec?.items ?? []; + isLoading[sectionId] = sec?.isLoading ?? false; + } + + return { + data: data as Record, + isLoading: isLoading as Record, + sectionsOrder: order, + }; + }, [exploreResult]); // Create Fuse instance for filtering Recents and Favorites const fuseInstance = useMemo(() => { diff --git a/app/components/UI/WebviewError/WebviewError.testIds.ts b/app/components/UI/WebviewError/WebviewError.testIds.ts new file mode 100644 index 00000000000..dfb8b777463 --- /dev/null +++ b/app/components/UI/WebviewError/WebviewError.testIds.ts @@ -0,0 +1,5 @@ +export const WebviewErrorSelectorsIDs = { + TITLE: 'error-page-title', + MESSAGE: 'error-page-message', + RETURN_BUTTON: 'error-page-return-button', +}; diff --git a/app/components/UI/WebviewError/index.js b/app/components/UI/WebviewError/index.js index a2d6d0004b3..bdae4ab0fd2 100644 --- a/app/components/UI/WebviewError/index.js +++ b/app/components/UI/WebviewError/index.js @@ -1,16 +1,11 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import { Image, StyleSheet, View, Text, Platform } from 'react-native'; +import { Image, StyleSheet, View, Text } from 'react-native'; import StyledButton from '../StyledButton'; import { strings } from '../../../../locales/i18n'; import { fontStyles } from '../../../styles/common'; import { ThemeContext, mockTheme } from '../../../util/theme'; -import generateTestId from '../../../../wdio/utils/generateTestId'; -import { - ERROR_PAGE_MESSAGE, - ERROR_PAGE_RETURN_BUTTON, - ERROR_PAGE_TITLE, -} from '../../../../wdio/screen-objects/testIDs/BrowserScreen/ExternalWebsites.testIds'; +import { WebviewErrorSelectorsIDs } from './WebviewError.testIds'; const createStyles = (colors) => StyleSheet.create({ @@ -102,13 +97,13 @@ export default class WebviewError extends PureComponent { {strings('webview_error.title')} {strings('webview_error.message')} @@ -118,7 +113,7 @@ export default class WebviewError extends PureComponent { {strings('webview_error.return_home')} diff --git a/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.tsx b/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.tsx index bb39df2bc79..4d5ff2d4068 100644 --- a/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.tsx +++ b/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.tsx @@ -14,6 +14,7 @@ import { BoxFlexDirection, BoxAlignItems, IconName as DSIconName, + HeaderStandard, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import Icon, { @@ -25,7 +26,6 @@ import Text, { TextVariant, TextColor, } from '../../../../component-library/components/Texts/Text'; -import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import { useTheme } from '../../../../util/theme'; import type { ListHeaderWithSearchProps } from './ListHeaderWithSearch.types'; import styleSheet from './ListHeaderWithSearch.styles'; @@ -140,7 +140,7 @@ const ListHeaderWithSearch: React.FC = ({ } return ( - { - const { theme } = params; - const { colors } = theme; - - return StyleSheet.create({ - accountSelectorFooterContent: { - paddingHorizontal: 16, - paddingTop: 24, - // Extra space above safe-area inset so the footer actions are not flush with the screen edge - paddingBottom: 20, - }, - backdrop: { - ...StyleSheet.absoluteFillObject, - backgroundColor: colors.overlay.default, - }, - keyboardAvoidingView: { - flex: 1, - backgroundColor: importedColors.transparent, - }, - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - addWalletModalContainer: { - flex: 1, - backgroundColor: colors.background.default, - }, - accountSelectorFooter: { - flexDirection: 'row', - }, - footerButton: { - flex: 1, - }, - footerButtonSubsequent: { - flex: 1, - marginLeft: 16, - }, - }); -}; - -export default styleSheet; diff --git a/app/components/Views/AccountSelector/AccountSelector.tsx b/app/components/Views/AccountSelector/AccountSelector.tsx index de18e3297b0..f4c18b90492 100644 --- a/app/components/Views/AccountSelector/AccountSelector.tsx +++ b/app/components/Views/AccountSelector/AccountSelector.tsx @@ -8,24 +8,29 @@ import React, { useState, } from 'react'; import { - KeyboardAvoidingView, - Platform, ActivityIndicator, + KeyboardAvoidingView, Modal, - useWindowDimensions, - View, + Platform, } from 'react-native'; import { StackActions, useNavigation } from '@react-navigation/native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - withSpring, - runOnJS, - useDerivedValue, - interpolate, -} from 'react-native-reanimated'; +import { + useSafeAreaFrame, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Button, + ButtonSize, + ButtonVariant, + FontWeight, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; // External dependencies. import MultichainAccountSelectorList from '../../../component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList'; @@ -36,13 +41,6 @@ import { store } from '../../../store'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { strings } from '../../../../locales/i18n'; import { useAccounts } from '../../hooks/useAccounts'; -import Button, { - ButtonSize, - ButtonVariants, - ButtonWidthTypes, -} from '../../../component-library/components/Buttons/Button'; -import { TextVariant } from '../../../component-library/components/Texts/Text'; -import Text from '../../../component-library/components/Texts/Text/Text'; import AddAccountActions from '../AddAccountActions'; import { AccountListBottomSheetSelectorsIDs } from './AccountListBottomSheet.testIds'; import { CommonSelectorsIDs } from '../../../util/Common.testIds'; @@ -50,12 +48,10 @@ import { selectSelectedAccountGroup } from '../../../selectors/multichainAccount import { AccountGroupObject } from '@metamask/account-tree-controller'; // Internal dependencies. -import { useStyles } from '../../../component-library/hooks'; import { AccountSelectorProps, AccountSelectorScreens, } from './AccountSelector.types'; -import styleSheet from './AccountSelector.styles'; import { useDispatch, useSelector } from 'react-redux'; import { setReloadAccounts } from '../../../actions/accounts'; import { RootState } from '../../../reducers'; @@ -67,24 +63,16 @@ import { trace, } from '../../../util/trace'; import { getTraceTags } from '../../../util/sentry/tags'; -import { ButtonProps } from '../../../component-library/components/Buttons/Button/Button.types'; import { useSyncSRPs } from '../../hooks/useSyncSRPs'; import { useAccountsOperationsLoadingStates } from '../../../util/accounts/useAccountsOperationsLoadingStates'; -import { Box } from '../../UI/Box/Box'; -import { - AlignItems, - FlexDirection, - JustifyContent, -} from '../../UI/Box/box.types'; -import { AnimationDuration } from '../../../component-library/constants/animation.constants'; import Routes from '../../../constants/navigation/Routes'; const AccountSelector = ({ route }: AccountSelectorProps) => { - const { styles } = useStyles(styleSheet, {}); + const tw = useTailwind(); const dispatch = useDispatch(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { width: screenWidth } = useWindowDimensions(); + const { y: frameY } = useSafeAreaFrame(); const { trackEvent, createEventBuilder } = useAnalytics(); const routeParams = useMemo(() => route?.params, [route?.params]); @@ -148,18 +136,14 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { } }, [navigation, shouldRedirectToAddWallet]); - // Tracing for the account list rendering: const isAccountSelector = useMemo( () => screen === AccountSelectorScreens.AccountSelector, [screen], ); - const translateX = useSharedValue(screenWidth); - - // Backdrop opacity animation - fades in as screen slides in from right - const backdropOpacity = useDerivedValue(() => - interpolate(translateX.value, [screenWidth, 0], [0, 0.5]), - ); + const handleClose = useCallback(() => { + navigation.goBack(); + }, [navigation]); useEffect(() => { if (reloadAccounts) { @@ -167,45 +151,38 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { } }, [dispatch, reloadAccounts]); + // Tracing for the account list: start at layout flush, end after paint (useEffect). useLayoutEffect(() => { - if (!isAccountSelector) return; - - const onAnimationComplete = () => { + if (!isAccountSelector) { + return undefined; + } + trace({ + name: TraceName.ShowAccountList, + op: TraceOperation.AccountUi, + tags: getTraceTags(store.getState()), + }); + return () => { endTrace({ name: TraceName.ShowAccountList, }); }; - - translateX.value = withSpring( - 0, - { - damping: 20, - stiffness: 500, - mass: 0.3, - }, - () => runOnJS(onAnimationComplete)(), - ); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAccountSelector]); - const closeModal = useCallback(() => { - const onCloseComplete = () => { - navigation.goBack(); - }; - - translateX.value = withTiming( - screenWidth, - { duration: AnimationDuration.Fast }, - () => runOnJS(onCloseComplete)(), - ); - }, [translateX, navigation, screenWidth]); + useEffect(() => { + if (!isAccountSelector) { + return; + } + endTrace({ + name: TraceName.ShowAccountList, + }); + }, [isAccountSelector]); const _onSelectMultichainAccount = useCallback( (accountGroup: AccountGroupObject) => { Engine.context.AccountTreeController.setSelectedAccountGroup( accountGroup.id, ); - closeModal(); + handleClose(); trackEvent( createEventBuilder(MetaMetricsEvents.SWITCHED_ACCOUNT) @@ -216,7 +193,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { .build(), ); }, - [accounts?.length, trackEvent, createEventBuilder, closeModal], + [accounts?.length, trackEvent, createEventBuilder, handleClose], ); const handleAddAccount = useCallback(() => { @@ -227,43 +204,6 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { setScreen(AccountSelectorScreens.AccountSelector); }, []); - // Tracing for the account list rendering: - useEffect(() => { - if (isAccountSelector) { - trace({ - name: TraceName.ShowAccountList, - op: TraceOperation.AccountUi, - tags: getTraceTags(store.getState()), - }); - // Trace ends in animation callback - } - }, [isAccountSelector]); - - const addAccountButtonProps: ButtonProps[] = useMemo( - () => [ - { - variant: ButtonVariants.Secondary, - isDisabled: isAccountSyncingInProgress, - label: ( - - {isAccountSyncingInProgress && } - {buttonLabel} - - ), - size: ButtonSize.Lg, - width: ButtonWidthTypes.Full, - onPress: handleAddAccount, - testID: AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ADD_BUTTON_ID, - }, - ], - [handleAddAccount, buttonLabel, isAccountSyncingInProgress], - ); - const renderAccountSelector = useCallback( () => ( @@ -277,24 +217,38 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { /> ) : null} {!disableAddAccountButton && ( - - {addAccountButtonProps.map((buttonProp, index) => ( - + )} ), @@ -302,11 +256,9 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { selectedAccountGroup, _onSelectMultichainAccount, disableAddAccountButton, - addAccountButtonProps, - styles.accountSelectorFooterContent, - styles.accountSelectorFooter, - styles.footerButton, - styles.footerButtonSubsequent, + handleAddAccount, + buttonLabel, + isAccountSyncingInProgress, ], ); @@ -320,14 +272,6 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { [handleBackToSelector], ); - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: translateX.value }], - })); - - const backdropStyle = useAnimatedStyle(() => ({ - opacity: backdropOpacity.value, - })); - const showAddWalletModal = screen === AccountSelectorScreens.AddAccountActions || screen === AccountSelectorScreens.MultichainAddWalletActions; @@ -338,31 +282,28 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { return ( <> - - {renderAccountSelector()} - + { onRequestClose={handleBackToSelector} > {showAddWalletModal ? ( - { {screen === AccountSelectorScreens.AddAccountActions ? renderAddAccountActions() : renderMultichainAddWalletActions()} - + ) : null} diff --git a/app/components/Views/AccountsMenu/AccountsMenu.test.tsx b/app/components/Views/AccountsMenu/AccountsMenu.test.tsx index e5f9c3e108f..5e2f63e78af 100644 --- a/app/components/Views/AccountsMenu/AccountsMenu.test.tsx +++ b/app/components/Views/AccountsMenu/AccountsMenu.test.tsx @@ -96,11 +96,6 @@ jest.mock('../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => key), })); -jest.mock('../../../util/networks', () => ({ - ...jest.requireActual('../../../util/networks'), - isPermissionsSettingsV1Enabled: true, -})); - jest.mock('../../UI/Ramp/hooks/useRampsUnifiedV1Enabled', () => ({ __esModule: true, default: jest.fn(() => false), diff --git a/app/components/Views/AccountsMenu/AccountsMenu.tsx b/app/components/Views/AccountsMenu/AccountsMenu.tsx index 260a13428ef..cf734b373e7 100644 --- a/app/components/Views/AccountsMenu/AccountsMenu.tsx +++ b/app/components/Views/AccountsMenu/AccountsMenu.tsx @@ -25,7 +25,6 @@ import Routes from '../../../constants/navigation/Routes'; import { strings } from '../../../../locales/i18n'; import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; import { AccountsMenuSelectorsIDs } from './AccountsMenu.testIds'; -import { isPermissionsSettingsV1Enabled } from '../../../util/networks'; import useRampsUnifiedV1Enabled from '../../UI/Ramp/hooks/useRampsUnifiedV1Enabled'; import useRampsUnifiedV2Enabled from '../../UI/Ramp/hooks/useRampsUnifiedV2Enabled'; import AppConstants from '../../../core/AppConstants'; @@ -441,17 +440,15 @@ const AccountsMenu = () => { /> {/* Permissions Row */} - {isPermissionsSettingsV1Enabled && ( - - } - label={strings('accounts_menu.permissions')} - endAccessory={arrowRightIcon} - onPress={onPressPermissions} - testID={AccountsMenuSelectorsIDs.PERMISSIONS} - /> - )} + + } + label={strings('accounts_menu.permissions')} + endAccessory={arrowRightIcon} + onPress={onPressPermissions} + testID={AccountsMenuSelectorsIDs.PERMISSIONS} + /> {/* Networks Row */} { networkType: NetworkType.Popular, }); + const { areAllNetworksSelected: areAllEvmPopularNetworksEnabled } = + useNetworksByCustomNamespace({ + networkType: NetworkType.Popular, + namespace: KnownCaipNamespace.Eip155, + }); + const currentNetworkName = getNetworkInfo(0)?.networkName; + const isMoneyHomeScreenEnabled = useSelector( + selectMoneyHomeScreenEnabledFlag, + ); + const params = useParams(); const perpsEnabledFlag = useSelector(selectPerpsEnabledFlag); const isPerpsEnabled = useMemo( - () => perpsEnabledFlag && isEvmSelected, - [perpsEnabledFlag, isEvmSelected], + () => + perpsEnabledFlag && (isEvmSelected || areAllEvmPopularNetworksEnabled), + [perpsEnabledFlag, isEvmSelected, areAllEvmPopularNetworksEnabled], ); const predictEnabledFlag = useSelector(selectPredictEnabledFlag); const isPredictEnabled = useMemo( @@ -114,13 +129,34 @@ const ActivityView = () => { navigation.navigate(...createNetworkManagerNavDetails({})); }; + // Prevent back button returning to confirmation screen in case that users are redirected after a successful transaction. + const handleNavigateHome = useCallback(() => { + navigation.navigate(Routes.HOME_TABS); + }, [navigation]); + const handleBackPress = useCallback(() => { - if (navigation.canGoBack()) { + if (isMoneyHomeScreenEnabled) { + handleNavigateHome(); + } else if (navigation.canGoBack()) { navigation.goBack(); } - }, [navigation]); + }, [isMoneyHomeScreenEnabled, navigation, handleNavigateHome]); + + useEffect(() => { + if (!isMoneyHomeScreenEnabled) return; + + const subscription = BackHandler.addEventListener( + 'hardwareBackPress', + () => { + handleNavigateHome(); + return true; + }, + ); + + return () => subscription.remove(); + }, [navigation, isMoneyHomeScreenEnabled, handleNavigateHome]); - const showBackButton = params.showBackButton || false; + const showBackButton = params.showBackButton || isMoneyHomeScreenEnabled; // Calculate dynamic tab indices based on which tabs are enabled // Tab order: Transactions (0), Orders (1), Perps (conditional), Predict (conditional) diff --git a/app/components/Views/ActivityView/index.test.tsx b/app/components/Views/ActivityView/index.test.tsx index f80014687c1..31e0ff0d4fe 100644 --- a/app/components/Views/ActivityView/index.test.tsx +++ b/app/components/Views/ActivityView/index.test.tsx @@ -1,14 +1,21 @@ import React from 'react'; import ActivityView from '.'; +import { BackHandler } from 'react-native'; import { backgroundState } from '../../../util/test/initial-root-state'; import renderWithProvider from '../../../util/test/renderWithProvider'; import { createStackNavigator } from '@react-navigation/stack'; -import { fireEvent } from '@testing-library/react-native'; +import { cleanup, fireEvent } from '@testing-library/react-native'; // eslint-disable-next-line import-x/no-namespace import * as networkManagerUtils from '../../UI/NetworkManager'; import { useCurrentNetworkInfo } from '../../hooks/useCurrentNetworkInfo'; import { ActivitiesViewSelectorsIDs } from './ActivitiesView.testIds'; import { WalletViewSelectorsIDs } from '../Wallet/WalletView.testIds'; +import Routes from '../../../constants/navigation/Routes'; + +let mockMoneyHomeScreenEnabled = false; +jest.mock('../../UI/Money/selectors/featureFlags', () => ({ + selectMoneyHomeScreenEnabledFlag: jest.fn(() => mockMoneyHomeScreenEnabled), +})); // Mock the Perps feature flag selector - will be controlled per test let mockPerpsEnabled = false; @@ -131,13 +138,20 @@ jest.mock('../../../core/Engine', () => ({ }, })); +let mockAreAllEvmPopularNetworksEnabled = false; + jest.mock('../../hooks/useNetworksByNamespace/useNetworksByNamespace', () => ({ useNetworksByNamespace: () => ({ networks: [], + areAllNetworksSelected: false, selectNetwork: jest.fn(), selectCustomNetwork: jest.fn(), selectPopularNetwork: jest.fn(), }), + useNetworksByCustomNamespace: () => ({ + networks: [], + areAllNetworksSelected: mockAreAllEvmPopularNetworksEnabled, + }), NetworkType: { Popular: 'popular', Custom: 'custom', @@ -229,6 +243,8 @@ describe('ActivityView', () => { const mockUseCurrentNetworkInfo = useCurrentNetworkInfo as jest.MockedFunction; + let backHandlerSpy: jest.SpyInstance; + const defaultNetworkInfo = { enabledNetworks: [ { chainId: '0x1', enabled: true }, @@ -259,14 +275,26 @@ describe('ActivityView', () => { beforeEach(() => { jest.clearAllMocks(); + backHandlerSpy = jest + .spyOn(BackHandler, 'addEventListener') + .mockReturnValue({ remove: jest.fn() } as unknown as ReturnType< + typeof BackHandler.addEventListener + >); mockUseCurrentNetworkInfo.mockReturnValue(defaultNetworkInfo); mockIsEvmSelected = true; + mockMoneyHomeScreenEnabled = false; mockPerpsEnabled = false; mockPredictEnabled = false; + mockAreAllEvmPopularNetworksEnabled = false; clearRenderedTabs(); mockRoute.params = {}; }); + afterEach(() => { + cleanup(); + backHandlerSpy.mockRestore(); + }); + describe('Network Manager Integration', () => { beforeEach(() => { jest.clearAllMocks(); @@ -395,6 +423,80 @@ describe('ActivityView', () => { expect(mockNavigation.goBack).not.toHaveBeenCalled(); }); + + it('displays back button when Money home screen flag is enabled without showBackButton param', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = {}; + + const { getByTestId } = renderComponent(mockInitialState); + + expect(getByTestId('activity-view-back-button')).toBeOnTheScreen(); + }); + + it('calls navigation.navigate with HOME_TABS on back button press when Money flag is enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = {}; + const { getByTestId } = renderComponent(mockInitialState); + + fireEvent.press(getByTestId('activity-view-back-button')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.HOME_TABS); + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + }); + + it('calls navigation.navigate with HOME_TABS and not goBack when both flag and showBackButton param are true', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = { showBackButton: true }; + const { getByTestId } = renderComponent(mockInitialState); + + fireEvent.press(getByTestId('activity-view-back-button')); + + expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.HOME_TABS); + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + }); + + it('registers hardwareBackPress handler when Money flag is enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = {}; + + renderComponent(mockInitialState); + + expect(BackHandler.addEventListener).toHaveBeenCalledWith( + 'hardwareBackPress', + expect.any(Function), + ); + }); + + it('navigates to HOME_TABS when hardwareBackPress fires with Money flag enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = {}; + renderComponent(mockInitialState); + const [[, handler]] = (BackHandler.addEventListener as jest.Mock).mock + .calls; + + const result = handler(); + + expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.HOME_TABS); + expect(result).toBe(true); + }); + + it('does not navigate to HOME_TABS on hardwareBackPress when Money flag is disabled', () => { + mockMoneyHomeScreenEnabled = false; + mockRoute.params = {}; + + renderComponent(mockInitialState); + + const hardwareBackPressCalls = ( + BackHandler.addEventListener as jest.Mock + ).mock.calls.filter(([event]: [string]) => event === 'hardwareBackPress'); + hardwareBackPressCalls.forEach(([, handler]: [string, () => boolean]) => + handler(), + ); + + expect(mockNavigation.navigate).not.toHaveBeenCalledWith( + Routes.HOME_TABS, + ); + }); }); describe('header and SafeAreaView', () => { @@ -455,6 +557,18 @@ describe('ActivityView', () => { queryByTestId(ActivitiesViewSelectorsIDs.HEADER_COMPACT_STANDARD), ).toBeNull(); }); + + it('renders HeaderCompactStandard when Money home screen flag is enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockRoute.params = {}; + + const { getByTestId, queryByTestId } = renderComponent(mockInitialState); + + expect( + getByTestId(ActivitiesViewSelectorsIDs.HEADER_COMPACT_STANDARD), + ).toBeOnTheScreen(); + expect(queryByTestId(ActivitiesViewSelectorsIDs.HEADER_ROOT)).toBeNull(); + }); }); describe('Perps tab', () => { @@ -474,6 +588,18 @@ describe('ActivityView', () => { expect(getRenderedTabs()).toContain('perps'); }); + it('includes Perps tab when all popular EVM networks are enabled while on non-EVM', () => { + mockPerpsEnabled = true; + mockIsEvmSelected = false; + mockAreAllEvmPopularNetworksEnabled = true; + + const { getByTestId, queryByTestId } = renderComponent(mockInitialState); + + expect(getByTestId('tab-perps')).toBeOnTheScreen(); + expect(queryByTestId('perps-transactions-view')).toBeNull(); + expect(getRenderedTabs()).toContain('perps'); + }); + it('excludes Perps tab when feature flag is disabled', () => { mockPerpsEnabled = false; mockIsEvmSelected = true; @@ -486,6 +612,7 @@ describe('ActivityView', () => { it('excludes Perps tab on non-EVM network even with feature flag enabled', () => { mockPerpsEnabled = true; mockIsEvmSelected = false; + mockAreAllEvmPopularNetworksEnabled = false; renderComponent(mockInitialState); diff --git a/app/components/Views/BrowserTab/components/Options/Options.testIds.ts b/app/components/Views/BrowserTab/components/Options/Options.testIds.ts new file mode 100644 index 00000000000..dac9ec7070a --- /dev/null +++ b/app/components/Views/BrowserTab/components/Options/Options.testIds.ts @@ -0,0 +1,10 @@ +export const BrowserOptionsSelectorsIDs = { + MENU: 'browser-options-menu', + ADD_FAVORITES: 'browser-options-menu-add-favorites', + OPEN_FAVORITES: 'browser-options-menu-open-favorites', + NEW_TAB: 'browser-options-menu-new-tab', + RELOAD: 'browser-options-menu-reload', + SHARE: 'browser-options-menu-share', + OPEN_IN_BROWSER: 'browser-options-menu-open-in-browser', + SWITCH_NETWORK: 'browser-options-switch-browser', +}; diff --git a/app/components/Views/BrowserTab/components/Options/index.tsx b/app/components/Views/BrowserTab/components/Options/index.tsx index 607a2cc9185..664216f2ee2 100644 --- a/app/components/Views/BrowserTab/components/Options/index.tsx +++ b/app/components/Views/BrowserTab/components/Options/index.tsx @@ -1,27 +1,18 @@ import React, { MutableRefObject, useCallback } from 'react'; import { Linking, - Platform, Text, TouchableWithoutFeedback, View, ImageSourcePropType, } from 'react-native'; import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; -import generateTestId from '../../../../../../wdio/utils/generateTestId'; import Device from '../../../../../util/device'; import { useStyles } from '../../../../hooks/useStyles'; import styleSheet from './styles'; import Button from '../../../../UI/Button'; import { strings } from '../../../../../../locales/i18n'; -import { - ADD_FAVORITES_OPTION, - MENU_ID, - NEW_TAB_OPTION, - OPEN_IN_BROWSER_OPTION, - RELOAD_OPTION, - SHARE_OPTION, -} from '../../../../../../wdio/screen-objects/testIDs/BrowserScreen/OptionMenu.testIds'; +import { BrowserOptionsSelectorsIDs } from './Options.testIds'; import Icon from 'react-native-vector-icons/FontAwesome'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; @@ -194,7 +185,7 @@ const Options = ({ {strings('browser.share')} @@ -216,7 +207,7 @@ const Options = ({ {strings('browser.reload')} @@ -235,7 +226,7 @@ const Options = ({ ? styles.optionsWrapperAndroid : styles.optionsWrapperIos, ]} - {...generateTestId(Platform, MENU_ID)} + testID={BrowserOptionsSelectorsIDs.MENU} > + ); diff --git a/app/components/Views/NftDetails/NftDetails.test.ts b/app/components/Views/NftDetails/NftDetails.test.ts index c62a4cacea8..752958b9330 100644 --- a/app/components/Views/NftDetails/NftDetails.test.ts +++ b/app/components/Views/NftDetails/NftDetails.test.ts @@ -205,6 +205,7 @@ describe('NftDetails', () => { ); expect(getByText(TEST_COLLECTIBLE.name)).toBeOnTheScreen(); + expect(getByText(TEST_COLLECTIBLE.collection.name)).toBeOnTheScreen(); }); it('tracks NFT Details Opened event with mobile-nft-list source', () => { diff --git a/app/components/Views/NftDetails/NftDetails.tsx b/app/components/Views/NftDetails/NftDetails.tsx index e89661cd859..129c204e7ab 100644 --- a/app/components/Views/NftDetails/NftDetails.tsx +++ b/app/components/Views/NftDetails/NftDetails.tsx @@ -528,6 +528,12 @@ const NftDetails = () => { ) : null} + + Notification Details + + {params?.notification?.id ?? ''} + + navigation.goBack()} + > + Back + + + ); +} + +function renderNotificationsScreen( + notifications: typeof MOCK_NOTIFICATIONS = MOCK_NOTIFICATIONS, +) { + return renderScreenWithRoutes( + NotificationsView as unknown as React.ComponentType, + { name: 'NotificationsView' }, + [ + { + name: Routes.NOTIFICATIONS.DETAILS, + Component: NotificationsDetailsProbe, + }, + ], + { state: buildNotificationsState({ notifications }) }, + ); +} + +describeForPlatforms('Notifications view (list + details flow)', () => { + /** + * The smoke spec inspects the rendered list of notifications. In jest, + * `FlatList` never receives layout metrics so it only renders the first row + * — instead of fighting virtualization we assert on the FlatList `data` + * prop, which is the same source the device-rendered list reads from. + */ + it('exposes the full seeded notifications list to the FlatList data source', () => { + const result = renderNotificationsScreen(); + const flatList = result.UNSAFE_getByType(FlatList); + + expect( + result.getByTestId(NotificationsViewSelectorsIDs.NOTIFICATIONS_CONTAINER), + ).toBeOnTheScreen(); + + const data = (flatList.props as { data?: typeof MOCK_NOTIFICATIONS }).data; + expect(data).toHaveLength(MOCK_NOTIFICATIONS.length); + + const seededIds = new Set(MOCK_NOTIFICATIONS.map((n) => n.id)); + data?.forEach((n) => { + expect(seededIds.has(n.id)).toBe(true); + }); + }); + + /** + * Seed only the feature announcement so it's the first (and only) row in + * the FlatList's initial render window — proves the same tap → details → + * back path the smoke spec asserts on, without depending on virtualization. + */ + it('opens details for a feature announcement and returns on back', async () => { + const featureAnnouncement = MOCK_FEATURE_ANNOUNCEMENT_NOTIFICATIONS[0]; + const result = renderNotificationsScreen([featureAnnouncement]); + + fireEvent.press( + await result.findByTestId( + NotificationMenuViewSelectorsIDs.ITEM(featureAnnouncement.id), + ), + ); + + await waitFor(() => { + expect( + result.getByTestId(NOTIFICATIONS_DETAILS_PROBE_TEST_ID), + ).toBeOnTheScreen(); + }); + expect( + result.getByTestId(NOTIFICATIONS_DETAILS_PROBE_ID_TEST_ID), + ).toHaveTextContent(featureAnnouncement.id); + + fireEvent.press(result.getByTestId(NOTIFICATIONS_DETAILS_BACK_TEST_ID)); + + await waitFor(() => { + expect( + result.getByTestId( + NotificationsViewSelectorsIDs.NOTIFICATIONS_CONTAINER, + ), + ).toBeOnTheScreen(); + }); + }); + + it('opens details for a wallet notification and returns on back', async () => { + const walletNotification = MOCK_ON_CHAIN_NOTIFICATIONS[0]; + const result = renderNotificationsScreen([walletNotification]); + + fireEvent.press( + await result.findByTestId( + NotificationMenuViewSelectorsIDs.ITEM(walletNotification.id), + ), + ); + + await waitFor(() => { + expect( + result.getByTestId(NOTIFICATIONS_DETAILS_PROBE_TEST_ID), + ).toBeOnTheScreen(); + }); + expect( + result.getByTestId(NOTIFICATIONS_DETAILS_PROBE_ID_TEST_ID), + ).toHaveTextContent(walletNotification.id); + + fireEvent.press(result.getByTestId(NOTIFICATIONS_DETAILS_BACK_TEST_ID)); + + await waitFor(() => { + expect( + result.getByTestId( + NotificationsViewSelectorsIDs.NOTIFICATIONS_CONTAINER, + ), + ).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/Views/OAuthRehydration/index.tsx b/app/components/Views/OAuthRehydration/index.tsx index fffa40fd48b..83fea56811b 100644 --- a/app/components/Views/OAuthRehydration/index.tsx +++ b/app/components/Views/OAuthRehydration/index.tsx @@ -747,16 +747,19 @@ const OAuthRehydration: React.FC = ({ const renderPasswordField = () => ( ); diff --git a/app/components/Views/ResetPassword/index.tsx b/app/components/Views/ResetPassword/index.tsx index c5ce58e47b3..0465441d70c 100644 --- a/app/components/Views/ResetPassword/index.tsx +++ b/app/components/Views/ResetPassword/index.tsx @@ -6,6 +6,7 @@ import { Alert, InteractionManager, TouchableOpacity, + TextInput, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; @@ -142,9 +143,7 @@ const ResetPassword = ({ navigation, route }: ResetPasswordProps) => { string | undefined >(undefined); - const confirmPasswordInput = useRef | null>(null); + const confirmPasswordInput = useRef(null); const mounted = useRef(true); useEffect(() => { @@ -573,12 +572,14 @@ const ResetPassword = ({ navigation, route }: ResetPasswordProps) => { {renderWarningText(warningIncorrectPassword)} @@ -648,16 +649,9 @@ const ResetPassword = ({ navigation, route }: ResetPasswordProps) => { onChangeText={onPasswordChange} onFocus={() => setIsPasswordFieldFocused(true)} onBlur={() => setIsPasswordFieldFocused(false)} - secureTextEntry={showPasswordIndex.includes(0)} placeholder={strings( 'reset_password.new_password_placeholder', )} - testID={ChoosePasswordSelectorsIDs.NEW_PASSWORD_INPUT_ID} - onSubmitEditing={jumpToConfirmPassword} - returnKeyType="next" - autoComplete="password-new" - autoCapitalize="none" - keyboardAppearance={themeAppearance} isError={isPasswordTooShort()} endAccessory={ toggleShowPassword(0)}> @@ -675,6 +669,15 @@ const ResetPassword = ({ navigation, route }: ResetPasswordProps) => { /> } + inputProps={{ + secureTextEntry: showPasswordIndex.includes(0), + testID: ChoosePasswordSelectorsIDs.NEW_PASSWORD_INPUT_ID, + onSubmitEditing: jumpToConfirmPassword, + returnKeyType: 'next', + autoComplete: 'password-new', + autoCapitalize: 'none', + keyboardAppearance: themeAppearance, + }} /> {renderPasswordHelperText()} @@ -689,20 +692,12 @@ const ResetPassword = ({ navigation, route }: ResetPasswordProps) => { {strings('reset_password.confirm_password')} toggleShowPassword(1)}> { /> } + inputProps={{ + secureTextEntry: showPasswordIndex.includes(1), + testID: + ChoosePasswordSelectorsIDs.CONFIRM_PASSWORD_INPUT_ID, + returnKeyType: 'done', + autoComplete: 'password-new', + autoCapitalize: 'none', + keyboardAppearance: themeAppearance, + }} /> {renderErrorText()} diff --git a/app/components/Views/RevealPrivateCredential/components/PasswordEntry.tsx b/app/components/Views/RevealPrivateCredential/components/PasswordEntry.tsx index 6fff497c663..0bc541c3dfb 100644 --- a/app/components/Views/RevealPrivateCredential/components/PasswordEntry.tsx +++ b/app/components/Views/RevealPrivateCredential/components/PasswordEntry.tsx @@ -12,7 +12,6 @@ import { strings } from '../../../../../locales/i18n'; import { RevealSeedViewSelectorsIDs } from '../RevealSeedView.testIds'; import { useTheme } from '../../../../util/theme'; import { PasswordEntryProps } from '../types'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; const PasswordEntry = ({ password, @@ -22,8 +21,7 @@ const PasswordEntry = ({ showPassword, onToggleShowPassword, }: PasswordEntryProps) => { - const tw = useTailwind(); - const { colors, themeAppearance } = useTheme(); + const { themeAppearance } = useTheme(); return ( <> @@ -38,22 +36,23 @@ const PasswordEntry = ({ } + inputProps={{ + secureTextEntry: !showPassword, + autoCapitalize: 'none', + onSubmitEditing: onSubmit, + keyboardAppearance: themeAppearance, + testID: RevealSeedViewSelectorsIDs.PASSWORD_INPUT_BOX_ID, + accessibilityLabel: RevealSeedViewSelectorsIDs.PASSWORD_INPUT_BOX_ID, + returnKeyType: 'done', + autoComplete: 'password', + }} /> ({ })); describe('Root', () => { + /** Must match `testID` on the `View` returned by `jest.mock('../../Nav/App')`. */ + const mockedAppTestId = 'mock-app'; + it('should initialize SecureKeychain', async () => { render(); @@ -66,9 +69,9 @@ describe('Root', () => { Object.defineProperty(testUtils, 'isTest', { value: true, writable: true }); }); - it('should render null while isTest is true and store is loading', () => { + it('does not mount Nav/App until the store gate clears when isTest is true', () => { Object.defineProperty(testUtils, 'isTest', { value: true, writable: true }); - const { toJSON } = render(); - expect(toJSON()).toMatchSnapshot(); + const { queryByTestId } = render(); + expect(queryByTestId(mockedAppTestId)).toBeNull(); }); }); diff --git a/app/components/Views/Settings/BatchAccountBalanceSettings/index.tsx b/app/components/Views/Settings/BatchAccountBalanceSettings/index.tsx index b7ccb73b816..cc4045b542b 100644 --- a/app/components/Views/Settings/BatchAccountBalanceSettings/index.tsx +++ b/app/components/Views/Settings/BatchAccountBalanceSettings/index.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { View, Switch, Platform } from 'react-native'; +import { View, Switch } from 'react-native'; import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import { selectIsMultiAccountBalancesEnabled } from '../../../../selectors/preferencesController'; @@ -10,7 +10,6 @@ import Text, { TextVariant, TextColor, } from '../../../../component-library/components/Texts/Text'; -import generateTestId from '../../../../../wdio/utils/generateTestId'; import styleSheet from './index.styles'; import { BATCH_BALANCE_REQUESTS_SECTION, @@ -61,10 +60,7 @@ const BatchAccountBalanceSettings = () => { thumbColor={theme.brandColors.white} style={styles.switch} ios_backgroundColor={colors.border.muted} - {...generateTestId( - Platform, - SECURITY_PRIVACY_MULTI_ACCOUNT_BALANCES_TOGGLE_ID, - )} + testID={SECURITY_PRIVACY_MULTI_ACCOUNT_BALANCES_TOGGLE_ID} /> diff --git a/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx b/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx new file mode 100644 index 00000000000..df67c1d30e5 --- /dev/null +++ b/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx @@ -0,0 +1,200 @@ +import '../../../../../tests/component-view/mocks'; +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; + +import { renderComponentViewScreen } from '../../../../../tests/component-view/render'; +import { describeForPlatforms } from '../../../../../tests/component-view/platform'; +import { + buildNotificationsState, + NOTIFICATIONS_ACCOUNT_ADDRESS, +} from '../../../../../tests/component-view/presets/notifications'; +import NotificationsSettings from './'; +import { + NotificationSettingsViewSelectorsIDs, + NotificationSettingsViewSelectorsText, +} from './NotificationSettingsView.testIds'; +import Engine from '../../../../core/Engine'; + +/** + * Component-view coverage for smoke `notification-settings-flow`. + * + * Smoke spec: tests/smoke/notifications/notification-settings-flow.spec.ts + * + * No hooks, selectors or services are mocked here — the per-account toggle + * resolves from real `AccountTreeController` + `AccountsController` state + * seeded by `buildNotificationsState`. The feature flag check resolves true + * via `IS_TEST=true` (set at config-load time in `jest.config.view.js`). + */ + +function renderSettings( + stateOverrides?: Parameters[0], +) { + return renderComponentViewScreen( + NotificationsSettings as unknown as React.ComponentType, + { name: 'NotificationsSettings' }, + { state: buildNotificationsState(stateOverrides) }, + { isFullScreenModal: false }, + ); +} + +describeForPlatforms('Notifications settings (toggles + visibility)', () => { + it('renders all sub-toggles when notifications are enabled', async () => { + const { getByTestId, findByText } = renderSettings(); + + expect( + getByTestId(NotificationSettingsViewSelectorsIDs.NOTIFICATIONS_TOGGLE), + ).toBeOnTheScreen(); + + expect( + await waitFor(() => + getByTestId( + NotificationSettingsViewSelectorsIDs.PUSH_NOTIFICATIONS_TOGGLE, + ), + ), + ).toBeOnTheScreen(); + + expect( + getByTestId( + NotificationSettingsViewSelectorsIDs.FEATURE_ANNOUNCEMENTS_TOGGLE, + ), + ).toBeOnTheScreen(); + + expect( + await findByText( + NotificationSettingsViewSelectorsText.ACCOUNT_ACTIVITY_SECTION, + ), + ).toBeOnTheScreen(); + + expect( + getByTestId( + NotificationSettingsViewSelectorsIDs.ACCOUNT_NOTIFICATION_TOGGLE( + NOTIFICATIONS_ACCOUNT_ADDRESS, + ), + ), + ).toBeOnTheScreen(); + }); + + it('hides push, feature announcements and account section when main toggle is off', async () => { + const { getByTestId, queryByTestId, queryByText } = renderSettings({ + notificationsEnabled: false, + }); + + expect( + getByTestId(NotificationSettingsViewSelectorsIDs.NOTIFICATIONS_TOGGLE), + ).toBeOnTheScreen(); + + await waitFor(() => { + expect( + queryByTestId( + NotificationSettingsViewSelectorsIDs.PUSH_NOTIFICATIONS_TOGGLE, + ), + ).toBeNull(); + }); + expect( + queryByTestId( + NotificationSettingsViewSelectorsIDs.FEATURE_ANNOUNCEMENTS_TOGGLE, + ), + ).toBeNull(); + expect( + queryByText( + NotificationSettingsViewSelectorsText.ACCOUNT_ACTIVITY_SECTION, + ), + ).toBeNull(); + }); + + it('invokes the disable controller path when the main toggle is pressed (on -> off)', async () => { + const disableSpy = jest + .spyOn( + Engine.context.NotificationServicesController as unknown as { + disableNotificationServices: () => Promise; + }, + 'disableNotificationServices', + ) + .mockResolvedValue(undefined); + + try { + const { getByTestId } = renderSettings(); + + fireEvent( + getByTestId(NotificationSettingsViewSelectorsIDs.NOTIFICATIONS_TOGGLE), + 'onChange', + { nativeEvent: { value: false } }, + ); + + await waitFor(() => { + expect(disableSpy).toHaveBeenCalled(); + }); + } finally { + disableSpy.mockRestore(); + } + }); + + it('invokes setFeatureAnnouncementsEnabled(false) when the feature announcements toggle is pressed', async () => { + const toggleSpy = jest + .spyOn( + Engine.context.NotificationServicesController as unknown as { + setFeatureAnnouncementsEnabled: (val: boolean) => Promise; + }, + 'setFeatureAnnouncementsEnabled', + ) + .mockResolvedValue(undefined); + + try { + const { getByTestId } = renderSettings(); + + fireEvent( + getByTestId( + NotificationSettingsViewSelectorsIDs.FEATURE_ANNOUNCEMENTS_TOGGLE, + ), + 'onChange', + { nativeEvent: { value: false } }, + ); + + await waitFor(() => { + expect(toggleSpy).toHaveBeenCalledWith(false); + }); + } finally { + toggleSpy.mockRestore(); + } + }); + + /** + * The per-account toggle's initial state comes from + * `Engine.NotificationServicesController.checkAccountsPresence`, which our + * Engine stub resolves to `{}` by default → toggle starts OFF. Pressing it + * therefore calls `enableAccounts` (off → on); the inverse direction is + * symmetrical. We assert the wiring through the press, not the direction. + */ + it('invokes enableAccounts with the account address when the per-account toggle is pressed', async () => { + const enableAccountsSpy = jest + .spyOn( + Engine.context.NotificationServicesController as unknown as { + enableAccounts: (addresses: string[]) => Promise; + }, + 'enableAccounts', + ) + .mockResolvedValue(undefined); + + try { + const { getByTestId } = renderSettings(); + + fireEvent( + getByTestId( + NotificationSettingsViewSelectorsIDs.ACCOUNT_NOTIFICATION_TOGGLE( + NOTIFICATIONS_ACCOUNT_ADDRESS, + ), + ), + 'onChange', + { nativeEvent: { value: true } }, + ); + + await waitFor(() => { + expect(enableAccountsSpy).toHaveBeenCalledWith([ + NOTIFICATIONS_ACCOUNT_ADDRESS, + ]); + }); + } finally { + enableAccountsSpy.mockRestore(); + } + }); +}); diff --git a/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts b/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts index d28261b5ef5..50fd8b639a2 100644 --- a/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts +++ b/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts @@ -13,6 +13,7 @@ export const SecurityPrivacyViewSelectorsIDs = { DEVICE_SECURITY_TOGGLE: 'device-security-toggle', CLEAR_PRIVACY_DATA_BUTTON: 'clear-privacy-data-button', PROTECT_YOUR_WALLET: 'protect-your-wallet', + DELETE_WALLET_BUTTON: 'security-settings-delete-wallet-buttons', }; export const SecurityPrivacyViewSelectorsText = { diff --git a/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx b/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx index 1399163f9d4..6057ff80af1 100644 --- a/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx +++ b/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx @@ -28,7 +28,7 @@ import { } from './Sections'; import { selectProviderType } from '../../../../selectors/networkController'; import { selectUseTransactionSimulations } from '../../../../selectors/preferencesController'; -import { SECURITY_PRIVACY_VIEW_ID } from '../../../../../wdio/screen-objects/testIDs/Screens/SecurityPrivacy.testIds'; +import { SecurityPrivacyViewSelectorsIDs } from './SecurityPrivacyView.testIds'; import createStyles from './SecuritySettings.styles'; import { HeadingProps, SecuritySettingsParams } from './SecuritySettings.types'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; @@ -360,7 +360,7 @@ const Settings: React.FC = () => { /> diff --git a/app/components/Views/Settings/index.test.tsx b/app/components/Views/Settings/index.test.tsx index db808cae634..28b54cdedf9 100644 --- a/app/components/Views/Settings/index.test.tsx +++ b/app/components/Views/Settings/index.test.tsx @@ -52,11 +52,6 @@ jest.mock('@react-navigation/native', () => { }; }); -jest.mock('../../../util/networks', () => ({ - ...jest.requireActual('../../../util/networks'), - isPermissionsSettingsV1Enabled: true, -})); - jest.mock('../../../util/notifications/constants/config', () => ({ isNotificationsFeatureEnabled: jest.fn(() => true), })); diff --git a/app/components/Views/SimpleWebview/index.test.tsx b/app/components/Views/SimpleWebview/index.test.tsx index d8ceedb3856..e2b9f9ef566 100644 --- a/app/components/Views/SimpleWebview/index.test.tsx +++ b/app/components/Views/SimpleWebview/index.test.tsx @@ -4,30 +4,20 @@ import SimpleWebview from './'; import { useNavigation } from '@react-navigation/native'; import Share from 'react-native-share'; import Logger from '../../../util/Logger'; -import getHeaderCompactStandardNavbarOptions from '../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions'; -import Device from '../../../util/device'; +import HeaderCompactStandard from '../../../component-library/components-temp/HeaderCompactStandard'; jest.mock( - '../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions', + '../../../component-library/components-temp/HeaderCompactStandard', () => ({ __esModule: true, - default: jest.fn(() => ({ - header: () => null, - })), + default: jest.fn(() => null), }), ); -jest.mock('../../../util/device', () => ({ - __esModule: true, - default: { - isAndroid: jest.fn(() => false), - }, -})); - -const mockSetOptions = jest.fn(); +const mockGoBack = jest.fn(); const mockNavigation = { - goBack: jest.fn(), - setOptions: mockSetOptions, + goBack: mockGoBack, + setOptions: jest.fn(), }; jest.mock('@react-navigation/native', () => ({ @@ -48,64 +38,38 @@ describe('SimpleWebview', () => { (Share.open as jest.Mock).mockImplementation(() => Promise.resolve()); }); - it('renders correctly', () => { + it('renders the HeaderCompactStandard with safe area top inset', () => { render(); - expect(getHeaderCompactStandardNavbarOptions).toHaveBeenCalled(); - }); - - it('sets header options from HeaderCompactStandard and Device.isAndroid() for includesTopInset', () => { - render(); - - expect(getHeaderCompactStandardNavbarOptions).toHaveBeenCalledWith( + expect(HeaderCompactStandard).toHaveBeenCalledWith( expect.objectContaining({ title: '', - includesTopInset: false, - twClassName: 'bg-default rounded-t-2xl', + includesTopInset: true, onBack: expect.any(Function), endButtonIconProps: expect.arrayContaining([ expect.objectContaining({ onPress: expect.any(Function) }), ]), }), - ); - expect(mockSetOptions).toHaveBeenCalledWith( - expect.objectContaining({ - header: expect.any(Function), - }), - ); - expect(mockSetOptions.mock.calls[0][0]).not.toHaveProperty('headerStyle'); - }); - - it('passes includesTopInset true when Device.isAndroid() is true', () => { - jest.mocked(Device.isAndroid).mockReturnValueOnce(true); - render(); - - expect(getHeaderCompactStandardNavbarOptions).toHaveBeenCalledWith( - expect.objectContaining({ - includesTopInset: true, - }), + expect.anything(), ); }); it('calls navigation.goBack when header onBack is invoked', () => { render(); - const { onBack } = (getHeaderCompactStandardNavbarOptions as jest.Mock).mock + const headerProps = (HeaderCompactStandard as unknown as jest.Mock).mock .calls[0][0] as { onBack: () => void }; - onBack(); + headerProps.onBack(); - expect(mockNavigation.goBack).toHaveBeenCalled(); + expect(mockGoBack).toHaveBeenCalled(); }); it('calls Share.open when share button onPress is invoked', () => { render(); - const { endButtonIconProps } = ( - getHeaderCompactStandardNavbarOptions as jest.Mock - ).mock.calls[0][0] as { - endButtonIconProps: { onPress: () => void }[]; - }; - endButtonIconProps[0].onPress(); + const headerProps = (HeaderCompactStandard as unknown as jest.Mock).mock + .calls[0][0] as { endButtonIconProps: { onPress: () => void }[] }; + headerProps.endButtonIconProps[0].onPress(); expect(Share.open).toHaveBeenCalledWith({ url: 'https://etherscan.io' }); }); @@ -116,12 +80,9 @@ describe('SimpleWebview', () => { render(); - const { endButtonIconProps } = ( - getHeaderCompactStandardNavbarOptions as jest.Mock - ).mock.calls[0][0] as { - endButtonIconProps: { onPress: () => void }[]; - }; - endButtonIconProps[0].onPress(); + const headerProps = (HeaderCompactStandard as unknown as jest.Mock).mock + .calls[0][0] as { endButtonIconProps: { onPress: () => void }[] }; + headerProps.endButtonIconProps[0].onPress(); await waitFor(() => { expect(log).toHaveBeenCalledWith( diff --git a/app/components/Views/SimpleWebview/index.tsx b/app/components/Views/SimpleWebview/index.tsx index fb0eb616c6f..42fb2807540 100644 --- a/app/components/Views/SimpleWebview/index.tsx +++ b/app/components/Views/SimpleWebview/index.tsx @@ -1,22 +1,25 @@ /* eslint-disable @typescript-eslint/consistent-type-definitions */ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback } from 'react'; +import { View } from 'react-native'; import { WebView } from '@metamask/react-native-webview'; -import getHeaderCompactStandardNavbarOptions from '../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions'; +import HeaderCompactStandard from '../../../component-library/components-temp/HeaderCompactStandard'; import { IconName } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import Share from 'react-native-share'; // eslint-disable-line import-x/default import Logger from '../../../util/Logger'; import { baseStyles } from '../../../styles/common'; -import Device from '../../../util/device'; import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; // TODO: This will be replaced with the actual route params type once navigation is refactored type RouteParams = { SimpleWebView: { url: string; + title?: string; }; }; const SimpleWebView = () => { + const tw = useTailwind(); const navigation = useNavigation(); const route = useRoute>(); const url = route.params.url; @@ -32,19 +35,17 @@ const SimpleWebView = () => { } }, [url]); - useEffect(() => { - navigation.setOptions({ - ...getHeaderCompactStandardNavbarOptions({ - title, - onBack: () => navigation.goBack(), - includesTopInset: Device.isAndroid(), - twClassName: 'bg-default rounded-t-2xl', - endButtonIconProps: [{ iconName: IconName.Share, onPress: share }], - }), - }); - }, [navigation, share, title]); - - return ; + return ( + + navigation.goBack()} + includesTopInset + endButtonIconProps={[{ iconName: IconName.Share, onPress: share }]} + /> + + + ); }; export default SimpleWebView; diff --git a/app/components/Views/SitesFullView/SitesFullView.test.tsx b/app/components/Views/SitesFullView/SitesFullView.test.tsx index b389e694a35..1abf768b60a 100644 --- a/app/components/Views/SitesFullView/SitesFullView.test.tsx +++ b/app/components/Views/SitesFullView/SitesFullView.test.tsx @@ -1,21 +1,33 @@ import React from 'react'; -import { render, fireEvent, act, waitFor } from '@testing-library/react-native'; +import { fireEvent, act, waitFor } from '@testing-library/react-native'; +import renderWithProvider from '../../../util/test/renderWithProvider'; import SitesFullView from './SitesFullView'; import { useSitesData } from '../../UI/Sites/hooks/useSiteData/useSitesData'; import type { SiteData } from '../../UI/Sites/components/SiteRowItem/SiteRowItem'; // Mock dependencies jest.mock('../../UI/Sites/hooks/useSiteData/useSitesData'); +jest.mock( + '../../UI/Sites/hooks/useBrowserFavoritesSites/useBrowserFavoritesSites', + () => ({ + useBrowserFavoritesSites: jest.fn(() => ({ sites: [], isLoading: false })), + }), +); const mockGoBack = jest.fn(); const mockNavigate = jest.fn(); -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ - navigate: mockNavigate, - goBack: mockGoBack, - }), -})); +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + navigate: mockNavigate, + goBack: mockGoBack, + }), + useRoute: () => ({ params: {} }), + }; +}); jest.mock('../../../util/theme', () => { const { mockTheme } = jest.requireActual('../../../util/theme'); @@ -185,7 +197,7 @@ describe('SitesFullView', () => { refetch: mockRefetch, }); - const { getByTestId } = render(); + const { getByTestId } = renderWithProvider(); expect(getByTestId('sites-full-view-header')).toBeOnTheScreen(); expect( @@ -204,7 +216,7 @@ describe('SitesFullView', () => { refetch: mockRefetch, }); - const { getByTestId } = render(); + const { getByTestId } = renderWithProvider(); expect(getByTestId('sites-list')).toBeOnTheScreen(); expect(getByTestId('site-item-1')).toBeOnTheScreen(); @@ -219,7 +231,7 @@ describe('SitesFullView', () => { refetch: mockRefetch, }); - const { getAllByTestId } = render(); + const { getAllByTestId } = renderWithProvider(); const skeletons = getAllByTestId('site-skeleton'); expect(skeletons.length).toBe(15); @@ -232,7 +244,7 @@ describe('SitesFullView', () => { refetch: mockRefetch, }); - const { getByTestId } = render(); + const { getByTestId } = renderWithProvider(); expect(getByTestId('refresh-control')).toBeOnTheScreen(); }); @@ -246,7 +258,7 @@ describe('SitesFullView', () => { refetch: mockRefetch, }); - const { getByTestId } = render(); + const { getByTestId } = renderWithProvider(); const backButton = getByTestId('sites-full-view-header-back-button'); fireEvent.press(backButton); @@ -259,7 +271,9 @@ describe('SitesFullView', () => { it('filters sites by name, URL, and display URL', () => { setupMockWithSearchFilter(); - const { getByTestId, queryByTestId } = render(); + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); // Activate search fireEvent.press(getByTestId('sites-full-view-header-search-toggle')); @@ -284,7 +298,7 @@ describe('SitesFullView', () => { it('shows all sites when search query is empty', () => { setupMockWithSearchFilter(); - const { getByTestId } = render(); + const { getByTestId } = renderWithProvider(); // Activate search fireEvent.press(getByTestId('sites-full-view-header-search-toggle')); @@ -306,7 +320,7 @@ describe('SitesFullView', () => { refetch: mockRefetch, }); - const { getByTestId } = render(); + const { getByTestId } = renderWithProvider(); // Activate search fireEvent.press(getByTestId('sites-full-view-header-search-toggle')); @@ -333,7 +347,9 @@ describe('SitesFullView', () => { refetch: mockRefetch, }); - const { getByTestId, queryByTestId } = render(); + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); // Initially no footer expect(queryByTestId('sites-search-footer')).toBeNull(); @@ -356,7 +372,9 @@ describe('SitesFullView', () => { refetch: mockRefetch, }); - const { getByTestId, queryByTestId } = render(); + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); // Footer should not appear when search is inactive expect(queryByTestId('sites-search-footer')).toBeNull(); @@ -377,7 +395,7 @@ describe('SitesFullView', () => { refetch: mockRefetch, }); - render(); + renderWithProvider(); expect(mockUseSitesData).toHaveBeenCalledWith(''); }); @@ -389,7 +407,7 @@ describe('SitesFullView', () => { refetch: mockRefetch, }); - render(); + renderWithProvider(); const SitesListMock = jest.requireMock( '../../UI/Sites/components/SitesList/SitesList', @@ -429,7 +447,7 @@ describe('SitesFullView', () => { refetch: mockRefetch, }); - const { getByTestId } = render(); + const { getByTestId } = renderWithProvider(); expect(getByTestId('site-item-1')).toBeOnTheScreen(); }); @@ -441,7 +459,9 @@ describe('SitesFullView', () => { refetch: mockRefetch, }); - const { getByTestId, queryByTestId } = render(); + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); expect(getByTestId('sites-list')).toBeOnTheScreen(); expect(queryByTestId('site-item-1')).toBeNull(); @@ -450,7 +470,9 @@ describe('SitesFullView', () => { it('performs case-insensitive search', () => { setupMockWithSearchFilter(); - const { getByTestId, queryByTestId } = render(); + const { getByTestId, queryByTestId } = renderWithProvider( + , + ); // Activate search fireEvent.press(getByTestId('sites-full-view-header-search-toggle')); diff --git a/app/components/Views/SitesFullView/SitesFullView.tsx b/app/components/Views/SitesFullView/SitesFullView.tsx index cffb315c4fc..5be333dd6f8 100644 --- a/app/components/Views/SitesFullView/SitesFullView.tsx +++ b/app/components/Views/SitesFullView/SitesFullView.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useState, useMemo } from 'react'; import { Platform, StyleSheet, View, RefreshControl } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { useDispatch } from 'react-redux'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { SafeAreaView, useSafeAreaInsets, @@ -8,12 +9,20 @@ import { import { useAppThemeFromContext } from '../../../util/theme'; import { Theme } from '../../../util/theme/models'; import { useSitesData } from '../../UI/Sites/hooks/useSiteData/useSitesData'; +import { useBrowserFavoritesSites } from '../../UI/Sites/hooks/useBrowserFavoritesSites/useBrowserFavoritesSites'; import SitesList from '../../UI/Sites/components/SitesList/SitesList'; import SiteSkeleton from '../../UI/Sites/components/SiteSkeleton/SiteSkeleton'; import SitesSearchFooter from '../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter'; +import { removeBookmark } from '../../../actions/bookmarks'; +import { + bookmarkUrlForRemoval, + type SiteData, +} from '../../UI/Sites/components/SiteRowItem/SiteRowItem'; import { strings } from '../../../../locales/i18n'; import ListHeaderWithSearch from '../../UI/shared/ListHeaderWithSearch/ListHeaderWithSearch'; +type SitesFullViewParams = { mode?: 'favorites' } | undefined; + const createStyles = (theme: Theme) => StyleSheet.create({ safeArea: { @@ -34,13 +43,35 @@ const SitesFullView: React.FC = () => { const theme = useAppThemeFromContext(); const styles = useMemo(() => createStyles(theme), [theme]); const insets = useSafeAreaInsets(); + const dispatch = useDispatch(); const navigation = useNavigation(); + const route = + useRoute>(); + const isFavorites = route.params?.mode === 'favorites'; + const [searchQuery, setSearchQuery] = useState(''); const [isSearchActive, setIsSearchActive] = useState(false); const [refreshing, setRefreshing] = useState(false); - // Fetch all sites (no limit) - const { sites, isLoading, refetch: refetchSites } = useSitesData(searchQuery); + // Always call both hooks unconditionally (Rules of Hooks). + // useSitesData has a module-level cache so the API is only called once. + const { + sites: popularSites, + isLoading: popularLoading, + refetch: popularRefetch, + } = useSitesData(isFavorites ? '' : searchQuery); + const { + data: favoriteSites, + isLoading: favoritesLoading, + refetch: favoritesRefetch, + } = useBrowserFavoritesSites(isFavorites ? searchQuery : ''); + + const sites = isFavorites ? favoriteSites : popularSites; + const isLoading = isFavorites ? favoritesLoading : popularLoading; + const refetchSites = isFavorites ? favoritesRefetch : popularRefetch; + const title = isFavorites + ? strings('autocomplete.favorites') + : strings('trending.popular_sites'); const handleBackPress = useCallback(() => { navigation.goBack(); @@ -48,15 +79,11 @@ const SitesFullView: React.FC = () => { const handleSearchToggle = useCallback(() => { setIsSearchActive((prev) => { - if (prev) { - // Closing search, clear the query - setSearchQuery(''); - } + if (prev) setSearchQuery(''); return !prev; }); }, []); - // Handle pull-to-refresh const handleRefresh = useCallback(async () => { setRefreshing(true); try { @@ -78,7 +105,6 @@ const SitesFullView: React.FC = () => { const renderFooter = useMemo(() => { if (!isSearchActive) return null; - return ; }, [isSearchActive, searchQuery]); @@ -98,7 +124,7 @@ const SitesFullView: React.FC = () => { ]} > { ) : ( + dispatch( + removeBookmark({ + url: bookmarkUrlForRemoval(site), + name: site.name, + }), + ) + : undefined + } refreshControl={ ({ jest.mock('@metamask/react-data-query'); +jest.mock('@sentry/react-native', () => ({ + addBreadcrumb: jest.fn(), +})); + +const mockAddBreadcrumb = addBreadcrumb as jest.Mock; + const mockRefetch = jest.fn(); const mockUseQuery = useQuery as jest.MockedFunction; @@ -18,6 +25,7 @@ const makeQueryResult = ( ({ data: undefined, isLoading: false, + isFetching: false, error: null, refetch: mockRefetch, ...overrides, @@ -45,6 +53,7 @@ describe('useFollowedTraders', () => { beforeEach(() => { jest.clearAllMocks(); mockUseQuery.mockReturnValue(makeQueryResult()); + mockAddBreadcrumb.mockClear(); }); describe('query configuration', () => { @@ -135,13 +144,17 @@ describe('useFollowedTraders', () => { expect(result.current.error).toBe('raw error'); }); - it('logs query errors', () => { + it('logs query errors with enriched extras', () => { const error = new Error('fetch failed'); mockUseQuery.mockReturnValue(makeQueryResult({ error })); renderHook(() => useFollowedTraders()); expect(Logger.error).toHaveBeenCalledWith( error, - 'useFollowedTraders: following fetch failed', + expect.objectContaining({ + message: 'useFollowedTraders: following fetch failed', + endpoint: 'following', + errorCategory: expect.any(String), + }), ); }); @@ -177,8 +190,46 @@ describe('useFollowedTraders', () => { expect(Logger.error).toHaveBeenCalledWith( error, - 'useFollowedTraders: refresh failed', + expect.objectContaining({ + message: 'useFollowedTraders: refresh failed', + endpoint: 'following', + errorCategory: expect.any(String), + }), + ); + }); + }); + + describe('breadcrumbs', () => { + it('emits a failure breadcrumb when an error is set', () => { + const error = new Error('fetch failed'); + mockUseQuery.mockReturnValue(makeQueryResult({ error })); + renderHook(() => useFollowedTraders()); + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'social_service', + level: 'error', + message: expect.stringContaining('social_service.following.failure'), + }), + ); + }); + + it('includes httpStatus in the failure breadcrumb for HttpError', () => { + const error = Object.assign(new Error('Unauthorized'), { + httpStatus: 401, + }); + mockUseQuery.mockReturnValue(makeQueryResult({ error })); + renderHook(() => useFollowedTraders()); + expect(mockAddBreadcrumb.mock.calls[0][0].message).toContain( + 'status=401', ); }); + + it('does not emit a breadcrumb when there is no error', () => { + mockUseQuery.mockReturnValue( + makeQueryResult({ data: fixtureFollowing as never }), + ); + renderHook(() => useFollowedTraders()); + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); }); }); diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.ts b/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.ts index e19a1c41730..dad7d878154 100644 --- a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.ts +++ b/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.ts @@ -2,6 +2,12 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useQuery } from '@metamask/react-data-query'; import type { FollowingResponse } from '@metamask/social-controllers'; import Logger from '../../../../../util/Logger'; +import { + addSocialBreadcrumb, + buildSocialErrorExtras, + categoriseSocialError, + extractHttpStatus, +} from '../../../../../util/social/socialServiceTelemetry'; export interface FollowedTrader { /** Clicker profile ID. */ @@ -67,7 +73,14 @@ export const useFollowedTraders = ( try { await refetch(); } catch (err) { - Logger.error(err as Error, 'useFollowedTraders: refresh failed'); + Logger.error( + err as Error, + buildSocialErrorExtras({ + legacyMessage: 'useFollowedTraders: refresh failed', + endpoint: 'following', + error: err, + }), + ); throw err; } }, [refetch]); @@ -76,8 +89,17 @@ export const useFollowedTraders = ( if (error) { Logger.error( error as Error, - 'useFollowedTraders: following fetch failed', + buildSocialErrorExtras({ + legacyMessage: 'useFollowedTraders: following fetch failed', + endpoint: 'following', + error, + }), ); + addSocialBreadcrumb({ + endpoint: 'following', + errorCategory: categoriseSocialError(error), + httpStatus: extractHttpStatus(error), + }); } }, [error]); diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx index 64136921239..f2603e7c76c 100644 --- a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx @@ -63,6 +63,7 @@ const fixtureTraders: TopTrader[] = [ const defaultUseTopTradersResult: UseTopTradersResult = { traders: fixtureTraders, isLoading: false, + isFetching: false, error: null, refresh: mockRefresh as () => Promise, toggleFollow: mockToggleFollow, diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx index 24483750049..8e02d0cdbaf 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/TraderPositionView.test.tsx @@ -92,6 +92,17 @@ jest.mock('../../../../core/ClipboardManager', () => ({ setString: jest.fn().mockResolvedValue(undefined), })); +// Pressing buy mounts QuickBuyBottomSheet. Jest's global mock for design-system +// `BottomSheet` (see app/util/test/testSetup.js) invokes `onOpenBottomSheet`'s +// callback synchronously, so `QuickBuyBottomSheetContent` mounts in the same turn +// and runs `useQuickBuyBottomSheet` (bridge selectors, device version compare, +// NetworkController, …). This file intentionally uses a minimal Redux store, so +// we stub the sheet here. +jest.mock('./components/QuickBuyBottomSheet', () => ({ + __esModule: true, + default: () => null, +})); + jest.mock('../../../../util/haptics', () => { const actual = jest.requireActual( '../../../../util/haptics', diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.test.ts index 406500a237d..8a954918d20 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.test.ts @@ -1,16 +1,34 @@ import { renderHook } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; import { useQuery } from '@metamask/react-data-query'; +import { addBreadcrumb } from '@sentry/react-native'; import type { Position } from '@metamask/social-controllers'; import Logger from '../../../../../util/Logger'; +import { selectIsUnlocked } from '../../../../../selectors/keyringController'; import { useTraderPosition } from './useTraderPosition'; +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../../selectors/keyringController', () => ({ + selectIsUnlocked: jest.fn(), +})); + jest.mock('../../../../../util/Logger', () => ({ error: jest.fn(), })); jest.mock('@metamask/react-data-query'); +jest.mock('@sentry/react-native', () => ({ + addBreadcrumb: jest.fn(), +})); + +const mockAddBreadcrumb = addBreadcrumb as jest.Mock; + const mockUseQuery = useQuery as jest.MockedFunction; +const mockUseSelector = useSelector as jest.MockedFunction; const makeQueryResult = ( overrides: Partial> = {}, @@ -41,6 +59,10 @@ const mockPosition: Position = { describe('useTraderPosition', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsUnlocked) return true; + return undefined; + }); }); it('calls useQuery with the SocialService:fetchPositionById key', () => { @@ -69,6 +91,20 @@ describe('useTraderPosition', () => { ); }); + it('disables the query when wallet is locked', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsUnlocked) return false; + return undefined; + }); + mockUseQuery.mockReturnValue(makeQueryResult()); + + renderHook(() => useTraderPosition('position-uuid-1')); + + expect(mockUseQuery).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + it('returns the resolved position on success', () => { mockUseQuery.mockReturnValue( makeQueryResult({ data: mockPosition, isLoading: false }), @@ -90,7 +126,7 @@ describe('useTraderPosition', () => { expect(result.current.position).toBeUndefined(); }); - it('returns the error message and logs on failure', () => { + it('returns the error message and logs on failure with enriched extras', () => { const fetchError = new Error('boom'); mockUseQuery.mockReturnValue(makeQueryResult({ error: fetchError })); @@ -99,7 +135,47 @@ describe('useTraderPosition', () => { expect(result.current.error).toBe('boom'); expect(Logger.error).toHaveBeenCalledWith( fetchError, - 'useTraderPosition: fetch failed', + expect.objectContaining({ + message: 'useTraderPosition: fetch failed', + endpoint: 'position_by_id', + errorCategory: expect.any(String), + }), ); }); + + it('emits a failure breadcrumb when an error is set', () => { + const fetchError = new Error('boom'); + mockUseQuery.mockReturnValue(makeQueryResult({ error: fetchError })); + + renderHook(() => useTraderPosition('position-uuid-1')); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'social_service', + level: 'error', + message: expect.stringContaining( + 'social_service.position_by_id.failure', + ), + }), + ); + }); + + it('includes httpStatus in the failure breadcrumb for HttpError', () => { + const fetchError = Object.assign(new Error('Unauthorized'), { + httpStatus: 401, + }); + mockUseQuery.mockReturnValue(makeQueryResult({ error: fetchError })); + + renderHook(() => useTraderPosition('position-uuid-1')); + + expect(mockAddBreadcrumb.mock.calls[0][0].message).toContain('status=401'); + }); + + it('does not emit a breadcrumb when there is no error', () => { + mockUseQuery.mockReturnValue(makeQueryResult({ data: mockPosition })); + + renderHook(() => useTraderPosition('position-uuid-1')); + + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.ts index b67f28b210e..06c6aff0f83 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/hooks/useTraderPosition.ts @@ -1,7 +1,15 @@ import { useEffect } from 'react'; +import { useSelector } from 'react-redux'; import { useQuery } from '@metamask/react-data-query'; import type { Position } from '@metamask/social-controllers'; import Logger from '../../../../../util/Logger'; +import { + addSocialBreadcrumb, + buildSocialErrorExtras, + categoriseSocialError, + extractHttpStatus, +} from '../../../../../util/social/socialServiceTelemetry'; +import { selectIsUnlocked } from '../../../../../selectors/keyringController'; export interface UseTraderPositionResult { position: Position | undefined; @@ -17,6 +25,7 @@ export interface UseTraderPositionResult { export const useTraderPosition = ( positionId: string | undefined, ): UseTraderPositionResult => { + const isUnlocked = useSelector(selectIsUnlocked); const fetchOptions = { positionId: positionId ?? '' }; const queryKey: [string, { positionId: string }] = [ @@ -26,12 +35,24 @@ export const useTraderPosition = ( const { data, isLoading, error } = useQuery({ queryKey, - enabled: Boolean(positionId), + enabled: Boolean(positionId) && isUnlocked, }); useEffect(() => { if (error) { - Logger.error(error as Error, 'useTraderPosition: fetch failed'); + Logger.error( + error as Error, + buildSocialErrorExtras({ + legacyMessage: 'useTraderPosition: fetch failed', + endpoint: 'position_by_id', + error, + }), + ); + addSocialBreadcrumb({ + endpoint: 'position_by_id', + errorCategory: categoriseSocialError(error), + httpStatus: extractHttpStatus(error), + }); } }, [error]); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.test.ts b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.test.ts index b1614083693..3d515e912c0 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.test.ts @@ -1,15 +1,33 @@ import { renderHook } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; import { useQuery } from '@metamask/react-data-query'; +import { addBreadcrumb } from '@sentry/react-native'; import Logger from '../../../../../util/Logger'; +import { selectIsUnlocked } from '../../../../../selectors/keyringController'; import { useTraderPositions } from './useTraderPositions'; +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../../selectors/keyringController', () => ({ + selectIsUnlocked: jest.fn(), +})); + jest.mock('../../../../../util/Logger', () => ({ error: jest.fn(), })); jest.mock('@metamask/react-data-query'); +jest.mock('@sentry/react-native', () => ({ + addBreadcrumb: jest.fn(), +})); + +const mockAddBreadcrumb = addBreadcrumb as jest.Mock; + const mockUseQuery = useQuery as jest.MockedFunction; +const mockUseSelector = useSelector as jest.MockedFunction; const makeQueryResult = ( overrides: Partial> = {}, @@ -17,6 +35,7 @@ const makeQueryResult = ( ({ data: undefined, isLoading: false, + isFetching: false, error: null, refetch: jest.fn(), ...overrides, @@ -64,6 +83,11 @@ describe('useTraderPositions', () => { beforeEach(() => { jest.clearAllMocks(); mockUseQuery.mockReturnValue(makeQueryResult()); + mockAddBreadcrumb.mockClear(); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsUnlocked) return true; + return undefined; + }); }); describe('query configuration', () => { @@ -90,7 +114,7 @@ describe('useTraderPositions', () => { ); }); - it('enables both queries when addressOrId is non-empty', () => { + it('enables both queries when addressOrId is non-empty and wallet is unlocked', () => { renderHook(() => useTraderPositions('trader-1')); expect(mockUseQuery.mock.calls[0][0]).toEqual( @@ -112,6 +136,21 @@ describe('useTraderPositions', () => { ); }); + it('disables both queries when wallet is locked', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsUnlocked) return false; + return undefined; + }); + renderHook(() => useTraderPositions('trader-1')); + + expect(mockUseQuery.mock.calls[0][0]).toEqual( + expect.objectContaining({ enabled: false }), + ); + expect(mockUseQuery.mock.calls[1][0]).toEqual( + expect.objectContaining({ enabled: false }), + ); + }); + it('forwards refetchInterval only to the open positions query', () => { renderHook(() => useTraderPositions('trader-1', { refetchInterval: 30_000 }), @@ -248,7 +287,7 @@ describe('useTraderPositions', () => { expect(result.current.error).toBe('raw error'); }); - it('logs the combined error', () => { + it('logs the open error with enriched extras including the endpoint', () => { const error = new Error('fetch failed'); mockUseQuery @@ -259,7 +298,30 @@ describe('useTraderPositions', () => { expect(Logger.error).toHaveBeenCalledWith( error, - 'useTraderPositions: positions fetch failed', + expect.objectContaining({ + message: 'useTraderPositions: positions fetch failed', + endpoint: 'open_positions', + errorCategory: expect.any(String), + }), + ); + }); + + it('logs the closed error with enriched extras including the endpoint', () => { + const error = new Error('closed fetch failed'); + + mockUseQuery + .mockReturnValueOnce(makeQueryResult()) + .mockReturnValueOnce(makeQueryResult({ error })); + + renderHook(() => useTraderPositions('trader-1')); + + expect(Logger.error).toHaveBeenCalledWith( + error, + expect.objectContaining({ + message: 'useTraderPositions: positions fetch failed', + endpoint: 'closed_positions', + errorCategory: expect.any(String), + }), ); }); @@ -267,5 +329,78 @@ describe('useTraderPositions', () => { renderHook(() => useTraderPositions('trader-1')); expect(Logger.error).not.toHaveBeenCalled(); }); + + it('does NOT include addressOrId in the Logger.error extras', () => { + const error = new Error('fetch failed'); + + mockUseQuery + .mockReturnValueOnce(makeQueryResult({ error })) + .mockReturnValueOnce(makeQueryResult()); + + renderHook(() => useTraderPositions('0xSensitiveAddress')); + + const call = (Logger.error as jest.Mock).mock.calls[0]; + const extras = call[1]; + const serialised = JSON.stringify(extras); + expect(serialised).not.toContain('0xSensitiveAddress'); + expect(Object.keys(extras)).not.toContain('addressOrId'); + }); + }); + + describe('breadcrumbs', () => { + it('emits a failure breadcrumb for open_positions on error', () => { + const error = new Error('open failed'); + mockUseQuery + .mockReturnValueOnce(makeQueryResult({ error })) + .mockReturnValueOnce(makeQueryResult()); + + renderHook(() => useTraderPositions('trader-1')); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'error', + message: expect.stringContaining( + 'social_service.open_positions.failure', + ), + }), + ); + }); + + it('emits a failure breadcrumb for closed_positions on error', () => { + const error = new Error('closed failed'); + mockUseQuery + .mockReturnValueOnce(makeQueryResult()) + .mockReturnValueOnce(makeQueryResult({ error })); + + renderHook(() => useTraderPositions('trader-1')); + + expect(mockAddBreadcrumb).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'error', + message: expect.stringContaining( + 'social_service.closed_positions.failure', + ), + }), + ); + }); + + it('does not emit a breadcrumb when there are no errors', () => { + renderHook(() => useTraderPositions('trader-1')); + expect(mockAddBreadcrumb).not.toHaveBeenCalled(); + }); + + it('never includes addressOrId in breadcrumb data', () => { + const error = new Error('open failed'); + mockUseQuery + .mockReturnValueOnce(makeQueryResult({ error })) + .mockReturnValueOnce(makeQueryResult()); + + renderHook(() => useTraderPositions('0xSensitiveAddress')); + + mockAddBreadcrumb.mock.calls.forEach(([breadcrumb]) => { + const serialised = JSON.stringify(breadcrumb); + expect(serialised).not.toContain('0xSensitiveAddress'); + }); + }); }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.ts b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.ts index 2c47af1ae38..d083ee59c2f 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.ts +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderPositions.ts @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { useSelector } from 'react-redux'; import { useQuery } from '@metamask/react-data-query'; import type { PositionsResponse, @@ -6,6 +7,13 @@ import type { Position, } from '@metamask/social-controllers'; import Logger from '../../../../../util/Logger'; +import { + addSocialBreadcrumb, + buildSocialErrorExtras, + categoriseSocialError, + extractHttpStatus, +} from '../../../../../util/social/socialServiceTelemetry'; +import { selectIsUnlocked } from '../../../../../selectors/keyringController'; const EMPTY_POSITIONS: Position[] = []; @@ -25,6 +33,7 @@ export const useTraderPositions = ( addressOrId: string, options?: UseTraderPositionsOptions, ): UseTraderPositionsResult => { + const isUnlocked = useSelector(selectIsUnlocked); const fetchOptions: FetchPositionsOptions = { addressOrId }; const { @@ -33,7 +42,7 @@ export const useTraderPositions = ( error: openError, } = useQuery({ queryKey: ['SocialService:fetchOpenPositions', fetchOptions], - enabled: Boolean(addressOrId), + enabled: Boolean(addressOrId) && isUnlocked, refetchInterval: options?.refetchInterval, }); @@ -43,22 +52,49 @@ export const useTraderPositions = ( error: closedError, } = useQuery({ queryKey: ['SocialService:fetchClosedPositions', fetchOptions], - enabled: Boolean(addressOrId), + enabled: Boolean(addressOrId) && isUnlocked, }); const openPositions = openData?.positions ?? EMPTY_POSITIONS; const closedPositions = closedData?.positions ?? EMPTY_POSITIONS; - const combinedError = openError ?? closedError; + useEffect(() => { + if (openError) { + Logger.error( + openError as Error, + buildSocialErrorExtras({ + legacyMessage: 'useTraderPositions: positions fetch failed', + endpoint: 'open_positions', + error: openError, + }), + ); + addSocialBreadcrumb({ + endpoint: 'open_positions', + errorCategory: categoriseSocialError(openError), + httpStatus: extractHttpStatus(openError), + }); + } + }, [openError]); useEffect(() => { - if (combinedError) { + if (closedError) { Logger.error( - combinedError as Error, - 'useTraderPositions: positions fetch failed', + closedError as Error, + buildSocialErrorExtras({ + legacyMessage: 'useTraderPositions: positions fetch failed', + endpoint: 'closed_positions', + error: closedError, + }), ); + addSocialBreadcrumb({ + endpoint: 'closed_positions', + errorCategory: categoriseSocialError(closedError), + httpStatus: extractHttpStatus(closedError), + }); } - }, [combinedError]); + }, [closedError]); + + const combinedError = openError ?? closedError; return { openPositions, diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderProfile.test.ts b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderProfile.test.ts index cf59e980a14..1091180d52e 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderProfile.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderProfile.test.ts @@ -3,10 +3,15 @@ import { useSelector } from 'react-redux'; import { useQuery } from '@metamask/react-data-query'; import Engine from '../../../../../core/Engine'; import Logger from '../../../../../util/Logger'; +import { selectIsUnlocked } from '../../../../../selectors/keyringController'; import { useTraderProfile } from './useTraderProfile'; jest.mock('react-redux', () => ({ - useSelector: jest.fn().mockReturnValue([]), + useSelector: jest.fn(), +})); + +jest.mock('../../../../../selectors/keyringController', () => ({ + selectIsUnlocked: jest.fn(), })); jest.mock('../../../../../selectors/socialController', () => ({ @@ -66,7 +71,10 @@ describe('useTraderProfile', () => { beforeEach(() => { jest.clearAllMocks(); mockUseQuery.mockReturnValue(makeQueryResult()); - mockUseSelector.mockReturnValue([]); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsUnlocked) return true; + return []; // default for other selectors (e.g. selectFollowingProfileIds) + }); }); describe('query configuration', () => { @@ -83,7 +91,7 @@ describe('useTraderProfile', () => { ); }); - it('enables the query when addressOrId is non-empty', () => { + it('enables the query when addressOrId is non-empty and wallet is unlocked', () => { renderHook(() => useTraderProfile('trader-1')); expect(mockUseQuery).toHaveBeenCalledWith( @@ -98,6 +106,18 @@ describe('useTraderProfile', () => { expect.objectContaining({ enabled: false }), ); }); + + it('disables the query when wallet is locked', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsUnlocked) return false; + return []; + }); + renderHook(() => useTraderProfile('trader-1')); + + expect(mockUseQuery).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); }); describe('profile data', () => { @@ -180,7 +200,10 @@ describe('useTraderProfile', () => { }); it('seeds isFollowing true when traderId is in followingProfileIds', () => { - mockUseSelector.mockReturnValue(['trader-1']); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsUnlocked) return true; + return ['trader-1']; + }); const { result } = renderHook(() => useTraderProfile('trader-1')); expect(result.current.isFollowing).toBe(true); }); @@ -199,7 +222,10 @@ describe('useTraderProfile', () => { }); it('calls unfollowTrader when currently following', async () => { - mockUseSelector.mockReturnValue(['trader-1']); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsUnlocked) return true; + return ['trader-1']; + }); const { result } = renderHook(() => useTraderProfile('trader-1')); await act(async () => { diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderProfile.ts b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderProfile.ts index a67d7e15e96..c96d0a8df3e 100644 --- a/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderProfile.ts +++ b/app/components/Views/SocialLeaderboard/TraderProfileView/hooks/useTraderProfile.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect } from 'react'; +import { useSelector } from 'react-redux'; import { useQuery } from '@metamask/react-data-query'; import type { TraderProfileResponse, @@ -6,6 +7,7 @@ import type { } from '@metamask/social-controllers'; import Logger from '../../../../../util/Logger'; import { useFollowToggle } from '../../../../hooks/useFollowToggle'; +import { selectIsUnlocked } from '../../../../../selectors/keyringController'; export interface UseTraderProfileOptions { refetchInterval?: number; @@ -24,6 +26,7 @@ export const useTraderProfile = ( addressOrId: string, options?: UseTraderProfileOptions, ): UseTraderProfileResult => { + const isUnlocked = useSelector(selectIsUnlocked); const fetchOptions: FetchTraderProfileOptions = { addressOrId }; const queryKey: [string, FetchTraderProfileOptions] = [ @@ -33,7 +36,7 @@ export const useTraderProfile = ( const { data, isLoading, error, refetch } = useQuery({ queryKey, - enabled: Boolean(addressOrId), + enabled: Boolean(addressOrId) && isUnlocked, refetchInterval: options?.refetchInterval, }); diff --git a/app/components/Views/TermsAndConditions/TermsAndConditions.testIds.ts b/app/components/Views/TermsAndConditions/TermsAndConditions.testIds.ts new file mode 100644 index 00000000000..9c31a0df993 --- /dev/null +++ b/app/components/Views/TermsAndConditions/TermsAndConditions.testIds.ts @@ -0,0 +1,3 @@ +export const TermsAndConditionsSelectorsIDs = { + ACCEPT_BUTTON: 'terms-and-conditions-button-id', +}; diff --git a/app/components/Views/TermsAndConditions/index.js b/app/components/Views/TermsAndConditions/index.js index fe14c90100c..1dbad54b9f3 100644 --- a/app/components/Views/TermsAndConditions/index.js +++ b/app/components/Views/TermsAndConditions/index.js @@ -1,12 +1,11 @@ import React, { PureComponent } from 'react'; -import { Text, StyleSheet, TouchableOpacity, Platform } from 'react-native'; +import { Text, StyleSheet, TouchableOpacity } from 'react-native'; import PropTypes from 'prop-types'; import { fontStyles } from '../../../styles/common'; import { strings } from '../../../../locales/i18n'; import AppConstants from '../../../core/AppConstants'; import { ThemeContext, mockTheme } from '../../../util/theme'; -import generateTestId from '../../../../wdio/utils/generateTestId'; -import { TERMS_AND_CONDITIONS_BUTTON_ID } from '../../../../wdio/screen-objects/testIDs/Components/TermsAndConditions.testIds'; +import { TermsAndConditionsSelectorsIDs } from './TermsAndConditions.testIds'; const createStyles = (colors) => StyleSheet.create({ @@ -49,7 +48,7 @@ export default class TermsAndConditions extends PureComponent { return ( diff --git a/app/components/Views/TrendingView/ExplorePageV1.tsx b/app/components/Views/TrendingView/ExplorePageV1.tsx new file mode 100644 index 00000000000..f336899246e --- /dev/null +++ b/app/components/Views/TrendingView/ExplorePageV1.tsx @@ -0,0 +1,268 @@ +import React, { useCallback, useMemo } from 'react'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { Box } from '@metamask/design-system-react-native'; +import type { ListRenderItem } from '@shopify/flash-list'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import type { PredictMarket as PredictMarketType } from '../../UI/Predict/types'; +import type { AppNavigationProp } from '../../../core/NavigationService/types'; +import type { PerpsNavigationParamList } from '../../UI/Perps/types/navigation'; +import type { SiteData } from '../../UI/Sites/components/SiteRowItem/SiteRowItem'; +import { selectPerpsEnabledFlag } from '../../UI/Perps'; +import { selectPredictEnabledFlag } from '../../UI/Predict'; +import Routes from '../../../constants/navigation/Routes'; +import { strings } from '../../../../locales/i18n'; +import { TokenDetailsSource } from '../../UI/TokenDetails/constants/constants'; +import { useTokensFeed } from './feeds/tokens/useTokensFeed'; +import { TokenRowItem } from './feeds/tokens/TokenRowItem'; +import TrendingTokensSkeleton from '../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; +import { usePerpsFeed, type PerpsFeedItem } from './feeds/perps/usePerpsFeed'; +import PerpsSectionProvider from './feeds/perps/PerpsSectionProvider'; +import PerpsTileRowItem from './feeds/perps/PerpsTileRowItem'; +import PerpsMarketTileCardSkeleton from '../Homepage/Sections/Perpetuals/components/PerpsMarketTileCardSkeleton'; +import { navigateToPerpsMarketList } from './feeds/perps/perpsNavigation'; +import { usePredictionsFeed } from './feeds/predictions/usePredictionsFeed'; +import { PredictionCarouselRowItem } from './feeds/predictions/PredictionRowItem'; +import PredictionsSkeleton from './feeds/predictions/PredictionsSkeleton'; +import { navigateToPredictionsList } from './feeds/predictions/predictionsNavigation'; +import { useStocksFeed } from './feeds/stocks/useStocksFeed'; +import { SiteRowItem } from './feeds/sites/SiteRowItem'; +import SiteSkeleton from '../../UI/Sites/components/SiteSkeleton/SiteSkeleton'; +import { useSitesFeed } from './feeds/sites/useSitesFeed'; +import CardList from './components/CardList'; +import QuickActions, { type SectionId } from './components/QuickActions'; +import ExploreScroll from './components/ExploreScroll'; +import HorizontalCarousel from './components/HorizontalCarousel'; +import SectionHeader from './components/SectionHeader'; +import TileCarousel from './components/TileCarousel'; +import type { TabProps } from './hooks/useExploreRefresh'; +import { TrendingViewSelectorsIDs } from './TrendingView.testIds'; + +interface ExploreV1PerpsBlockProps { + refresh: TabProps['refresh']; + navigation: NavigationProp; +} + +const ExploreV1PerpsBlock: React.FC = ({ + refresh, + navigation, +}) => { + const perps = usePerpsFeed({ + variant: 'all', + refresh, + withTileExtras: true, + }); + + if (!perps.isLoading && perps.data.length === 0) { + return null; + } + + return ( + + navigateToPerpsMarketList(navigation)} + testID="section-header-view-all-perps" + /> + + data={perps.data} + isLoading={perps.isLoading} + renderItem={(item) => ( + + )} + keyExtractor={(item) => item.market.symbol} + Skeleton={PerpsMarketTileCardSkeleton} + onViewMore={() => navigateToPerpsMarketList(navigation)} + testID="explore-perps-carousel" + viewMoreTestID="perps-view-more-card" + /> + + ); +}; + +/** + * Legacy Explore layout: stacked sections in a single scroll (pre–tabbed Explore V2). + */ +const ExplorePageV1: React.FC = ({ + refresh, + refreshing, + onRefresh, +}) => { + const navigation = useNavigation(); + const perpsNavigation = + useNavigation>(); + const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); + const isPredictEnabled = useSelector(selectPredictEnabledFlag); + + const predictions = usePredictionsFeed({ refresh }); + const tokens = useTokensFeed({ refresh }); + const stocks = useStocksFeed({ refresh }); + const sites = useSitesFeed({ refresh }); + + const renderPredictionItem: ListRenderItem = useCallback( + ({ item }) => ( + + ), + [], + ); + + const renderTrendingTokenItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + ), + [], + ); + + const renderStockItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + ), + [], + ); + + const renderSite: ListRenderItem = useCallback( + ({ item }) => , + [], + ); + + const showPredictions = + isPredictEnabled && (predictions.isLoading || predictions.data.length > 0); + const showTrendingTokens = tokens.isLoading || tokens.data.length > 0; + const showStocks = stocks.isLoading || stocks.data.length > 0; + const showSites = sites.isLoading || sites.data.length > 0; + + const quickActionsEmptySections = useMemo(() => { + const empty = new Set(); + if (!showTrendingTokens) { + empty.add('tokens'); + } + if (!isPerpsEnabled) { + empty.add('perps'); + } + if (!showStocks) { + empty.add('stocks'); + } + if (!showPredictions) { + empty.add('predictions'); + } + if (!showSites) { + empty.add('sites'); + } + return empty; + }, [ + showTrendingTokens, + isPerpsEnabled, + showStocks, + showPredictions, + showSites, + ]); + + return ( + + + + + {showPredictions && ( + + + navigateToPredictionsList(navigation, 'trending') + } + testID="section-header-view-all-predictions" + /> + + data={predictions.data} + isLoading={predictions.isLoading} + renderItem={renderPredictionItem} + Skeleton={PredictionsSkeleton} + idPrefix="explore_v1_predictions" + /> + + )} + + {showTrendingTokens && ( + + + navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW) + } + testID="section-header-view-all-tokens" + /> + + data={tokens.data} + isLoading={tokens.isLoading} + renderItem={renderTrendingTokenItem} + Skeleton={TrendingTokensSkeleton} + idPrefix="explore_v1_tokens" + /> + + )} + + {isPerpsEnabled && ( + + + + )} + + {showStocks && ( + + + navigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW) + } + testID="section-header-view-all-stocks" + /> + + data={stocks.data} + isLoading={stocks.isLoading} + renderItem={renderStockItem} + Skeleton={TrendingTokensSkeleton} + idPrefix="explore_v1_stocks" + /> + + )} + + {showSites && ( + + navigation.navigate(Routes.SITES_FULL_VIEW)} + testID="section-header-view-all-sites" + /> + + data={sites.data} + isLoading={sites.isLoading} + renderItem={renderSite} + Skeleton={SiteSkeleton} + idPrefix="explore_v1_sites" + /> + + )} + + + ); +}; + +export default ExplorePageV1; diff --git a/app/components/Views/TrendingView/TrendingView.test.tsx b/app/components/Views/TrendingView/TrendingView.test.tsx index c06ca4c16bb..a6854d1a823 100644 --- a/app/components/Views/TrendingView/TrendingView.test.tsx +++ b/app/components/Views/TrendingView/TrendingView.test.tsx @@ -39,6 +39,7 @@ import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetwork import { selectEnabledNetworksByNamespace } from '../../../selectors/networkEnablementController'; import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts'; import { selectBasicFunctionalityEnabled } from '../../../selectors/settings'; +import { selectExplorePageV2EnabledFlag } from '../../../selectors/featureFlagController/explorePageV2'; import { useSelector } from 'react-redux'; import Routes from '../../../constants/navigation/Routes'; @@ -128,6 +129,7 @@ describe('TrendingView', () => { overrides: { browserTabsCount?: number; basicFunctionalityEnabled?: boolean; + explorePageV2Enabled?: boolean; } = {}, ) => (selector: unknown) => { @@ -172,6 +174,9 @@ describe('TrendingView', () => { if (selector === selectBasicFunctionalityEnabled) { return overrides.basicFunctionalityEnabled ?? true; } + if (selector === selectExplorePageV2EnabledFlag) { + return overrides.explorePageV2Enabled ?? true; + } if (selector === selectSelectedInternalAccountByScope) { return (_scope: string) => null; } @@ -381,5 +386,22 @@ describe('TrendingView', () => { expect(getByTestId('basic-functionality-empty-state')).toBeOnTheScreen(); }); + + it('renders Explore Page V1 when Explore Page V2 flag is disabled', () => { + mockUseSelector.mockImplementation( + createMockSelectorImplementation({ + explorePageV2Enabled: false, + basicFunctionalityEnabled: true, + }), + ); + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('explore-page-v1')).toBeOnTheScreen(); + }); }); }); diff --git a/app/components/Views/TrendingView/TrendingView.testIds.ts b/app/components/Views/TrendingView/TrendingView.testIds.ts index 7ad98bcb25f..f4c3d88bc20 100644 --- a/app/components/Views/TrendingView/TrendingView.testIds.ts +++ b/app/components/Views/TrendingView/TrendingView.testIds.ts @@ -1,16 +1,17 @@ export const TrendingViewSelectorsIDs = { TRENDING_FEED_SCROLL_VIEW: 'trending-feed-scroll-view', - QUICK_ACTIONS_SCROLL_VIEW: 'quick-actions-scroll-view', EXPLORE_HEADER_ROOT: 'explore-header-root', EXPLORE_SAFE_AREA: 'explore-safe-area', SECTION_HEADER_VIEW_ALL_TOKENS: 'section-header-view-all-tokens', TRENDING_TOKENS_HEADER: 'trending-tokens-header', EXPLORE_VIEW_SEARCH_BUTTON: 'explore-view-search-button', EXPLORE_VIEW_SEARCH_INPUT: 'explore-view-search-input', + EXPLORE_VIEW_SEARCH_TEXT_INPUT: 'explore-view-search-text-input', TRENDING_SEARCH_RESULTS_LIST: 'trending-search-results-list', ALL_NETWORKS_BUTTON: 'all-networks-button', CLOSE_BUTTON: 'close-button', TRENDING_TOKENS_HEADER_SEARCH_TOGGLE: 'trending-tokens-header-search-toggle', + QUICK_ACTIONS_SCROLL_VIEW: 'quick-actions-scroll-view', } as const; export type TrendingViewSelectorsIDsType = typeof TrendingViewSelectorsIDs; diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx index 6968ab420ae..6d42545cb2f 100644 --- a/app/components/Views/TrendingView/TrendingView.tsx +++ b/app/components/Views/TrendingView/TrendingView.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { ScrollView, TouchableOpacity, RefreshControl } from 'react-native'; +import React, { useCallback, useEffect, useRef } from 'react'; +import { TouchableOpacity } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; @@ -13,114 +13,54 @@ import { IconSize, } from '@metamask/design-system-react-native'; import HeaderRoot from '../../../component-library/components-temp/HeaderRoot'; +import TabsList from '../../../component-library/components-temp/Tabs/TabsList/TabsList'; +import { TabViewProps } from '../../../component-library/components-temp/Tabs/TabsList/TabsList.types'; import { strings } from '../../../../locales/i18n'; import AppConstants from '../../../core/AppConstants'; import { useBuildPortfolioUrl } from '../../hooks/useBuildPortfolioUrl'; -import { useTheme } from '../../../util/theme'; import Routes from '../../../constants/navigation/Routes'; -import ExploreSearchBar from './components/ExploreSearchBar/ExploreSearchBar'; -import QuickActions from './components/QuickActions/QuickActions'; -import SectionHeader from './components/SectionHeader/SectionHeader'; -import { useHomeSections, SectionId } from './sections.config'; import { selectBasicFunctionalityEnabled } from '../../../selectors/settings'; +import { selectExplorePageV2EnabledFlag } from '../../../selectors/featureFlagController/explorePageV2'; import BasicFunctionalityEmptyState from '../../UI/BasicFunctionality/BasicFunctionalityEmptyState/BasicFunctionalityEmptyState'; import TrendingFeedSessionManager from '../../UI/Trending/services/TrendingFeedSessionManager'; -import Section, { RefreshConfig } from './components/Sections/Section'; +import ExploreSearchBar from './components/ExploreSearchBar/ExploreSearchBar'; +import { useExploreRefresh } from './hooks/useExploreRefresh'; +import NowTab from './tabs/NowTab'; +import MacroTab from './tabs/MacroTab'; +import RwasTab from './tabs/RwasTab'; +import CryptoTab from './tabs/CryptoTab'; +import SportsTab from './tabs/SportsTab'; +import DappsTab from './tabs/DappsTab'; import { TrendingViewSelectorsIDs } from './TrendingView.testIds'; - -const curriedSetSectionState = - (setState: (updater: (prev: Set) => Set) => void) => - (sectionId: SectionId) => - (isActive: boolean): void => { - setState((prev) => { - const newSet = new Set(prev); - - if (isActive) { - newSet.add(sectionId); - } else { - newSet.delete(sectionId); - } - - return newSet; - }); - }; - -/** - * Custom hook to track boolean state for each section - * Returns the Set of sections with that state and callbacks to update them - */ -const useSectionStateTracker = ( - sections: { id: SectionId }[], -): { - sectionsWithState: Set; - callbacks: Record void>; -} => { - const [activeSections, setActiveSections] = useState>( - new Set(), - ); - - const callbacks = useMemo(() => { - const result = {} as Record void>; - sections.forEach((s) => { - result[s.id] = curriedSetSectionState(setActiveSections)(s.id); - }); - return result; - }, [sections]); - - return { sectionsWithState: activeSections, callbacks }; -}; +import ExplorePageV1 from './ExplorePageV1'; +import { + trackExploreInteracted, + type ExploreTabName, +} from './search/analytics'; + +const TAB_NAMES: ExploreTabName[] = [ + 'Now', + 'Macro', + 'RWAs', + 'Crypto', + 'Sports', + 'Sites', +]; export const ExploreFeed: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); const buildPortfolioUrlWithMetrics = useBuildPortfolioUrl(); - const { colors } = useTheme(); - const [refreshing, setRefreshing] = useState(false); - const [refreshConfig, setRefreshConfig] = useState({ - trigger: 0, - silentRefresh: true, - }); - - const homeSections = useHomeSections(); - - // Track which sections have empty data (for QuickActions empty state) - const { sectionsWithState: emptySections, callbacks: emptyStateCallbacks } = - useSectionStateTracker(homeSections); - - const noopLoadingState = useCallback((_isLoading: boolean) => undefined, []); + const tabProps = useExploreRefresh(); const sessionManager = TrendingFeedSessionManager.getInstance(); - // REMOVED FOR NOW (https://consensys.slack.com/archives/C07NF2K42LE/p1766152712027759?thread_ts=1766135783.241539&cid=C07NF2K42LE) - // Trigger refresh only when navigating to an already-mounted screen - // useEffect(() => { - // const params = route.params as { refresh?: boolean } | undefined; - - // // Skip refresh on first mount - // if (isFirstMount.current) { - // isFirstMount.current = false; - // return; - // } - - // if (params?.refresh === true) { - // // Silent refresh - don't show skeletons - // setRefreshConfig((prev) => ({ - // trigger: prev.trigger + 1, - // silentRefresh: false, - // })); - // } - // }, [route.params]); - // Initialize session and enable AppState listener on mount useEffect(() => { - // Enable AppState listener to detect app backgrounding sessionManager.enableAppStateListener(); - - // Start session sessionManager.startSession('trending_feed'); return () => { - // End session and disable listener on unmount sessionManager.endSession(); sessionManager.disableAppStateListener(); }; @@ -134,10 +74,10 @@ export const ExploreFeed: React.FC = () => { const isBasicFunctionalityEnabled = useSelector( selectBasicFunctionalityEnabled, ); + const isExplorePageV2Enabled = useSelector(selectExplorePageV2EnabledFlag); const handleBrowserPress = useCallback(() => { if (browserTabsCount > 0) { - // If tabs exist, show the tabs view directly navigation.navigate(Routes.BROWSER.HOME, { screen: Routes.BROWSER.VIEW, params: { @@ -147,7 +87,6 @@ export const ExploreFeed: React.FC = () => { }, }); } else { - // If no tabs exist, open a new tab with portfolio URL navigation.navigate(Routes.BROWSER.HOME, { screen: Routes.BROWSER.VIEW, params: { @@ -163,24 +102,17 @@ export const ExploreFeed: React.FC = () => { navigation.navigate(Routes.EXPLORE_SEARCH); }, [navigation]); - // Clean up timeout when component unmounts or refreshing changes - useEffect(() => { - if (refreshing) { - const timeoutId = setTimeout(() => { - setRefreshing(false); - }, 1000); - - return () => clearTimeout(timeoutId); - } - }, [refreshing]); + const previousTabRef = useRef('Now'); - const handleRefresh = useCallback(() => { - setRefreshing(true); - // Pull-to-refresh - show skeletons - setRefreshConfig((prev) => ({ - trigger: prev.trigger + 1, - silentRefresh: true, - })); + const handleTabChange = useCallback(({ i }: { i: number }) => { + const destinationTab = TAB_NAMES[i]; + if (!destinationTab) return; + trackExploreInteracted({ + interaction_type: 'tab_switched', + tab_name: destinationTab, + previous_tab: previousTabRef.current, + }); + previousTabRef.current = destinationTab; }, []); return ( @@ -195,7 +127,7 @@ export const ExploreFeed: React.FC = () => { /> - + @@ -214,54 +146,66 @@ export const ExploreFeed: React.FC = () => { - {isBasicFunctionalityEnabled ? ( - - } + {!isBasicFunctionalityEnabled ? ( + + ) : isExplorePageV2Enabled ? ( + - - - {homeSections.map((section) => { - // Hide section visually but keep mounted so it can report when data arrives - const isHidden = emptySections.has(section.id); - - const sectionComponent = ( -
- ); - - return ( - - - {section.SectionWrapper ? ( - - {sectionComponent} - - ) : ( - sectionComponent - )} - - ); - })} - + + + + + + + + + + + + + + + + + + + ) : ( - + )} diff --git a/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.tsx b/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.tsx index e86004ebc74..313d1b69bc6 100644 --- a/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.tsx +++ b/app/components/Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen.tsx @@ -1,12 +1,11 @@ -import React, { useState, useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { Keyboard, Platform } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { Box } from '@metamask/design-system-react-native'; import ExploreSearchBar from '../../components/ExploreSearchBar/ExploreSearchBar'; -import ExploreSearchResults from '../../components/ExploreSearchResults/ExploreSearchResults'; -import { PerpsConnectionProvider } from '../../../../UI/Perps/providers/PerpsConnectionProvider'; -import { PerpsStreamProvider } from '../../../../UI/Perps/providers/PerpsStreamManager'; +import ExploreSearchResults from '../../search/ExploreSearchResults'; +import PerpsSectionProvider from '../../feeds/perps/PerpsSectionProvider'; const ExploreSearchScreen: React.FC = () => { const insets = useSafeAreaInsets(); @@ -33,11 +32,9 @@ const ExploreSearchScreen: React.FC = () => { /> - - - - - + + + ); }; diff --git a/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.test.tsx b/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.test.tsx index ec4a8862c9b..d4eaeb07594 100644 --- a/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.test.tsx +++ b/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.test.tsx @@ -14,12 +14,12 @@ const mockTokenData = [ ]; const mockRouteParams: { - sectionId: string; + feedId: string; title: string; searchQuery: string; data: unknown[]; } = { - sectionId: 'tokens', + feedId: 'tokens', title: 'Trending tokens', searchQuery: 'bitcoin', data: mockTokenData, @@ -63,34 +63,53 @@ const mockCreateEventBuilder = typeof AnalyticsEventBuilder.createEventBuilder >; -jest.mock('../../sections.config', () => { +// Replace the search row dispatcher with a stub that exposes the item id so +// taps on a specific row are testable. +jest.mock('../../search/SearchFeedRow', () => { const { View } = jest.requireActual('react-native'); - const MockRowItem = ({ item }: { item: unknown }) => ( - - ); - + const TapView = jest.requireActual('../../search/TapView').default; return { - SECTIONS_CONFIG: { - tokens: { - id: 'tokens', - title: 'Trending tokens', - RowItem: MockRowItem, - getItemIdentifier: (item: unknown) => - (item as { assetId: string }).assetId, - }, + __esModule: true, + default: ({ + item, + sectionTitle, + searchQuery, + interactionType, + }: { + item: { assetId: string }; + sectionTitle: string; + searchQuery: string; + interactionType: string; + }) => { + const { trackExploreEvent } = jest.requireActual( + '../../search/analytics', + ); + const { MetaMetricsEvents } = jest.requireActual( + '../../../../../core/Analytics/MetaMetrics.events', + ); + return ( + + trackExploreEvent(MetaMetricsEvents.EXPLORE_SEARCH_INTERACTED, { + interaction_type: interactionType, + search_query: searchQuery, + section_name: sectionTitle, + item_clicked: item.assetId, + }) + } + > + + + ); }, + SearchFeedSkeleton: () => , }; }); -jest.mock( - '../../../../UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem', - () => () => null, -); - describe('ExploreSectionResultsFullView', () => { beforeEach(() => { jest.clearAllMocks(); - mockRouteParams.sectionId = 'tokens'; + mockRouteParams.feedId = 'tokens'; mockRouteParams.title = 'Trending tokens'; mockRouteParams.searchQuery = 'bitcoin'; mockRouteParams.data = mockTokenData; @@ -104,21 +123,17 @@ describe('ExploreSectionResultsFullView', () => { it('renders the title from route params', () => { const { getByText } = render(); - expect(getByText('Trending tokens')).toBeOnTheScreen(); }); it('navigates back when back button is pressed', () => { const { getByLabelText } = render(); - fireEvent.press(getByLabelText('Go back')); - expect(mockGoBack).toHaveBeenCalledTimes(1); }); it('renders all items from the section data', () => { const { getByTestId } = render(); - expect(getByTestId('row-item-1')).toBeOnTheScreen(); expect(getByTestId('row-item-2')).toBeOnTheScreen(); expect(getByTestId('row-item-3')).toBeOnTheScreen(); @@ -127,9 +142,7 @@ describe('ExploreSectionResultsFullView', () => { it('renders empty list when section data is empty', () => { mockRouteParams.data = []; - const { queryByTestId } = render(); - expect(queryByTestId('row-item-1')).toBeNull(); }); diff --git a/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.tsx b/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.tsx index 3652de9d111..5f0ca2f6764 100644 --- a/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.tsx +++ b/app/components/Views/TrendingView/Views/ExploreSectionResultsFullView/ExploreSectionResultsFullView.tsx @@ -21,46 +21,57 @@ import { BoxAlignItems, FontWeight, } from '@metamask/design-system-react-native'; -import { SECTIONS_CONFIG, type SectionId } from '../../sections.config'; -import { TrackedRowItem, useScrollTracking } from '../../utils/exploreSearch'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import type { PerpsMarketData } from '@metamask/perps-controller'; +import type { PredictMarket as PredictMarketType } from '../../../../UI/Predict/types'; +import type { SiteData } from '../../../../UI/Sites/components/SiteRowItem/SiteRowItem'; +import PerpsSectionProvider from '../../feeds/perps/PerpsSectionProvider'; +import SearchFeedRow from '../../search/SearchFeedRow'; +import { useScrollTracking } from '../../search/analytics'; +import type { SearchFeedId } from '../../search/useExploreSearch'; -interface SectionContentProps { - sectionId: SectionId; +const SectionContent: React.FC<{ + feedId: SearchFeedId; searchQuery: string; data: unknown[]; -} - -const SectionContent: React.FC = ({ - sectionId, - searchQuery, - data, -}) => { + title: string; +}> = ({ feedId, searchQuery, data, title }) => { const tw = useTailwind(); - const section = SECTIONS_CONFIG[sectionId]; - const { onScrollBeginDrag } = useScrollTracking( 'view_all_scrolled', searchQuery, - { section_name: section.title }, + { section_name: title }, ); const renderItem: ListRenderItem = useCallback( ({ item, index }) => ( - ), - [section, searchQuery], + [feedId, searchQuery, title], ); const keyExtractor = useCallback( - (item: unknown, index: number) => - `${sectionId}-${section.getItemIdentifier(item) || index}`, - [sectionId, section], + (item: unknown, index: number) => { + switch (feedId) { + case 'tokens': + case 'stocks': + return `${feedId}-${(item as TrendingAsset).assetId ?? index}`; + case 'perps': + return `${feedId}-${(item as PerpsMarketData).symbol ?? index}`; + case 'predictions': + return `${feedId}-${(item as PredictMarketType).id ?? index}`; + case 'sites': + return `${feedId}-${(item as SiteData).url ?? index}`; + } + }, + [feedId], ); return ( @@ -81,9 +92,8 @@ const ExploreSectionResultsFullView: React.FC = () => { const route = useRoute>(); - const { sectionId, title, searchQuery, data } = route.params; - const section = SECTIONS_CONFIG[sectionId]; - const Wrapper = section.SectionWrapper ?? React.Fragment; + const { feedId, title, searchQuery, data } = route.params; + const Wrapper = feedId === 'perps' ? PerpsSectionProvider : React.Fragment; const handleGoBack = useCallback(() => { navigation.goBack(); @@ -112,9 +122,10 @@ const ExploreSectionResultsFullView: React.FC = () => { diff --git a/app/components/Views/TrendingView/components/CardList.test.tsx b/app/components/Views/TrendingView/components/CardList.test.tsx new file mode 100644 index 00000000000..9cf76f19d15 --- /dev/null +++ b/app/components/Views/TrendingView/components/CardList.test.tsx @@ -0,0 +1,72 @@ +jest.mock('@shopify/flash-list', () => { + const RN = jest.requireActual('react-native'); + return { FlashList: RN.FlatList }; +}); + +import React from 'react'; +import { Text } from 'react-native'; +import { render } from '@testing-library/react-native'; +import type { ListRenderItem } from '@shopify/flash-list'; +import CardList from './CardList'; + +interface Row { + id: string; +} + +const Skeleton = () => sk; + +const renderItem: ListRenderItem = ({ item }) => ( + {item.id} +); + +describe('CardList', () => { + it('renders skeleton rows while loading and hides the list', () => { + const { getAllByTestId, queryByTestId } = render( + + data={[{ id: 'a' }, { id: 'b' }]} + isLoading + renderItem={renderItem} + Skeleton={Skeleton} + idPrefix="pfx" + listTestId="card-list-flash" + />, + ); + + expect(getAllByTestId('card-skeleton')).toHaveLength(3); + expect(queryByTestId('card-list-flash')).toBeNull(); + }); + + it('respects skeletonCount while loading', () => { + const { getAllByTestId } = render( + + data={[]} + isLoading + renderItem={renderItem} + Skeleton={Skeleton} + idPrefix="pfx" + skeletonCount={2} + />, + ); + expect(getAllByTestId('card-skeleton')).toHaveLength(2); + }); + + it('slices data to max and renders rows when not loading', () => { + const { getByTestId, queryByTestId } = render( + + data={[{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]} + isLoading={false} + max={2} + renderItem={renderItem} + Skeleton={Skeleton} + idPrefix="pfx" + listTestId="card-list-flash" + />, + ); + + expect(getByTestId('card-list-flash')).toBeTruthy(); + expect(getByTestId('row-1')).toBeTruthy(); + expect(getByTestId('row-2')).toBeTruthy(); + expect(queryByTestId('row-3')).toBeNull(); + expect(queryByTestId('row-4')).toBeNull(); + }); +}); diff --git a/app/components/Views/TrendingView/components/CardList.tsx b/app/components/Views/TrendingView/components/CardList.tsx new file mode 100644 index 00000000000..ec0c06c3ad1 --- /dev/null +++ b/app/components/Views/TrendingView/components/CardList.tsx @@ -0,0 +1,65 @@ +import React, { useMemo } from 'react'; +import { StyleSheet } from 'react-native'; +import { FlashList, ListRenderItem } from '@shopify/flash-list'; +import Card from '../../../../component-library/components/Cards/Card'; + +const DEFAULT_MAX_ITEMS = 3; + +const styles = StyleSheet.create({ + cardContainer: { + padding: 0, + marginBottom: 28, + borderWidth: 0, + }, +}); + +export interface CardListProps { + data: T[]; + isLoading: boolean; + renderItem: ListRenderItem; + Skeleton: React.ComponentType; + /** @default 3 */ + max?: number; + /** Stable id for the keyExtractor namespace. */ + idPrefix: string; + listTestId?: string; + /** @default 3 */ + skeletonCount?: number; +} + +/** + * Card-wrapped vertical list rendering up to `max` items via FlashList. + * Shows `Skeleton` (rendered `skeletonCount` times) while loading. + */ +function CardList({ + data, + isLoading, + renderItem, + Skeleton, + max = DEFAULT_MAX_ITEMS, + idPrefix, + listTestId, + skeletonCount = DEFAULT_MAX_ITEMS, +}: CardListProps) { + const displayData = useMemo(() => data.slice(0, max), [data, max]); + + return ( + + {isLoading && + Array.from({ length: skeletonCount }).map((_, i) => ( + + ))} + {!isLoading && ( + `${idPrefix}-${index}`} + keyboardShouldPersistTaps="handled" + testID={listTestId} + /> + )} + + ); +} + +export default CardList; diff --git a/app/components/Views/TrendingView/components/ExplorePill.test.tsx b/app/components/Views/TrendingView/components/ExplorePill.test.tsx new file mode 100644 index 00000000000..9a4ef38fd8e --- /dev/null +++ b/app/components/Views/TrendingView/components/ExplorePill.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { render, fireEvent } from '@testing-library/react-native'; +import { TextColor } from '@metamask/design-system-react-native'; +import ExplorePill from './ExplorePill'; + +describe('ExplorePill', () => { + it('calls onPress when pressed', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + L} + title="SYMBOL" + />, + ); + + fireEvent.press(getByTestId('explore-pill-test')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('renders title and optional change label when non-empty', () => { + const { getByText, queryByText } = render( + L} + title="BTC" + changeLabel="+1.00%" + changeTextColor={TextColor.SuccessDefault} + />, + ); + + expect(getByText('BTC')).toBeTruthy(); + expect(getByText('+1.00%')).toBeTruthy(); + }); + + it('does not render change line when changeLabel is undefined or empty', () => { + const { queryByText, rerender, getByText } = render( + L} + title="ONLY" + />, + ); + expect(getByText('ONLY')).toBeTruthy(); + expect(queryByText('+1.00%')).toBeNull(); + + rerender( + L} + title="ONLY" + changeLabel="" + />, + ); + expect(queryByText('+1.00%')).toBeNull(); + }); +}); diff --git a/app/components/Views/TrendingView/components/ExplorePill.tsx b/app/components/Views/TrendingView/components/ExplorePill.tsx new file mode 100644 index 00000000000..70841bf21e6 --- /dev/null +++ b/app/components/Views/TrendingView/components/ExplorePill.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Pressable } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + BoxBackgroundColor, + BoxFlexDirection, + Text, + TextColor, + TextVariant, + FontWeight, +} from '@metamask/design-system-react-native'; + +export interface ExplorePillProps { + onPress: () => void; + testID: string; + /** Icon or logo on the left (e.g. token logo, with or without a network badge wrapper). */ + leading: React.ReactNode; + title: string; + changeLabel?: string; + changeTextColor?: TextColor; +} + +/** + * Shared horizontal “pill” shell for Explore sections (e.g. Crypto Movers, Perps). + * Visual layout only; callers supply `leading` and press behavior. + */ +const ExplorePill: React.FC = ({ + onPress, + testID, + leading, + title, + changeLabel, + changeTextColor = TextColor.TextAlternative, +}) => { + const tw = useTailwind(); + const showChange = changeLabel !== undefined && changeLabel.length > 0; + + return ( + tw.style('shrink', pressed && 'opacity-80')} + > + + {leading} + + {title} + + {showChange ? ( + + {changeLabel} + + ) : null} + + + ); +}; + +export default React.memo(ExplorePill); diff --git a/app/components/Views/TrendingView/components/ExploreScroll.tsx b/app/components/Views/TrendingView/components/ExploreScroll.tsx new file mode 100644 index 00000000000..54ee4544f71 --- /dev/null +++ b/app/components/Views/TrendingView/components/ExploreScroll.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { ScrollView, RefreshControl } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useTheme } from '../../../../util/theme'; + +interface ExploreScrollProps { + refreshing: boolean; + onRefresh: () => void; + testID?: string; + /** + * When false, omits top padding (legacy Explore V1 sits directly under the + * global search row; tabbed V2 uses default spacing). + */ + includeTopPadding?: boolean; + children: React.ReactNode; +} + +/** + * Vertical ScrollView wrapper for an Explore tab body. Owns horizontal and top + * padding and pull-to-refresh wiring. + */ +const ExploreScroll: React.FC = ({ + refreshing, + onRefresh, + testID, + includeTopPadding = true, + children, +}) => { + const tw = useTailwind(); + const { colors } = useTheme(); + + return ( + + } + > + {children} + + ); +}; + +export default ExploreScroll; diff --git a/app/components/Views/TrendingView/components/ExploreSearchBar/ExploreSearchBar.tsx b/app/components/Views/TrendingView/components/ExploreSearchBar/ExploreSearchBar.tsx index 5c23eef0857..916a49e4275 100644 --- a/app/components/Views/TrendingView/components/ExploreSearchBar/ExploreSearchBar.tsx +++ b/app/components/Views/TrendingView/components/ExploreSearchBar/ExploreSearchBar.tsx @@ -17,6 +17,7 @@ import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; import { selectBasicFunctionalityEnabled } from '../../../../../selectors/settings'; +import { TrendingViewSelectorsIDs } from '../../TrendingView.testIds'; interface ExploreSearchBarButtonProps { type: 'button'; @@ -83,17 +84,23 @@ const ExploreSearchBar: React.FC = (props) => { ) : ( <> - + { props.onSearchChange(''); }} clearButtonProps={{ testID: 'explore-search-clear-button' }} + inputProps={{ + autoCapitalize: 'none', + testID: TrendingViewSelectorsIDs.EXPLORE_VIEW_SEARCH_TEXT_INPUT, + }} /> ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: mockNavigate, - }), -})); - -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn(), -})); - -jest.mock('../../hooks/useExploreSearch'); -const mockUseExploreSearch = useExploreSearch as jest.MockedFunction< - typeof useExploreSearch ->; -const mockUseSelector = useSelector as jest.MockedFunction; - -// Mock child components that render individual items -jest.mock( - '../../../../UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem', - () => () => null, -); - -jest.mock( - '../../../../UI/Perps/components/PerpsMarketRowItem', - () => () => null, -); - -jest.mock('../../../../UI/Predict/components/PredictMarket', () => () => null); - -jest.mock( - '../../../../UI/Predict/components/PredictMarketRowItem', - () => () => null, -); - -jest.mock( - '../../../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter', - () => { - const ReactNative = jest.requireActual('react-native'); - return jest.fn(({ searchQuery }) => - searchQuery ? ( - - {searchQuery} - - ) : null, - ); - }, -); - -describe('ExploreSearchResults', () => { - beforeEach(() => { - jest.clearAllMocks(); - - // Mock selectBasicFunctionalityEnabled to return true by default - mockUseSelector.mockImplementation((selector) => { - if (selector === selectBasicFunctionalityEnabled) { - return true; - } - return undefined; - }); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('renders section headers for sections with data or loading', () => { - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [ - { assetId: '1', symbol: 'BTC', name: 'Bitcoin' }, - { assetId: '2', symbol: 'ETH', name: 'Ethereum' }, - ], - perps: [{ symbol: 'BTC-USD', name: 'Bitcoin' }], - predictions: [ - { - id: '1', - title: 'Will Bitcoin reach 100k?', - outcomes: [ - { - id: 'outcome-1', - status: 'open', - tokens: [{ id: 'token-1', title: 'Yes', price: 0.65 }], - }, - ], - }, - ], - stocks: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - stocks: false, - sites: false, - }, - sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], - }); - - const { getByText, getByTestId } = render( - , - ); - - expect(getByTestId('trending-search-results-list')).toBeOnTheScreen(); - expect(getByText(strings('trending.trending_tokens'))).toBeOnTheScreen(); - expect(getByText('Perps')).toBeOnTheScreen(); - expect(getByText('Predictions')).toBeOnTheScreen(); - }); - - it('only shows sections with data or loading state', () => { - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }], - perps: [], - predictions: [], - stocks: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - stocks: false, - sites: false, - }, - sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], - }); - - const { getByText, queryByText } = render( - , - ); - - expect(getByText(strings('trending.trending_tokens'))).toBeOnTheScreen(); - expect(queryByText('Perps')).toBeNull(); - expect(queryByText('Predictions')).toBeNull(); - }); - - it('passes search query to useExploreSearch hook', () => { - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [], - perps: [], - predictions: [], - stocks: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - stocks: false, - sites: false, - }, - sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], - }); - - render(); - - expect(mockUseExploreSearch).toHaveBeenCalledWith('ethereum'); - }); - - describe('Footer', () => { - // Note: FlashList's ListFooterComponent doesn't render in test environment, - // so we verify the list renders correctly with the search query prop. - // The actual footer rendering is tested via the SitesSearchFooter unit tests. - - it('renders list with search query that would trigger footer', () => { - // Arrange - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }], - perps: [], - predictions: [], - stocks: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - stocks: false, - sites: false, - }, - sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], - }); - - // Act - const { getByTestId, getByText } = render( - , - ); - - // Assert - FlashList renders with data and search query is passed to hook - expect(getByTestId('trending-search-results-list')).toBeOnTheScreen(); - expect(getByText(strings('trending.trending_tokens'))).toBeOnTheScreen(); - expect(mockUseExploreSearch).toHaveBeenCalledWith('bitcoin'); - }); - - it('renders list with empty search query (no footer expected)', () => { - // Arrange - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }], - perps: [], - predictions: [], - stocks: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - stocks: false, - sites: false, - }, - sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], - }); - - // Act - const { getByTestId } = render(); - - // Assert - list renders, empty query means footer won't render - expect(getByTestId('trending-search-results-list')).toBeOnTheScreen(); - expect(mockUseExploreSearch).toHaveBeenCalledWith(''); - }); - }); - - describe('loading state', () => { - it('renders skeleton items when section is loading', () => { - // Arrange - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [], - perps: [], - predictions: [], - stocks: [], - sites: [], - }, - isLoading: { - tokens: true, - perps: false, - predictions: false, - stocks: false, - sites: false, - }, - sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], - }); - - // Act - const { getByText } = render( - , - ); - - // Assert - shows header for loading section - expect(getByText(strings('trending.trending_tokens'))).toBeOnTheScreen(); - }); - - it('hides section when not loading and has no data', () => { - // Arrange - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [], - perps: [], - predictions: [], - stocks: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - stocks: false, - sites: false, - }, - sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], - }); - - // Act - const { queryByText } = render( - , - ); - - // Assert - no section headers when empty and not loading - expect(queryByText(strings('trending.trending_tokens'))).toBeNull(); - expect(queryByText('Perps')).toBeNull(); - expect(queryByText('Predictions')).toBeNull(); - }); - }); - - describe('basic functionality toggle', () => { - it('hides all sections when basic functionality is disabled', () => { - // Arrange - mockUseSelector.mockImplementation((selector) => { - if (selector === selectBasicFunctionalityEnabled) { - return false; - } - return undefined; - }); - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }], - perps: [{ symbol: 'BTC-USD', name: 'Bitcoin' }], - predictions: [], - stocks: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - stocks: false, - sites: false, - }, - sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], - }); - - // Act - const { queryByText } = render( - , - ); - - // Assert - all sections hidden when basic functionality disabled - expect(queryByText(strings('trending.trending_tokens'))).toBeNull(); - expect(queryByText('Perps')).toBeNull(); - }); - }); - - describe('section config handling', () => { - it('handles undefined section config gracefully', () => { - // Arrange - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }], - perps: [], - predictions: [], - stocks: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - stocks: false, - sites: false, - }, - sectionsOrder: [ - 'tokens', - 'unknown' as 'tokens', // Intentionally invalid ID to test graceful handling - 'stocks', - 'perps', - 'predictions', - 'sites', - ], - }); - - // Act & Assert - should not throw - const { getByText } = render(); - expect(getByText(strings('trending.trending_tokens'))).toBeOnTheScreen(); - }); - }); - - describe('view all and item limit', () => { - it('shows "View all" when a section has more than 3 items', () => { - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [ - { assetId: '1', symbol: 'BTC', name: 'Bitcoin' }, - { assetId: '2', symbol: 'ETH', name: 'Ethereum' }, - { assetId: '3', symbol: 'SOL', name: 'Solana' }, - { assetId: '4', symbol: 'USDC', name: 'USD Coin' }, - ], - perps: [], - predictions: [], - stocks: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - stocks: false, - sites: false, - }, - sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], - }); - - const { getByText } = render(); - - expect(getByText('View all')).toBeOnTheScreen(); - }); - - it('does not show "View all" when a section has 3 or fewer items', () => { - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [ - { assetId: '1', symbol: 'BTC', name: 'Bitcoin' }, - { assetId: '2', symbol: 'ETH', name: 'Ethereum' }, - ], - perps: [], - predictions: [], - stocks: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - stocks: false, - sites: false, - }, - sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], - }); - - const { queryByText } = render( - , - ); - - expect(queryByText('View all')).toBeNull(); - }); - - it('navigates to full view with section params when "View all" is pressed', () => { - mockUseExploreSearch.mockReturnValue({ - data: { - tokens: [ - { assetId: '1', symbol: 'BTC', name: 'Bitcoin' }, - { assetId: '2', symbol: 'ETH', name: 'Ethereum' }, - { assetId: '3', symbol: 'SOL', name: 'Solana' }, - { assetId: '4', symbol: 'USDC', name: 'USD Coin' }, - ], - perps: [], - predictions: [], - stocks: [], - sites: [], - }, - isLoading: { - tokens: false, - perps: false, - predictions: false, - stocks: false, - sites: false, - }, - sectionsOrder: ['tokens', 'stocks', 'perps', 'predictions', 'sites'], - }); - - const { getByText } = render( - , - ); - - fireEvent.press(getByText('View all')); - - expect(mockNavigate).toHaveBeenCalledWith( - Routes.EXPLORE_SECTION_RESULTS_FULL_VIEW, - { - sectionId: 'tokens', - title: strings('trending.trending_tokens'), - searchQuery: 'bitcoin', - data: [ - { assetId: '1', symbol: 'BTC', name: 'Bitcoin' }, - { assetId: '2', symbol: 'ETH', name: 'Ethereum' }, - { assetId: '3', symbol: 'SOL', name: 'Solana' }, - { assetId: '4', symbol: 'USDC', name: 'USD Coin' }, - ], - }, - ); - }); - }); -}); diff --git a/app/components/Views/TrendingView/components/HorizontalCarousel.tsx b/app/components/Views/TrendingView/components/HorizontalCarousel.tsx new file mode 100644 index 00000000000..b417808a30d --- /dev/null +++ b/app/components/Views/TrendingView/components/HorizontalCarousel.tsx @@ -0,0 +1,93 @@ +import React, { useMemo, useRef } from 'react'; +import { Dimensions } from 'react-native'; +import { Box, BoxBorderColor } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { FlashList, FlashListRef, ListRenderItem } from '@shopify/flash-list'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const CARD_WIDTH = SCREEN_WIDTH * 0.8; +const CARD_HEIGHT = 220; +const SKELETON_PLACEHOLDER_COUNT = 3; + +export interface HorizontalCarouselProps { + data: T[]; + isLoading: boolean; + renderItem: ListRenderItem; + Skeleton: React.ComponentType; + /** Stable id for the keyExtractor namespace. */ + idPrefix: string; + /** @default 3 */ + skeletonCount?: number; + testID?: string; +} + +/** + * Horizontally-scrolling carousel of full-width cards used for predictions + * and any "feature card" feed. Each card is `~80%` of the screen width. + */ +function HorizontalCarousel({ + data, + isLoading, + renderItem, + Skeleton, + idPrefix, + skeletonCount = SKELETON_PLACEHOLDER_COUNT, + testID, +}: HorizontalCarouselProps) { + const tw = useTailwind(); + const flashListRef = useRef>(null); + + const skeletonData = useMemo( + () => Array.from({ length: skeletonCount }), + [skeletonCount], + ); + const displayData = isLoading ? skeletonData : data; + + return ( + + { + const isLastItem = index === displayData.length - 1; + return ( + + + {isLoading ? ( + + ) : ( + renderItem({ + item: item as T, + index, + target: 'Cell', + }) + )} + + + ); + }} + contentContainerStyle={tw.style('px-4')} + keyExtractor={ + isLoading + ? (_, index) => `${idPrefix}-skeleton-${index}` + : (_, index) => `${idPrefix}-${index}` + } + horizontal + pagingEnabled={false} + showsHorizontalScrollIndicator={false} + snapToInterval={CARD_WIDTH} + decelerationRate="fast" + testID={testID ?? `${idPrefix}-flash-list`} + /> + + ); +} + +export default HorizontalCarousel; diff --git a/app/components/Views/TrendingView/components/PillRow.tsx b/app/components/Views/TrendingView/components/PillRow.tsx new file mode 100644 index 00000000000..ee35aba7cd1 --- /dev/null +++ b/app/components/Views/TrendingView/components/PillRow.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Pressable, ScrollView } from 'react-native'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; + +export interface PillOption { + key: string; + name: string; +} + +interface PillRowProps { + pills: PillOption[]; + activeKey: string; + onSelect: (key: string) => void; + testIdPrefix?: string; +} + +const PillRow: React.FC = ({ + pills, + activeKey, + onSelect, + testIdPrefix = 'pill-row', +}) => { + const tw = useTailwind(); + + return ( + + + + {pills.map((pill) => { + const isSelected = activeKey === pill.key; + return ( + onSelect(pill.key)} + testID={`${testIdPrefix}-pill-${pill.key}`} + style={tw.style( + 'rounded-xl px-[12px] py-2', + isSelected ? 'bg-icon-default' : 'bg-muted', + )} + > + + {pill.name} + + + ); + })} + + + + ); +}; + +export default PillRow; diff --git a/app/components/Views/TrendingView/components/PillScrollList.test.tsx b/app/components/Views/TrendingView/components/PillScrollList.test.tsx new file mode 100644 index 00000000000..de5e3be2302 --- /dev/null +++ b/app/components/Views/TrendingView/components/PillScrollList.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { render } from '@testing-library/react-native'; +import PillScrollList from './PillScrollList'; + +const Skeleton = () => loading; + +describe('PillScrollList', () => { + it('renders skeleton when loading and does not render the scroll list', () => { + const { getByTestId, queryByTestId } = render( + item} + keyExtractor={(item: { id: string }) => item.id} + Skeleton={Skeleton} + listTestId="pills-list" + />, + ); + + expect(getByTestId('pill-scroll-skeleton')).toBeTruthy(); + expect(queryByTestId('pills-list')).toBeNull(); + }); + + it('renders nothing in the scroll region when not loading and data is empty', () => { + const { queryByTestId } = render( + item} + keyExtractor={(item: { id: string }) => item.id} + Skeleton={Skeleton} + listTestId="pills-list" + />, + ); + + expect(queryByTestId('pills-list')).toBeNull(); + }); + + it('splits items across two rows and passes correct indices to renderItem', () => { + const renderItem = jest.fn((item: { id: string }, index: number) => ( + {String(index)} + )); + + const { getByTestId } = render( + item.id} + Skeleton={Skeleton} + listTestId="pills-list" + />, + ); + + expect(getByTestId('pills-list')).toBeTruthy(); + expect(getByTestId('pill-a').props.children).toBe('0'); + expect(getByTestId('pill-b').props.children).toBe('1'); + expect(getByTestId('pill-c').props.children).toBe('2'); + expect(renderItem).toHaveBeenCalledTimes(3); + }); + + it('respects maxPills when slicing data before splitting', () => { + const { getByTestId, queryByTestId } = render( + ( + {item.id} + )} + keyExtractor={(item: { id: string }) => item.id} + Skeleton={Skeleton} + listTestId="pills-list" + />, + ); + + expect(getByTestId('pill-1')).toBeTruthy(); + expect(getByTestId('pill-2')).toBeTruthy(); + expect(queryByTestId('pill-3')).toBeNull(); + expect(queryByTestId('pill-4')).toBeNull(); + }); +}); diff --git a/app/components/Views/TrendingView/components/PillScrollList.tsx b/app/components/Views/TrendingView/components/PillScrollList.tsx new file mode 100644 index 00000000000..128686b2cb2 --- /dev/null +++ b/app/components/Views/TrendingView/components/PillScrollList.tsx @@ -0,0 +1,98 @@ +import React, { useMemo } from 'react'; +import { ScrollView } from 'react-native'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; + +const DEFAULT_MAX_PILLS = 12; + +function splitIntoTwoRows(items: T[]): [T[], T[]] { + if (items.length === 0) return [[], []]; + const mid = Math.ceil(items.length / 2); + return [items.slice(0, mid), items.slice(mid)]; +} + +export interface PillScrollListProps { + data: T[]; + isLoading: boolean; + renderItem: (item: T, index: number) => React.ReactNode; + keyExtractor: (item: T) => string; + Skeleton: React.ComponentType; + /** @default 12 */ + maxPills?: number; + listTestId?: string; +} + +/** + * Two-row horizontal scroll of pill-shaped items. Used for "crypto movers". + * Splits incoming data evenly between the two rows. + */ +function PillScrollList({ + data, + isLoading, + renderItem, + keyExtractor, + Skeleton, + maxPills = DEFAULT_MAX_PILLS, + listTestId, +}: PillScrollListProps) { + const tw = useTailwind(); + const displayData = useMemo(() => data.slice(0, maxPills), [data, maxPills]); + const [row1, row2] = useMemo( + () => splitIntoTwoRows(displayData), + [displayData], + ); + + const renderRow = (items: T[], startIndex: number) => + items.map((item, i) => ( + + {renderItem(item, startIndex + i)} + + )); + + return ( + + {isLoading && ( + + + + )} + {!isLoading && (row1.length > 0 || row2.length > 0) && ( + + + {row1.length > 0 ? ( + + {renderRow(row1, 0)} + + ) : null} + {row2.length > 0 ? ( + + {renderRow(row2, row1.length)} + + ) : null} + + + )} + + ); +} + +export default PillScrollList; diff --git a/app/components/Views/TrendingView/components/PillToggleCardList.test.tsx b/app/components/Views/TrendingView/components/PillToggleCardList.test.tsx new file mode 100644 index 00000000000..486b31e6f67 --- /dev/null +++ b/app/components/Views/TrendingView/components/PillToggleCardList.test.tsx @@ -0,0 +1,111 @@ +jest.mock('@shopify/flash-list', () => { + const RN = jest.requireActual('react-native'); + return { FlashList: RN.FlatList }; +}); + +import React from 'react'; +import { Text } from 'react-native'; +import { render, fireEvent, act } from '@testing-library/react-native'; +import type { ListRenderItem } from '@shopify/flash-list'; +import PillToggleCardList from './PillToggleCardList'; + +interface Row { + id: string; + label: string; +} + +const Skeleton = () => sk; + +const renderItem: ListRenderItem = ({ item }) => ( + {item.label} +); + +describe('PillToggleCardList', () => { + const tabs = [ + { + key: 'a', + name: 'Tab A', + items: [{ id: '1', label: 'One' }], + }, + { + key: 'b', + name: 'Tab B', + items: [ + { id: '2', label: 'Two' }, + { id: '3', label: 'Three' }, + ], + }, + ]; + + it('defaults to first tab when defaultPillKey is omitted', () => { + const { getByTestId } = render( + + tabs={tabs} + isLoading={false} + renderItem={renderItem} + Skeleton={Skeleton} + idPrefix="test" + testIdPrefix="toggle-list" + listTestId="card-flash" + />, + ); + + expect(getByTestId('toggle-list')).toBeTruthy(); + expect(getByTestId('card-row-1')).toBeTruthy(); + }); + + it('starts on defaultPillKey when provided', () => { + const { getByTestId, queryByTestId } = render( + + tabs={tabs} + isLoading={false} + renderItem={renderItem} + Skeleton={Skeleton} + idPrefix="test" + defaultPillKey="b" + testIdPrefix="toggle-list" + listTestId="card-flash" + />, + ); + + expect(getByTestId('card-row-2')).toBeTruthy(); + expect(queryByTestId('card-row-1')).toBeNull(); + }); + + it('switches CardList data when a different pill is selected', () => { + const { getByTestId, queryByTestId } = render( + + tabs={tabs} + isLoading={false} + renderItem={renderItem} + Skeleton={Skeleton} + idPrefix="test" + testIdPrefix="toggle-list" + listTestId="card-flash" + />, + ); + + act(() => { + fireEvent.press(getByTestId('toggle-list-pill-b')); + }); + + expect(queryByTestId('card-row-1')).toBeNull(); + expect(getByTestId('card-row-2')).toBeTruthy(); + expect(getByTestId('card-row-3')).toBeTruthy(); + }); + + it('shows skeletons while loading', () => { + const { getAllByTestId } = render( + + tabs={tabs} + isLoading + renderItem={renderItem} + Skeleton={Skeleton} + idPrefix="test" + testIdPrefix="toggle-list" + />, + ); + + expect(getAllByTestId('card-list-skeleton').length).toBeGreaterThan(0); + }); +}); diff --git a/app/components/Views/TrendingView/components/PillToggleCardList.tsx b/app/components/Views/TrendingView/components/PillToggleCardList.tsx new file mode 100644 index 00000000000..38ff17105dd --- /dev/null +++ b/app/components/Views/TrendingView/components/PillToggleCardList.tsx @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; +import { Box } from '@metamask/design-system-react-native'; +import type { ListRenderItem } from '@shopify/flash-list'; +import PillRow, { type PillOption } from './PillRow'; +import CardList from './CardList'; + +export interface PillToggleCardListTab { + key: string; + name: string; + items: T[]; +} + +export interface PillToggleCardListProps { + tabs: PillToggleCardListTab[]; + isLoading: boolean; + renderItem: ListRenderItem; + Skeleton: React.ComponentType; + idPrefix: string; + /** Defaults to first tab. */ + defaultPillKey?: string; + /** Called whenever the active pill changes. */ + onPillChange?: (key: string) => void; + testIdPrefix?: string; + listTestId?: string; +} + +const DEFAULT_TEST_ID_PREFIX = 'pill-toggle-card-list'; + +/** + * Pill selector + card list composition. The active pill's `items` are passed + * to {@link CardList}. Used for perps "stocks vs commodities vs forex" toggles. + */ +function PillToggleCardList({ + tabs, + isLoading, + renderItem, + Skeleton, + idPrefix, + defaultPillKey, + onPillChange, + testIdPrefix = DEFAULT_TEST_ID_PREFIX, + listTestId, +}: PillToggleCardListProps) { + const firstKey = tabs[0]?.key ?? ''; + const [activeKey, setActiveKey] = useState(defaultPillKey ?? firstKey); + const active = tabs.find((p) => p.key === activeKey) ?? tabs[0]; + const pills: PillOption[] = tabs.map(({ key, name }) => ({ key, name })); + + const handleSelect = (key: string) => { + setActiveKey(key); + onPillChange?.(key); + }; + + return ( + + + + data={active?.items ?? []} + isLoading={isLoading} + renderItem={renderItem} + Skeleton={Skeleton} + idPrefix={idPrefix} + listTestId={listTestId} + /> + + ); +} + +export default PillToggleCardList; diff --git a/app/components/Views/TrendingView/components/QuickActions.tsx b/app/components/Views/TrendingView/components/QuickActions.tsx new file mode 100644 index 00000000000..8093dccf14a --- /dev/null +++ b/app/components/Views/TrendingView/components/QuickActions.tsx @@ -0,0 +1,118 @@ +import React, { useMemo } from 'react'; +import { ScrollView, TouchableOpacity } from 'react-native'; +import { useNavigation, type NavigationProp } from '@react-navigation/native'; +import { + Box, + FontWeight, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; +import type { AppNavigationProp } from '../../../../core/NavigationService/types'; +import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; +import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation'; +import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavigation'; +import { TrendingViewSelectorsIDs } from '../TrendingView.testIds'; + +export type SectionId = 'tokens' | 'perps' | 'stocks' | 'predictions' | 'sites'; + +interface QuickActionSection { + id: SectionId; + title: string; + viewAllAction: (navigation: AppNavigationProp) => void; +} + +interface QuickActionsProps { + /** Set of section IDs that have empty data and should be hidden */ + emptySections: Set; +} + +/** + * Horizontal quick-action pills for Explore (Trending → Perps → Stocks → Predictions → Sites). + */ +const QuickActions: React.FC = ({ emptySections }) => { + const navigation = useNavigation(); + const tw = useTailwind(); + + const sectionsArray = useMemo( + () => [ + { + id: 'tokens', + title: strings('trending.trending_tokens'), + viewAllAction: (nav) => { + nav.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW); + }, + }, + { + id: 'perps', + title: strings('trending.perps'), + viewAllAction: (nav) => { + navigateToPerpsMarketList( + nav as NavigationProp, + ); + }, + }, + { + id: 'stocks', + title: strings('trending.stocks'), + viewAllAction: (nav) => { + nav.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW); + }, + }, + { + id: 'predictions', + title: strings('wallet.predict'), + viewAllAction: (nav) => { + navigateToPredictionsList(nav, 'trending'); + }, + }, + { + id: 'sites', + title: strings('trending.sites'), + viewAllAction: (nav) => { + nav.navigate(Routes.SITES_FULL_VIEW); + }, + }, + ], + [], + ); + + const visibleSections = sectionsArray.filter((s) => !emptySections.has(s.id)); + + if (visibleSections.length === 0) { + return null; + } + + return ( + + + + {visibleSections.map((section) => ( + section.viewAllAction(navigation)} + // Use `tokens` suffix for E2E / View All naming (`section-header-view-all-tokens`), not `trending` + testID={`quick-action-${section.id}`} + style={tw.style( + 'flex-row items-center justify-center rounded-xl bg-background-section py-2 pl-4 pr-3', + )} + > + + {section.title} + + + ))} + + + + ); +}; + +export default QuickActions; diff --git a/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx b/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx deleted file mode 100644 index 29d85010327..00000000000 --- a/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; -import { ScrollView, TouchableOpacity } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { - Box, - FontWeight, - Text, - TextVariant, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { SectionId, useQuickActionsSectionsArray } from '../../sections.config'; -import { TrendingViewSelectorsIDs } from '../../TrendingView.testIds'; -import { AppNavigationProp } from '../../../../../core/NavigationService/types'; - -interface QuickActionsProps { - /** Set of section IDs that have empty data and should be hidden */ - emptySections: Set; -} - -/** - * A dynamic component that automatically generates action buttons based on the - * centralized sections configuration. When a new section is added to SECTIONS_CONFIG, - * a corresponding button will automatically appear here. - */ -const QuickActions: React.FC = ({ emptySections }) => { - const navigation = useNavigation(); - const tw = useTailwind(); - const sectionsArray = useQuickActionsSectionsArray(); - - const visibleSections = sectionsArray.filter((s) => !emptySections.has(s.id)); - - return ( - - - - {visibleSections.map((section) => ( - section.viewAllAction(navigation)} - testID={`quick-action-${section.id}`} - style={tw.style( - 'flex-row items-center justify-center rounded-xl bg-background-section py-2 pl-4 pr-3', - )} - > - - {section.title} - - - ))} - - - - ); -}; - -export default QuickActions; diff --git a/app/components/Views/TrendingView/components/SectionHeader.tsx b/app/components/Views/TrendingView/components/SectionHeader.tsx new file mode 100644 index 00000000000..4b188fdf712 --- /dev/null +++ b/app/components/Views/TrendingView/components/SectionHeader.tsx @@ -0,0 +1,66 @@ +import React, { useCallback } from 'react'; +import { + Text, + TextVariant, + TextColor, +} from '@metamask/design-system-react-native'; +import BaseSectionHeader from '../../../../component-library/components-temp/SectionHeader'; +import { + trackExploreInteracted, + type ExploreTabName, + type ExploreSectionName, +} from '../search/analytics'; + +export interface SectionHeaderProps { + title: string; + subtitle?: string; + /** When provided, the title becomes tappable with a trailing chevron. */ + onViewAll?: () => void; + testID?: string; + /** Tab context for analytics — required when onViewAll is set. */ + tabName?: ExploreTabName; + /** Section context for analytics — required when onViewAll is set. */ + sectionName?: ExploreSectionName; +} + +const SectionHeader: React.FC = ({ + title, + subtitle, + onViewAll, + testID, + tabName, + sectionName, +}) => { + const handleViewAll = useCallback(() => { + if (tabName && sectionName) { + trackExploreInteracted({ + interaction_type: 'section_see_all_tapped', + tab_name: tabName, + section_name: sectionName, + }); + } + onViewAll?.(); + }, [onViewAll, tabName, sectionName]); + + return ( + <> + + {subtitle && ( + + {subtitle} + + )} + + ); +}; + +export default SectionHeader; diff --git a/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.test.tsx b/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.test.tsx deleted file mode 100644 index 184d58ca968..00000000000 --- a/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; -import { fireEvent } from '@testing-library/react-native'; -import { strings } from '../../../../../../locales/i18n'; -import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import { backgroundState } from '../../../../../util/test/initial-root-state'; -import SectionHeader from './SectionHeader'; - -const mockNavigate = jest.fn(); - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: mockNavigate, - }), -})); - -const initialState = { - engine: { - backgroundState, - }, -}; - -describe('SectionHeader', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders title for predictions section', () => { - const { getByText } = renderWithProvider( - , - { state: initialState }, - ); - - expect(getByText('Predictions')).toBeOnTheScreen(); - }); - - it('renders title for tokens section', () => { - const { getByText } = renderWithProvider( - , - { state: initialState }, - ); - - expect(getByText(strings('trending.trending_tokens'))).toBeOnTheScreen(); - }); - - it('renders title for perps section', () => { - const { getByText } = renderWithProvider( - , - { state: initialState }, - ); - - expect(getByText('Perps')).toBeOnTheScreen(); - }); - - it('calls navigation action when header is pressed', () => { - const { getByTestId } = renderWithProvider( - , - { state: initialState }, - ); - - fireEvent.press(getByTestId('section-header-view-all-perps')); - - expect(mockNavigate).toHaveBeenCalledTimes(1); - }); -}); diff --git a/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx b/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx deleted file mode 100644 index c20a18237d1..00000000000 --- a/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { SectionId, SECTIONS_CONFIG } from '../../sections.config'; -import SectionHeader from '../../../../../component-library/components-temp/SectionHeader'; -import { AppNavigationProp } from '../../../../../core/NavigationService/types'; - -export interface SectionHeaderProps { - sectionId: SectionId; -} - -/** - * Displays a section header with title and "View All" button. - * All configuration is pulled from sections.config.tsx based on the sectionId. - * - * This component is part of the centralized section management system that ensures - * consistency between QuickActions buttons and section "View All" buttons. - */ -const TrendingSectionHeader: React.FC = ({ sectionId }) => { - const navigation = useNavigation(); - const sectionConfig = SECTIONS_CONFIG[sectionId]; - - return ( - sectionConfig.viewAllAction(navigation)} - twClassName="px-0 mb-2" - /> - ); -}; - -export default TrendingSectionHeader; diff --git a/app/components/Views/TrendingView/components/Sections/Section.tsx b/app/components/Views/TrendingView/components/Sections/Section.tsx deleted file mode 100644 index 40d0f28d666..00000000000 --- a/app/components/Views/TrendingView/components/Sections/Section.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { useEffect } from 'react'; -import { SectionId, SECTIONS_CONFIG } from '../../sections.config'; - -export interface RefreshConfig { - /** Incrementing counter to trigger refetch */ - trigger: number; - /** Whether to show loading skeleton during this refresh */ - silentRefresh: boolean; -} - -export interface SectionProps { - sectionId: SectionId; - refreshConfig: RefreshConfig; - /** Callback when data empty state changes (only called after loading completes) */ - toggleSectionEmptyState: (isEmpty: boolean) => void; - /** Callback when loading state changes (for silent refresh indicator) */ - toggleSectionLoadingState: (isLoading: boolean) => void; -} - -const Section: React.FC = ({ - sectionId, - refreshConfig, - toggleSectionEmptyState, - toggleSectionLoadingState, -}) => { - const section = SECTIONS_CONFIG[sectionId]; - const { data, isLoading, refetch } = section.useSectionData(); - - // Notify parent when data is empty - useEffect(() => { - if (!isLoading) { - toggleSectionEmptyState(data.length === 0); - } - }, [data.length, isLoading, toggleSectionEmptyState]); - - // Notify parent when loading - useEffect(() => { - toggleSectionLoadingState(isLoading); - }, [isLoading, toggleSectionLoadingState]); - - useEffect(() => { - if (refreshConfig && refreshConfig.trigger > 0 && refetch) { - refetch(); - } - }, [refreshConfig, refetch]); - - // Only show loading skeleton if refreshConfig allows it - const shouldShowSkeleton = isLoading && refreshConfig.silentRefresh; - - return ( - - ); -}; - -export default Section; diff --git a/app/components/Views/TrendingView/components/Sections/SectionTypes/PerpsExploreSection.test.tsx b/app/components/Views/TrendingView/components/Sections/SectionTypes/PerpsExploreSection.test.tsx deleted file mode 100644 index 2ec89c18d03..00000000000 --- a/app/components/Views/TrendingView/components/Sections/SectionTypes/PerpsExploreSection.test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react-native'; -import PerpsExploreSection from './PerpsExploreSection'; - -const mockNavigate = jest.fn(); -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ navigate: mockNavigate }), -})); - -jest.mock('react-redux', () => ({ - useSelector: jest.fn(() => ['MKT1']), -})); - -jest.mock('@metamask/perps-controller', () => ({ - PERPS_EVENT_VALUE: { SOURCE: { EXPLORE: 'explore' } }, -})); - -jest.mock('../../../../../../constants/navigation/Routes', () => ({ - __esModule: true, - default: { - PERPS: { - ROOT: 'PerpsRoot', - MARKET_LIST: 'PerpsMarketList', - MARKET_DETAILS: 'PerpsMarketDetails', - }, - }, -})); - -const mockUseHomepageSparklines = jest.fn(() => ({ sparklines: {} })); -jest.mock( - '../../../../Homepage/Sections/Perpetuals/hooks/useHomepageSparklines', - () => ({ - useHomepageSparklines: ( - ...args: Parameters - ) => mockUseHomepageSparklines(...args), - }), -); - -jest.mock('../../../../../UI/Perps/selectors/perpsController', () => ({ - selectPerpsWatchlistMarkets: jest.fn(), -})); - -jest.mock( - '../../../../Homepage/Sections/Perpetuals/components/PerpsMarketTileCard', - () => { - const ReactNative = jest.requireActual('react-native'); - return ({ - market, - testID, - }: { - market: { symbol: string }; - testID?: string; - }) => ( - - {market.symbol} - - ); - }, -); - -jest.mock( - '../../../../Homepage/Sections/Perpetuals/components/PerpsMarketTileCardSkeleton', - () => { - const ReactNative = jest.requireActual('react-native'); - return () => ; - }, -); - -jest.mock('../../../../Homepage/components/ViewMoreCard', () => { - const ReactNative = jest.requireActual('react-native'); - return ({ onPress, testID }: { onPress: () => void; testID?: string }) => ( - - View more - - ); -}); - -const createMarkets = (count: number) => - Array.from({ length: count }, (_, i) => ({ - symbol: `MKT${i}`, - name: `Market ${i}`, - change24hPercent: '0', - })); - -describe('PerpsExploreSection', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockUseHomepageSparklines.mockReturnValue({ sparklines: {} }); - }); - - it('renders skeleton when loading', () => { - render(); - - expect(screen.getByTestId('mock-skeleton')).toBeOnTheScreen(); - expect(screen.queryByTestId('explore-perps-carousel')).toBeNull(); - }); - - it('renders items and ViewMoreCard when not loading', () => { - const data = createMarkets(3); - - render( - , - ); - - expect(screen.getByTestId('explore-perps-carousel')).toBeOnTheScreen(); - expect(screen.getByTestId('perps-market-tile-card-MKT0')).toBeOnTheScreen(); - expect(screen.getByTestId('perps-market-tile-card-MKT1')).toBeOnTheScreen(); - expect(screen.getByTestId('perps-market-tile-card-MKT2')).toBeOnTheScreen(); - expect(screen.getByTestId('perps-view-more-card')).toBeOnTheScreen(); - }); - - it('limits displayed items to 5', () => { - const data = createMarkets(8); - - render( - , - ); - - expect(screen.getByTestId('perps-market-tile-card-MKT0')).toBeOnTheScreen(); - expect(screen.getByTestId('perps-market-tile-card-MKT4')).toBeOnTheScreen(); - expect(screen.queryByTestId('perps-market-tile-card-MKT5')).toBeNull(); - expect(screen.queryByTestId('perps-market-tile-card-MKT7')).toBeNull(); - }); - - it('navigates to market list when ViewMoreCard is pressed', () => { - render( - , - ); - - fireEvent.press(screen.getByTestId('perps-view-more-card')); - - expect(mockNavigate).toHaveBeenCalledWith( - 'PerpsRoot', - expect.objectContaining({ screen: 'PerpsMarketList' }), - ); - }); - - it('renders only ViewMoreCard when data is empty', () => { - render( - , - ); - - expect(screen.getByTestId('explore-perps-carousel')).toBeOnTheScreen(); - expect(screen.getByTestId('perps-view-more-card')).toBeOnTheScreen(); - }); - - it('batches sparkline subscriptions for displayed symbols only', () => { - const data = createMarkets(8); - - render( - , - ); - - expect(mockUseHomepageSparklines).toHaveBeenCalledWith([ - 'MKT0', - 'MKT1', - 'MKT2', - 'MKT3', - 'MKT4', - ]); - }); -}); diff --git a/app/components/Views/TrendingView/components/Sections/SectionTypes/PerpsExploreSection.tsx b/app/components/Views/TrendingView/components/Sections/SectionTypes/PerpsExploreSection.tsx deleted file mode 100644 index 8b6b64e415d..00000000000 --- a/app/components/Views/TrendingView/components/Sections/SectionTypes/PerpsExploreSection.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useMemo } from 'react'; -import { ScrollView } from 'react-native'; -import { useNavigation, type NavigationProp } from '@react-navigation/native'; -import { useSelector } from 'react-redux'; -import { Box } from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { - PERPS_EVENT_VALUE, - type PerpsMarketData, -} from '@metamask/perps-controller'; -import type { PerpsNavigationParamList } from '../../../../../UI/Perps/types/navigation'; -import { selectPerpsWatchlistMarkets } from '../../../../../UI/Perps/selectors/perpsController'; -import { useHomepageSparklines } from '../../../../Homepage/Sections/Perpetuals/hooks/useHomepageSparklines'; -import PerpsMarketTileCard from '../../../../Homepage/Sections/Perpetuals/components/PerpsMarketTileCard'; -import PerpsMarketTileCardSkeleton from '../../../../Homepage/Sections/Perpetuals/components/PerpsMarketTileCardSkeleton'; -import ViewMoreCard from '../../../../Homepage/components/ViewMoreCard'; -import Routes from '../../../../../../constants/navigation/Routes'; -import type { SectionId } from '../../../sections.config'; - -const MAX_ITEMS = 5; - -export interface PerpsExploreSectionProps { - sectionId: SectionId; - data: unknown[]; - isLoading: boolean; -} - -/** - * Self-contained section that batches sparkline subscriptions and watchlist - * reads for all visible perps tiles, then renders tiles directly. - */ -const PerpsExploreSection: React.FC = ({ - data, - isLoading, -}) => { - const navigation = useNavigation(); - const tw = useTailwind(); - - const displayMarkets = useMemo( - () => (data as PerpsMarketData[]).slice(0, MAX_ITEMS), - [data], - ); - const displaySymbols = useMemo( - () => displayMarkets.map((m) => m.symbol), - [displayMarkets], - ); - const { sparklines } = useHomepageSparklines(displaySymbols); - const watchlistSymbols = useSelector(selectPerpsWatchlistMarkets) ?? []; - - return ( - - {isLoading ? ( - - - - ) : ( - - {displayMarkets.map((market) => ( - { - ( - navigation as NavigationProp - )?.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKET_DETAILS, - params: { - market, - source: PERPS_EVENT_VALUE.SOURCE.EXPLORE, - }, - }); - }} - /> - ))} - - navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKET_LIST, - params: { - defaultMarketTypeFilter: 'all', - source: PERPS_EVENT_VALUE.SOURCE.EXPLORE, - }, - }) - } - twClassName="w-[180px] flex-1" - testID="perps-view-more-card" - /> - - )} - - ); -}; - -export default PerpsExploreSection; diff --git a/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCard.tsx b/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCard.tsx deleted file mode 100644 index 9be62cc85fc..00000000000 --- a/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCard.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { useCallback } from 'react'; -import { StyleSheet } from 'react-native'; -import Card from '../../../../../../component-library/components/Cards/Card'; -import { SectionId, SECTIONS_CONFIG } from '../../../sections.config'; -import { FlashList, ListRenderItem } from '@shopify/flash-list'; -import { useNavigation } from '@react-navigation/native'; - -const styles = StyleSheet.create({ - cardContainer: { - padding: 0, - marginBottom: 28, - borderWidth: 0, - }, -}); -interface SectionCardProps { - sectionId: SectionId; - data: unknown[]; - isLoading: boolean; -} - -const SectionCard: React.FC = ({ - sectionId, - data, - isLoading, -}) => { - const navigation = useNavigation(); - - const section = SECTIONS_CONFIG[sectionId]; - - const renderFlatItem: ListRenderItem = useCallback( - ({ item, index }) => ( - - ), - [navigation, section], - ); - - return ( - - {isLoading && ( - <> - - - - - )} - {!isLoading && ( - `${section.id}-${index}`} - keyboardShouldPersistTaps="handled" - testID="perps-tokens-list" - /> - )} - - ); -}; - -export default SectionCard; diff --git a/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCarrousel.tsx b/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCarrousel.tsx deleted file mode 100644 index e278985ec3a..00000000000 --- a/app/components/Views/TrendingView/components/Sections/SectionTypes/SectionCarrousel.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Box, BoxBorderColor } from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import React, { useRef } from 'react'; -import { Dimensions } from 'react-native'; -import { FlashList, FlashListRef } from '@shopify/flash-list'; -import { SectionId, SECTIONS_CONFIG } from '../../../sections.config'; -import { useNavigation } from '@react-navigation/native'; - -const { width: SCREEN_WIDTH } = Dimensions.get('window'); -const CONTENT_WIDTH = SCREEN_WIDTH; -const CARD_WIDTH = CONTENT_WIDTH * 0.8; -const CARD_HEIGHT = 220; - -export interface SectionCarrouselProps { - sectionId: SectionId; - data: unknown[]; - isLoading: boolean; -} - -const SectionCarrousel: React.FC = ({ - sectionId, - data, - isLoading, -}) => { - const navigation = useNavigation(); - const tw = useTailwind(); - const flashListRef = useRef>(null); - - const section = SECTIONS_CONFIG[sectionId]; - - const skeletonCount = 3; - const skeletonData = Array.from({ length: skeletonCount }); - - const displayData = isLoading ? skeletonData : data; - - return ( - - { - const isLastItem = index === displayData.length - 1; - return ( - - - {isLoading ? ( - - ) : ( - - )} - - - ); - }} - contentContainerStyle={tw.style('px-4')} - keyExtractor={ - isLoading - ? (_, index) => `skeleton-${index}` - : (_, index) => `${section.id}-${index}` - } - horizontal - pagingEnabled={false} - showsHorizontalScrollIndicator={false} - snapToInterval={CARD_WIDTH} - decelerationRate="fast" - testID={`${sectionId}-flash-list`} - /> - - ); -}; - -export default SectionCarrousel; diff --git a/app/components/Views/TrendingView/components/TileCarousel.tsx b/app/components/Views/TrendingView/components/TileCarousel.tsx new file mode 100644 index 00000000000..650188d6361 --- /dev/null +++ b/app/components/Views/TrendingView/components/TileCarousel.tsx @@ -0,0 +1,80 @@ +import React, { useMemo } from 'react'; +import { ScrollView } from 'react-native'; +import { Box } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import ViewMoreCard from '../../Homepage/components/ViewMoreCard'; + +/** Default number of tiles shown; sparkline fetches in `usePerpsFeed` must match. */ +export const TILE_CAROUSEL_DEFAULT_MAX_TILES = 5; + +export interface TileCarouselProps { + data: T[]; + isLoading: boolean; + renderItem: (item: T, index: number) => React.ReactNode; + keyExtractor: (item: T) => string; + Skeleton: React.ComponentType; + /** Optional trailing "View more" tile press handler. Omit to hide the tile. */ + onViewMore?: () => void; + /** @default 5 */ + max?: number; + testID?: string; + viewMoreTestID?: string; + /** + * When true, uses `mb-7` after the carousel so spacing matches {@link CardList} + * section tails (~28px). Use for the first tile strip on Sites (Recents) to + * mirror Crypto "Trending" → next section rhythm. + */ + compactSectionTail?: boolean; +} + +/** + * Horizontal scrollview of fixed-width tiles. Used for perps tiles and + * recent dapp / network tiles. Trailing `` is opt-in. + */ +function TileCarousel({ + data, + isLoading, + renderItem, + keyExtractor, + Skeleton, + onViewMore, + max = TILE_CAROUSEL_DEFAULT_MAX_TILES, + testID, + viewMoreTestID, + compactSectionTail = false, +}: TileCarouselProps) { + const tw = useTailwind(); + const displayItems = useMemo(() => data.slice(0, max), [data, max]); + + return ( + + {isLoading ? ( + + + + ) : ( + + {displayItems.map((item, index) => ( + + {renderItem(item, index)} + + ))} + {onViewMore && ( + + )} + + )} + + ); +} + +export default TileCarousel; diff --git a/app/components/Views/TrendingView/feeds/dapps/SiteTileRowItem.tsx b/app/components/Views/TrendingView/feeds/dapps/SiteTileRowItem.tsx new file mode 100644 index 00000000000..44563f3ef44 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/dapps/SiteTileRowItem.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { + TouchableOpacity, + StyleSheet, + Image, + type TextStyle, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + FontWeight, + Icon, + IconColor, + IconName, + IconSize, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useStyles } from '../../../../../component-library/hooks'; +import WebsiteIcon from '../../../../UI/WebsiteIcon'; +import type { SiteData } from '../../../../UI/Sites/components/SiteRowItem/SiteRowItem'; +import type { AppNavigationProp } from '../../../../../core/NavigationService/types'; +import Routes from '../../../../../constants/navigation/Routes'; +import type { Theme } from '../../../../../util/theme/models'; + +export const SITE_TILE_WIDTH = 180; +export const SITE_TILE_HEIGHT = 120; +export const SITE_TILE_BORDER_RADIUS = 12; + +const LOGO_SIZE = 40; + +const websiteIconTextStyle: TextStyle = { fontSize: 12 }; + +const styleSheet = ({ theme }: { theme: Theme }) => + StyleSheet.create({ + card: { + width: SITE_TILE_WIDTH, + height: SITE_TILE_HEIGHT, + backgroundColor: theme.colors.background.section, + borderRadius: SITE_TILE_BORDER_RADIUS, + paddingHorizontal: 16, + paddingVertical: 12, + }, + websiteIcon: { + width: LOGO_SIZE, + height: LOGO_SIZE, + borderRadius: LOGO_SIZE / 2, + }, + }); + +interface SiteTileRowItemProps { + site: SiteData; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; +} + +/** + * Compact tile (icon, title, url) for Explore "Recents" / "Networks" carousels. + */ +const SiteTileRowItem: React.FC = ({ + site, + onCardPress, +}) => { + const navigation = useNavigation(); + const { styles } = useStyles(styleSheet); + const tw = useTailwind(); + + const onPress = () => { + onCardPress?.(); + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: site.url, + timestamp: Date.now(), + fromTrending: true, + }, + }); + }; + + return ( + + + + {site.logoSource ? ( + + ) : ( + + )} + + + + + {site.name} + + + {site.displayUrl} + + + ); +}; + +export default SiteTileRowItem; diff --git a/app/components/Views/TrendingView/feeds/dapps/SiteTileSkeleton.tsx b/app/components/Views/TrendingView/feeds/dapps/SiteTileSkeleton.tsx new file mode 100644 index 00000000000..a7f2e82947c --- /dev/null +++ b/app/components/Views/TrendingView/feeds/dapps/SiteTileSkeleton.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { View } from 'react-native'; +import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useTheme } from '../../../../../util/theme'; +import { + SITE_TILE_BORDER_RADIUS, + SITE_TILE_HEIGHT, + SITE_TILE_WIDTH, +} from './SiteTileRowItem'; + +const SKELETON_CARD_COUNT = 3; + +/** Loading strip for the dapps "Recents" / "Networks" carousels. */ +const SiteTileSkeleton: React.FC = () => { + const { colors } = useTheme(); + const tw = useTailwind(); + + return ( + + + {Array.from({ length: SKELETON_CARD_COUNT }, (_, i) => ( + + ))} + + + ); +}; + +export default React.memo(SiteTileSkeleton); diff --git a/app/components/Views/TrendingView/feeds/dapps/useFavoritesFeed.ts b/app/components/Views/TrendingView/feeds/dapps/useFavoritesFeed.ts new file mode 100644 index 00000000000..8ba3f07fcbb --- /dev/null +++ b/app/components/Views/TrendingView/feeds/dapps/useFavoritesFeed.ts @@ -0,0 +1,25 @@ +import { useBrowserFavoritesSites } from '../../../../UI/Sites/hooks/useBrowserFavoritesSites/useBrowserFavoritesSites'; +import { useFeedRefresh } from '../../hooks/useFeedRefresh'; +import type { RefreshConfig } from '../../hooks/useExploreRefresh'; +import type { SiteData } from '../../../../UI/Sites/components/SiteRowItem/SiteRowItem'; + +interface UseFavoritesFeedOptions { + query?: string; + refresh?: RefreshConfig; +} + +export interface UseFavoritesFeedResult { + data: SiteData[]; + isLoading: boolean; + refetch: () => Promise; +} + +/** Bookmarked browser sites mapped to `SiteData`. */ +export const useFavoritesFeed = ({ + query, + refresh, +}: UseFavoritesFeedOptions = {}): UseFavoritesFeedResult => { + const { data, isLoading, refetch } = useBrowserFavoritesSites(query); + useFeedRefresh(refresh, refetch); + return { data, isLoading, refetch }; +}; diff --git a/app/components/Views/TrendingView/feeds/dapps/useNetworksFeed.ts b/app/components/Views/TrendingView/feeds/dapps/useNetworksFeed.ts new file mode 100644 index 00000000000..f3fb060c7af --- /dev/null +++ b/app/components/Views/TrendingView/feeds/dapps/useNetworksFeed.ts @@ -0,0 +1,48 @@ +import images from '../../../../../images/image-icons'; +import type { SiteData } from '../../../../UI/Sites/components/SiteRowItem/SiteRowItem'; + +const NETWORKS: SiteData[] = [ + { + id: 'network-linea', + name: 'Linea', + url: 'https://portfolio.metamask.io/explore/networks/linea', + displayUrl: 'Linea Hub', + logoSource: images['LINEA-MAINNET'], + }, + { + id: 'network-sei', + name: 'Sei', + url: 'https://portfolio.metamask.io/explore/networks/sei', + displayUrl: 'Sei Hub', + logoSource: images.SEI, + }, + { + id: 'network-monad', + name: 'Monad', + url: 'https://portfolio.metamask.io/explore/networks/monad', + displayUrl: 'Monad Hub', + logoSource: images.MON, + }, + { + id: 'network-solana', + name: 'Solana', + url: 'https://portfolio.metamask.io/explore/networks/solana', + displayUrl: 'Solana Hub', + logoSource: images.SOLANA, + }, +]; + +export interface UseNetworksFeedResult { + data: SiteData[]; + isLoading: false; + refetch: () => Promise; +} + +/** Static list of network "hub" sites; no remote fetch. */ +export const useNetworksFeed = (): UseNetworksFeedResult => ({ + data: NETWORKS, + isLoading: false, + refetch: async () => { + /* Static data; no remote refetch. */ + }, +}); diff --git a/app/components/Views/TrendingView/feeds/dapps/useRecentsFeed.ts b/app/components/Views/TrendingView/feeds/dapps/useRecentsFeed.ts new file mode 100644 index 00000000000..1faf93b90f7 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/dapps/useRecentsFeed.ts @@ -0,0 +1,23 @@ +import { useBrowserRecentsSites } from '../../../../UI/Sites/hooks/useBrowserRecentsSites/useBrowserRecentsSites'; +import { useFeedRefresh } from '../../hooks/useFeedRefresh'; +import type { RefreshConfig } from '../../hooks/useExploreRefresh'; +import type { SiteData } from '../../../../UI/Sites/components/SiteRowItem/SiteRowItem'; + +interface UseRecentsFeedOptions { + refresh?: RefreshConfig; +} + +export interface UseRecentsFeedResult { + data: SiteData[]; + isLoading: boolean; + refetch: () => Promise; +} + +/** Most-recent unique browser-history entries as `SiteData` tiles. */ +export const useRecentsFeed = ({ + refresh, +}: UseRecentsFeedOptions = {}): UseRecentsFeedResult => { + const { data, isLoading, refetch } = useBrowserRecentsSites(); + useFeedRefresh(refresh, refetch); + return { data, isLoading, refetch }; +}; diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsPillItem.test.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsPillItem.test.tsx new file mode 100644 index 00000000000..d0a024b2add --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/PerpsPillItem.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { PERPS_EVENT_VALUE } from '@metamask/perps-controller'; +import Routes from '../../../../../constants/navigation/Routes'; +import PerpsPillItem from './PerpsPillItem'; +import type { PerpsFeedItem } from './usePerpsFeed'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +describe('PerpsPillItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const buildItem = ( + change24hPercent: string | null | undefined, + ): PerpsFeedItem => + ({ + market: { + symbol: 'ETH', + change24hPercent, + }, + isWatchlisted: false, + }) as PerpsFeedItem; + + it('navigates to market details with explore source on press', () => { + const item = buildItem('1.5'); + const { getByTestId } = render(); + + fireEvent.press(getByTestId('perps-market-tile-card-ETH')); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market: item.market, + source: PERPS_EVENT_VALUE.SOURCE.EXPLORE, + }, + }); + }); + + it('hides change label when change is null, empty, or not a number', () => { + const { queryByText, rerender } = render( + , + ); + expect(queryByText(/%/)).toBeNull(); + + rerender(); + expect(queryByText(/%/)).toBeNull(); + + rerender(); + expect(queryByText(/%/)).toBeNull(); + }); + + it('renders 0.00% and signed changes for valid numbers', () => { + const { getByText, rerender } = render( + , + ); + expect(getByText('0.00%')).toBeTruthy(); + + rerender(); + expect(getByText('+3.46%')).toBeTruthy(); + + rerender(); + expect(getByText('-0.50%')).toBeTruthy(); + }); +}); diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsPillItem.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsPillItem.tsx new file mode 100644 index 00000000000..c24549de87c --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/PerpsPillItem.tsx @@ -0,0 +1,74 @@ +import React, { useMemo } from 'react'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { + PERPS_EVENT_VALUE, + getPerpsDisplaySymbol, +} from '@metamask/perps-controller'; +import { TextColor } from '@metamask/design-system-react-native'; +import PerpsTokenLogo from '../../../../UI/Perps/components/PerpsTokenLogo'; +import type { PerpsNavigationParamList } from '../../../../UI/Perps/types/navigation'; +import Routes from '../../../../../constants/navigation/Routes'; +import ExplorePill from '../../components/ExplorePill'; +import type { PerpsFeedItem } from './usePerpsFeed'; + +const LOGO_SIZE = 24; + +interface PerpsPillItemProps { + item: PerpsFeedItem; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; +} + +const PerpsPillItem: React.FC = ({ item, onCardPress }) => { + const navigation = useNavigation>(); + const { market } = item; + + const { changeLabel, changeTextColor } = useMemo(() => { + const raw = market.change24hPercent; + const n = parseFloat(String(raw)); + if (raw == null || raw === '' || Number.isNaN(n)) { + return { + changeLabel: undefined, + changeTextColor: TextColor.TextAlternative, + }; + } + if (n === 0) { + return { + changeLabel: '0.00%', + changeTextColor: TextColor.TextAlternative, + }; + } + return { + changeLabel: `${n > 0 ? '+' : ''}${n.toFixed(2)}%`, + changeTextColor: + n > 0 ? TextColor.SuccessDefault : TextColor.ErrorDefault, + }; + }, [market.change24hPercent]); + + const onPress = () => { + onCardPress?.(); + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { market, source: PERPS_EVENT_VALUE.SOURCE.EXPLORE }, + }); + }; + + return ( + + } + title={getPerpsDisplaySymbol(market.symbol)} + changeLabel={changeLabel} + changeTextColor={changeTextColor} + /> + ); +}; + +export default React.memo(PerpsPillItem); diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsRowItem.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsRowItem.tsx new file mode 100644 index 00000000000..cef900b9124 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/PerpsRowItem.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { + PERPS_EVENT_VALUE, + type PerpsMarketData, +} from '@metamask/perps-controller'; +import PerpsMarketRowItem from '../../../../UI/Perps/components/PerpsMarketRowItem'; +import type { PerpsNavigationParamList } from '../../../../UI/Perps/types/navigation'; +import Routes from '../../../../../constants/navigation/Routes'; + +interface PerpsRowItemProps { + market: PerpsMarketData; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; +} + +/** Compact list row for perps — used by pill-toggled lists and search. */ +const PerpsRowItem: React.FC = ({ market, onCardPress }) => { + const navigation = useNavigation>(); + return ( + { + onCardPress?.(); + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { market, source: PERPS_EVENT_VALUE.SOURCE.EXPLORE }, + }); + }} + showBadge={false} + compact + /> + ); +}; + +export default PerpsRowItem; diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsSectionProvider.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsSectionProvider.tsx new file mode 100644 index 00000000000..8bf601a27f3 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/PerpsSectionProvider.tsx @@ -0,0 +1,15 @@ +import React, { type PropsWithChildren } from 'react'; +import { PerpsConnectionProvider } from '../../../../UI/Perps/providers/PerpsConnectionProvider'; +import { PerpsStreamProvider } from '../../../../UI/Perps/providers/PerpsStreamManager'; + +/** + * Wraps any subtree that consumes the perps feed. Required because + * `usePerpsMarkets` reads from the perps connection + stream contexts. + */ +const PerpsSectionProvider: React.FC = ({ children }) => ( + + {children} + +); + +export default PerpsSectionProvider; diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsTileRowItem.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsTileRowItem.tsx new file mode 100644 index 00000000000..d640187d8fd --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/PerpsTileRowItem.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { PERPS_EVENT_VALUE } from '@metamask/perps-controller'; +import PerpsMarketTileCard from '../../../Homepage/Sections/Perpetuals/components/PerpsMarketTileCard'; +import type { PerpsNavigationParamList } from '../../../../UI/Perps/types/navigation'; +import Routes from '../../../../../constants/navigation/Routes'; +import type { PerpsFeedItem } from './usePerpsFeed'; + +interface PerpsTileRowItemProps { + item: PerpsFeedItem; + testIdPrefix: string; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; +} + +const PerpsTileRowItem: React.FC = ({ + item, + testIdPrefix, + onCardPress, +}) => { + const navigation = useNavigation>(); + const { market, sparkline, isWatchlisted } = item; + + return ( + { + onCardPress?.(); + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { market, source: PERPS_EVENT_VALUE.SOURCE.EXPLORE }, + }); + }} + /> + ); +}; + +export default PerpsTileRowItem; diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.test.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.test.tsx new file mode 100644 index 00000000000..dd354e95942 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.test.tsx @@ -0,0 +1,263 @@ +/** + * PerpsToggleBlock — unit tests + * + * Core concerns: + * 1. Renders title and the default pill's items. + * 2. "View All" button calls onViewAll with the active pill key and the + * sortOptionId prop — this is the critical wiring introduced alongside the + * sort-param feature. + * 3. Switching pills updates the key passed to onViewAll. + * 4. Shows skeletons while loading. + * 5. Analytics: trackExploreInteracted is called when a row is pressed. + */ + +jest.mock('@shopify/flash-list', () => { + const RN = jest.requireActual('react-native'); + return { FlashList: RN.FlatList }; +}); + +jest.mock('../../search/analytics', () => ({ + trackExploreInteracted: jest.fn(), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => { + const twFn = () => ({}); + twFn.style = () => ({}); + return { useTailwind: () => twFn }; +}); + +import React from 'react'; +import { Text } from 'react-native'; +import { render, fireEvent, act } from '@testing-library/react-native'; +import type { PerpsMarketData, SortOptionId } from '@metamask/perps-controller'; +import { trackExploreInteracted } from '../../search/analytics'; +import PerpsToggleBlock, { + type PerpsToggleBlockProps, +} from './PerpsToggleBlock'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeMarket = (symbol: string): PerpsMarketData => + ({ + symbol, + name: `${symbol} Market`, + price: '$1.00', + change24h: '+1%', + change24hPercent: '1', + volume: '$100M', + maxLeverage: '10x', + isHip3: true, + marketType: 'equity', + }) as PerpsMarketData; + +// Minimal PerpsRowItem mock — renders the symbol so assertions are simple. +jest.mock('./PerpsRowItem', () => { + const { TouchableOpacity, Text: RNText } = jest.requireActual('react-native'); + return function MockPerpsRowItem({ + market, + onCardPress, + }: { + market: PerpsMarketData; + onCardPress?: () => void; + }) { + return ( + + {market.symbol} + + ); + }; +}); + +jest.mock('../../../../UI/Perps/components/PerpsRowSkeleton', () => { + const { View, Text: RNText } = jest.requireActual('react-native'); + return function MockPerpsRowSkeleton() { + return ( + + skeleton + + ); + }; +}); + +const Skeleton = () => sk; + +const STOCKS_MARKETS = [makeMarket('AAPL'), makeMarket('GOOGL')]; +const COMMODITY_MARKETS = [makeMarket('GOLD')]; + +const DEFAULT_TABS = [ + { key: 'stocks', name: 'Stocks', items: STOCKS_MARKETS }, + { key: 'commodities', name: 'Commodities', items: COMMODITY_MARKETS }, +]; + +const DEFAULT_PROPS: PerpsToggleBlockProps = { + title: 'Stocks & Commodities', + tabs: DEFAULT_TABS, + isLoading: false, + defaultPillKey: 'stocks', + onViewAll: jest.fn(), + sortOptionId: 'volume', + tabName: 'Macro', + sectionName: 'perps_stocks_commodities', + headerTestID: 'section-header-view-all-test', + idPrefix: 'test', + testIdPrefix: 'test-toggle', + listTestId: 'test-list', +}; + +const renderBlock = (props = DEFAULT_PROPS) => + render(); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('PerpsToggleBlock', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders the section title', () => { + const { getByText } = renderBlock(); + expect(getByText('Stocks & Commodities')).toBeTruthy(); + }); + + it('renders items for the default pill', () => { + const { getByTestId, queryByTestId } = renderBlock(); + expect(getByTestId('perps-row-AAPL')).toBeTruthy(); + expect(getByTestId('perps-row-GOOGL')).toBeTruthy(); + expect(queryByTestId('perps-row-GOLD')).toBeNull(); + }); + + it('renders pill buttons for each tab', () => { + const { getByTestId } = renderBlock(); + expect(getByTestId('test-toggle-pill-stocks')).toBeTruthy(); + expect(getByTestId('test-toggle-pill-commodities')).toBeTruthy(); + }); + }); + + describe('loading state', () => { + it('shows skeletons while isLoading is true', () => { + const { getAllByTestId } = renderBlock({ + ...DEFAULT_PROPS, + isLoading: true, + }); + expect(getAllByTestId('perps-row-skeleton').length).toBeGreaterThan(0); + }); + }); + + describe('pill switching', () => { + it('shows the commodities items after selecting the commodities pill', () => { + const { getByTestId, queryByTestId } = renderBlock(); + + act(() => { + fireEvent.press(getByTestId('test-toggle-pill-commodities')); + }); + + expect(getByTestId('perps-row-GOLD')).toBeTruthy(); + expect(queryByTestId('perps-row-AAPL')).toBeNull(); + }); + + it('switching back to stocks shows stocks items again', () => { + const { getByTestId } = renderBlock(); + + act(() => { + fireEvent.press(getByTestId('test-toggle-pill-commodities')); + }); + act(() => { + fireEvent.press(getByTestId('test-toggle-pill-stocks')); + }); + + expect(getByTestId('perps-row-AAPL')).toBeTruthy(); + }); + }); + + describe('View All — sort and filter forwarding', () => { + it('calls onViewAll with the defaultPillKey and sortOptionId before any pill change', () => { + const onViewAll = jest.fn(); + const { getByTestId } = renderBlock({ ...DEFAULT_PROPS, onViewAll }); + + fireEvent.press(getByTestId('section-header-view-all-test')); + + expect(onViewAll).toHaveBeenCalledTimes(1); + expect(onViewAll).toHaveBeenCalledWith('stocks', 'volume'); + }); + + it('calls onViewAll with the newly active pill key after switching pills', () => { + const onViewAll = jest.fn(); + const { getByTestId } = renderBlock({ ...DEFAULT_PROPS, onViewAll }); + + act(() => { + fireEvent.press(getByTestId('test-toggle-pill-commodities')); + }); + + fireEvent.press(getByTestId('section-header-view-all-test')); + + expect(onViewAll).toHaveBeenCalledWith('commodities', 'volume'); + }); + + it('forwards the sortOptionId prop unchanged regardless of active pill', () => { + const onViewAll = jest.fn(); + const { getByTestId } = renderBlock({ + ...DEFAULT_PROPS, + sortOptionId: 'priceChange', + onViewAll, + }); + + act(() => { + fireEvent.press(getByTestId('test-toggle-pill-commodities')); + }); + fireEvent.press(getByTestId('section-header-view-all-test')); + + expect(onViewAll).toHaveBeenCalledWith('commodities', 'priceChange'); + }); + + it('calls onViewAll only once per press', () => { + const onViewAll = jest.fn(); + const { getByTestId } = renderBlock({ ...DEFAULT_PROPS, onViewAll }); + + fireEvent.press(getByTestId('section-header-view-all-test')); + fireEvent.press(getByTestId('section-header-view-all-test')); + + expect(onViewAll).toHaveBeenCalledTimes(2); + }); + }); + + describe('analytics', () => { + it('calls trackExploreInteracted with correct context when a row is pressed', () => { + const mockTrack = trackExploreInteracted as jest.Mock; + const { getByTestId } = renderBlock(); + + fireEvent.press(getByTestId('perps-row-AAPL')); + + expect(mockTrack).toHaveBeenCalledWith( + expect.objectContaining({ + interaction_type: 'section_item_tapped', + tab_name: 'Macro', + section_name: 'perps_stocks_commodities', + asset_type: 'perp', + item_clicked: 'AAPL', + }), + ); + }); + + it('includes the correct position index in analytics', () => { + const mockTrack = trackExploreInteracted as jest.Mock; + const { getByTestId } = renderBlock(); + + fireEvent.press(getByTestId('perps-row-GOOGL')); + + expect(mockTrack).toHaveBeenCalledWith( + expect.objectContaining({ + position: 1, + item_clicked: 'GOOGL', + }), + ); + }); + }); +}); diff --git a/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx new file mode 100644 index 00000000000..bed6dd344a3 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/PerpsToggleBlock.tsx @@ -0,0 +1,100 @@ +import React, { useCallback, useRef } from 'react'; +import { Box } from '@metamask/design-system-react-native'; +import type { ListRenderItem } from '@shopify/flash-list'; +import type { PerpsMarketData, SortOptionId } from '@metamask/perps-controller'; +import PerpsRowItem from './PerpsRowItem'; +import PerpsRowSkeleton from '../../../../UI/Perps/components/PerpsRowSkeleton'; +import PillToggleCardList, { + type PillToggleCardListTab, +} from '../../components/PillToggleCardList'; +import SectionHeader from '../../components/SectionHeader'; +import { + type ExploreTabName, + type ExploreSectionName, + trackExploreInteracted, +} from '../../search/analytics'; + +const PerpsRowSingleSkeleton: React.FC = () => ; + +export interface PerpsToggleBlockProps { + title: string; + tabs: PillToggleCardListTab[]; + isLoading: boolean; + defaultPillKey: string; + onViewAll: (filter: string, sortOptionId: SortOptionId) => void; + sortOptionId: SortOptionId; + /** Analytics context */ + tabName: ExploreTabName; + sectionName: ExploreSectionName; + /** Test IDs */ + headerTestID: string; + idPrefix: string; + testIdPrefix: string; + listTestId: string; +} + +/** + * Shared perps section that renders a pill-toggled list of perp rows with + * a "See all" header. Used by MacroTab and RwasTab. + */ +const PerpsToggleBlock: React.FC = ({ + title, + tabs, + isLoading, + defaultPillKey, + onViewAll, + sortOptionId, + tabName, + sectionName, + headerTestID, + idPrefix, + testIdPrefix, + listTestId, +}) => { + const activePillKey = useRef(defaultPillKey); + + const renderItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: tabName, + section_name: sectionName, + asset_type: 'perp', + position: index, + item_clicked: item.symbol, + }) + } + /> + ), + [tabName, sectionName], + ); + + return ( + + onViewAll(activePillKey.current, sortOptionId)} + testID={headerTestID} + tabName={tabName} + sectionName={sectionName} + /> + + tabs={tabs} + isLoading={isLoading} + renderItem={renderItem} + Skeleton={PerpsRowSingleSkeleton} + idPrefix={idPrefix} + onPillChange={(key) => { + activePillKey.current = key; + }} + testIdPrefix={testIdPrefix} + listTestId={listTestId} + /> + + ); +}; + +export default PerpsToggleBlock; diff --git a/app/components/Views/TrendingView/feeds/perps/perpsNavigation.test.ts b/app/components/Views/TrendingView/feeds/perps/perpsNavigation.test.ts new file mode 100644 index 00000000000..c1f0e39d246 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/perpsNavigation.test.ts @@ -0,0 +1,72 @@ +import { NavigationProp } from '@react-navigation/native'; +import { PERPS_EVENT_VALUE } from '@metamask/perps-controller'; +import type { PerpsNavigationParamList } from '../../../../UI/Perps/types/navigation'; +import Routes from '../../../../../constants/navigation/Routes'; +import { navigateToPerpsMarketList } from './perpsNavigation'; + +describe('navigateToPerpsMarketList', () => { + it('navigates to market list with default filter and explore source', () => { + const navigate = jest.fn(); + const navigation = { + navigate, + } as unknown as NavigationProp; + + navigateToPerpsMarketList(navigation); + + expect(navigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_LIST, + params: { + defaultMarketTypeFilter: 'all', + source: PERPS_EVENT_VALUE.SOURCE.EXPLORE, + }, + }); + }); + + it('passes a custom market type filter', () => { + const navigate = jest.fn(); + const navigation = { + navigate, + } as unknown as NavigationProp; + + navigateToPerpsMarketList(navigation, 'commodity'); + + expect(navigate).toHaveBeenCalledWith( + Routes.PERPS.ROOT, + expect.objectContaining({ + params: expect.objectContaining({ + defaultMarketTypeFilter: 'commodity', + }), + }), + ); + }); + + it('passes a custom sort option ID', () => { + const navigate = jest.fn(); + const navigation = { + navigate, + } as unknown as NavigationProp; + + navigateToPerpsMarketList(navigation, 'all', 'priceChange'); + + expect(navigate).toHaveBeenCalledWith( + Routes.PERPS.ROOT, + expect.objectContaining({ + params: expect.objectContaining({ + defaultSortOptionId: 'priceChange', + }), + }), + ); + }); + + it('does not include defaultSortOptionId when not provided', () => { + const navigate = jest.fn(); + const navigation = { + navigate, + } as unknown as NavigationProp; + + navigateToPerpsMarketList(navigation, 'crypto'); + + const callParams = navigate.mock.calls[0][1].params; + expect(callParams).not.toHaveProperty('defaultSortOptionId'); + }); +}); diff --git a/app/components/Views/TrendingView/feeds/perps/perpsNavigation.ts b/app/components/Views/TrendingView/feeds/perps/perpsNavigation.ts new file mode 100644 index 00000000000..df7fd5d64a2 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/perpsNavigation.ts @@ -0,0 +1,23 @@ +import { NavigationProp } from '@react-navigation/native'; +import { + PERPS_EVENT_VALUE, + type SortOptionId, +} from '@metamask/perps-controller'; +import type { PerpsNavigationParamList } from '../../../../UI/Perps/types/navigation'; +import Routes from '../../../../../constants/navigation/Routes'; + +/** Navigate to the perps market list, optionally pre-filtering by market type and pre-sorting by a sort option. */ +export const navigateToPerpsMarketList = ( + navigation: NavigationProp, + filter: string = 'all', + sortOptionId?: SortOptionId, +): void => { + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_LIST, + params: { + defaultMarketTypeFilter: filter, + source: PERPS_EVENT_VALUE.SOURCE.EXPLORE, + ...(sortOptionId !== undefined && { defaultSortOptionId: sortOptionId }), + }, + }); +}; diff --git a/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.test.ts b/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.test.ts new file mode 100644 index 00000000000..0c76f327dd6 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.test.ts @@ -0,0 +1,226 @@ +/** + * usePerpsFeed — unit tests + * + * Focuses on the sorting/ordering logic that lives inside the useMemo: + * 1. No-query path: items sorted by the variant's comparator. + * 2. Query path (non-macro): Fuse.js relevance order is preserved. + * 3. Query path (macro): sorted by volume even when a query is present. + * 4. defaultSortOptionId matches PERPS_VARIANT_SORT_OPTION for each variant. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import type { PerpsMarketData } from '@metamask/perps-controller'; +import { usePerpsFeed, PERPS_VARIANT_SORT_OPTION } from './usePerpsFeed'; + +// --------------------------------------------------------------------------- +// Core dependency mocks +// --------------------------------------------------------------------------- + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(() => []), +})); + +const mockMarkets: PerpsMarketData[] = []; +const mockRefetch = jest.fn(); + +jest.mock('../../../../UI/Perps/hooks', () => ({ + usePerpsMarkets: jest.fn(() => ({ + markets: mockMarkets, + isLoading: false, + refresh: mockRefetch, + isRefreshing: false, + })), +})); + +jest.mock('../../../../UI/Perps/providers/PerpsConnectionProvider', () => ({ + PerpsConnectionContext: { _currentValue: null }, +})); + +jest.mock('../../../../UI/Perps/selectors/perpsController', () => ({ + selectPerpsWatchlistMarkets: jest.fn(), +})); + +jest.mock( + '../../../Homepage/Sections/Perpetuals/hooks/useHomepageSparklines', + () => ({ + useHomepageSparklines: jest.fn(() => ({ sparklines: {} })), + }), +); + +jest.mock('../../hooks/useFeedRefresh', () => ({ + useFeedRefresh: jest.fn(), +})); + +// --------------------------------------------------------------------------- +// fuseSearch mock — controllable so we can verify order is preserved +// --------------------------------------------------------------------------- + +const mockFuseSearch = jest.fn(); +jest.mock('../search-utils', () => ({ + fuseSearch: (...args: unknown[]) => mockFuseSearch(...args), + PERPS_FUSE_OPTIONS: {}, +})); + +jest.mock('@metamask/perps-controller', () => ({ + filterMarketsByQuery: jest.fn((items: unknown[]) => items), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +import { usePerpsMarkets } from '../../../../UI/Perps/hooks'; + +const makeMarket = ( + symbol: string, + change24hPercent: string, + volumeNumber: number, +): PerpsMarketData => + ({ + symbol, + name: symbol, + change24hPercent, + volumeNumber, + marketType: 'equity', + isHip3: false, + }) as unknown as PerpsMarketData; + +const renderFeed = (options: Parameters[0] = {}) => + renderHook(() => usePerpsFeed(options)); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('usePerpsFeed', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default: fuseSearch returns items as-is + mockFuseSearch.mockImplementation((items: unknown[]) => items); + }); + + describe('no-query path', () => { + it('sorts all/crypto/rwa variants by 24h price change descending', () => { + const markets = [ + makeMarket('LOW', '1', 100), + makeMarket('HIGH', '5', 50), + makeMarket('MID', '3', 75), + ]; + (usePerpsMarkets as jest.Mock).mockReturnValue({ + markets, + isLoading: false, + refresh: mockRefetch, + isRefreshing: false, + }); + + for (const variant of ['all', 'crypto', 'rwa'] as const) { + const { result } = renderFeed({ variant }); + const symbols = result.current.data.map((d) => d.market.symbol); + expect(symbols).toEqual(['HIGH', 'MID', 'LOW']); + } + }); + + it('sorts macro variant by volume descending', () => { + const markets = [ + makeMarket('LOW_VOL', '5', 10), + makeMarket('HIGH_VOL', '1', 200), + makeMarket('MID_VOL', '3', 100), + ].map((m) => ({ ...m, marketType: 'equity' as const })); + + (usePerpsMarkets as jest.Mock).mockReturnValue({ + markets, + isLoading: false, + refresh: mockRefetch, + isRefreshing: false, + }); + + const { result } = renderFeed({ variant: 'macro' }); + const symbols = result.current.data.map((d) => d.market.symbol); + expect(symbols).toEqual(['HIGH_VOL', 'MID_VOL', 'LOW_VOL']); + }); + }); + + describe('query path', () => { + it('preserves Fuse.js relevance order for non-macro variants', () => { + const markets = [ + makeMarket('BTC', '1', 100), + makeMarket('ETH', '5', 50), + makeMarket('SOL', '3', 75), + ]; + (usePerpsMarkets as jest.Mock).mockReturnValue({ + markets, + isLoading: false, + refresh: mockRefetch, + isRefreshing: false, + }); + + // Fuse returns a specific relevance order (SOL first, then ETH, then BTC) + const fuseRelevanceOrder = [ + makeMarket('SOL', '3', 75), + makeMarket('ETH', '5', 50), + makeMarket('BTC', '1', 100), + ]; + mockFuseSearch.mockReturnValue(fuseRelevanceOrder); + + for (const variant of ['all', 'crypto', 'rwa'] as const) { + const { result } = renderFeed({ variant, query: 'S' }); + const symbols = result.current.data.map((d) => d.market.symbol); + // Must match fuse order, NOT sorted by price change (which would be ETH→SOL→BTC) + expect(symbols).toEqual(['SOL', 'ETH', 'BTC']); + } + }); + + it('sorts macro fuse results by volume, overriding relevance order', () => { + const markets = [ + makeMarket('AAPL', '1', 10), + makeMarket('MSFT', '5', 200), + makeMarket('NVDA', '3', 100), + ].map((m) => ({ ...m, marketType: 'equity' as const })); + + (usePerpsMarkets as jest.Mock).mockReturnValue({ + markets, + isLoading: false, + refresh: mockRefetch, + isRefreshing: false, + }); + + // Fuse returns relevance order: AAPL first + mockFuseSearch.mockReturnValue([ + markets[0], // AAPL — low volume, but top relevance match + markets[1], // MSFT + markets[2], // NVDA + ]); + + const { result } = renderFeed({ variant: 'macro', query: 'A' }); + const symbols = result.current.data.map((d) => d.market.symbol); + // Must be sorted by volume desc, NOT fuse order + expect(symbols).toEqual(['MSFT', 'NVDA', 'AAPL']); + }); + }); + + describe('defaultSortOptionId', () => { + it.each([ + ['all', 'priceChange'], + ['crypto', 'priceChange'], + ['rwa', 'priceChange'], + ['macro', 'volume'], + ] as const)( + 'returns "%s" for variant "%s"', + (variant, expectedSortOptionId) => { + (usePerpsMarkets as jest.Mock).mockReturnValue({ + markets: [], + isLoading: false, + refresh: mockRefetch, + isRefreshing: false, + }); + + const { result } = renderFeed({ variant }); + expect(result.current.defaultSortOptionId).toBe(expectedSortOptionId); + // Also verify it matches the canonical map + expect(result.current.defaultSortOptionId).toBe( + PERPS_VARIANT_SORT_OPTION[variant], + ); + }, + ); + }); +}); diff --git a/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.ts b/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.ts new file mode 100644 index 00000000000..d1422a5ff6d --- /dev/null +++ b/app/components/Views/TrendingView/feeds/perps/usePerpsFeed.ts @@ -0,0 +1,174 @@ +import { useContext, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { + filterMarketsByQuery, + type PerpsMarketData, + type SortOptionId, +} from '@metamask/perps-controller'; +import { usePerpsMarkets } from '../../../../UI/Perps/hooks'; +import type { PerpsMarketDataWithVolumeNumber } from '../../../../UI/Perps/hooks/usePerpsMarkets'; +import { PerpsConnectionContext } from '../../../../UI/Perps/providers/PerpsConnectionProvider'; +import { selectPerpsWatchlistMarkets } from '../../../../UI/Perps/selectors/perpsController'; +import { useHomepageSparklines } from '../../../Homepage/Sections/Perpetuals/hooks/useHomepageSparklines'; +import { useFeedRefresh } from '../../hooks/useFeedRefresh'; +import type { RefreshConfig } from '../../hooks/useExploreRefresh'; +import { TILE_CAROUSEL_DEFAULT_MAX_TILES } from '../../components/TileCarousel'; +import { fuseSearch, PERPS_FUSE_OPTIONS } from '../search-utils'; + +const EMPTY_WATCHLIST_SYMBOLS: string[] = []; + +export type PerpsVariant = 'all' | 'crypto' | 'rwa' | 'macro'; + +interface UsePerpsFeedOptions { + /** @default 'all' */ + variant?: PerpsVariant; + query?: string; + refresh?: RefreshConfig; + /** + * When true, fetch sparklines + watchlist flags and attach them to each item + * (tile rendering needs this; row rendering does not). + */ + withTileExtras?: boolean; +} + +/** Per-item enrichment merged in when `withTileExtras` is true. */ +export interface PerpsFeedItem { + market: PerpsMarketData; + sparkline?: number[]; + isWatchlisted: boolean; +} + +export interface UsePerpsFeedResult { + data: PerpsFeedItem[]; + isLoading: boolean; + refetch: () => Promise; + /** The sort option ID that matches this feed's sort order — pass to `navigateToPerpsMarketList` so the market list opens consistently sorted. */ + defaultSortOptionId: SortOptionId; +} + +/** + * Maps each feed variant to the sort option ID it uses when displaying items. + * This is the single source of truth — both the feed's internal sort and the + * "View All" navigation use this mapping so they stay in sync. + */ +export const PERPS_VARIANT_SORT_OPTION: Record = { + all: 'priceChange', + crypto: 'priceChange', + rwa: 'priceChange', + macro: 'volume', +}; + +const sortByVolumeDesc = (a: PerpsMarketData, b: PerpsMarketData) => { + const av = (a as PerpsMarketDataWithVolumeNumber).volumeNumber ?? 0; + const bv = (b as PerpsMarketDataWithVolumeNumber).volumeNumber ?? 0; + return bv - av; +}; + +const sortByChange24hDesc = (a: PerpsMarketData, b: PerpsMarketData) => + (parseFloat(b.change24hPercent) || 0) - (parseFloat(a.change24hPercent) || 0); + +/** Maps each SortOptionId to the comparator used inside the feed. */ +const SORT_FNS: Record< + SortOptionId, + (a: PerpsMarketData, b: PerpsMarketData) => number +> = { + volume: sortByVolumeDesc, + priceChange: sortByChange24hDesc, + openInterest: sortByVolumeDesc, + fundingRate: sortByVolumeDesc, +}; + +const filterByVariant = ( + markets: PerpsMarketData[], + variant: PerpsVariant, +): PerpsMarketData[] => { + switch (variant) { + case 'crypto': + return markets.filter((m) => !m.isHip3); + case 'rwa': + return markets.filter( + (m) => + m.marketType === 'equity' || + m.marketType === 'commodity' || + m.marketType === 'forex', + ); + case 'macro': + return markets.filter( + (m) => m.marketType === 'equity' || m.marketType === 'commodity', + ); + case 'all': + default: + return markets; + } +}; + +/** + * Perps markets feed. Returns enriched items (market + optional sparkline + + * watchlist flag) so consumers don't have to stitch data themselves. + * + * **Consumers must be wrapped in ``** — `usePerpsMarkets` + * reads from perps contexts. + */ +export const usePerpsFeed = ({ + variant = 'all', + query, + refresh, + withTileExtras = false, +}: UsePerpsFeedOptions = {}): UsePerpsFeedResult => { + const connectionContext = useContext(PerpsConnectionContext); + const { + markets, + isLoading, + refresh: refetch, + isRefreshing, + } = usePerpsMarkets(); + + useFeedRefresh(refresh, refetch); + + const filtered = useMemo(() => { + if (connectionContext?.error) return []; + const subset = filterByVariant(markets, variant); + const sortFn = SORT_FNS[PERPS_VARIANT_SORT_OPTION[variant]]; + if (!query) { + return [...subset].sort(sortFn); + } + const queryFiltered = filterMarketsByQuery(subset, query); + const fused = fuseSearch(queryFiltered, query, PERPS_FUSE_OPTIONS); + // Preserve Fuse.js relevance ordering for variants that sort by price change + // (the relevance signal is more useful than a metric sort during search). + // Macro sorts by volume even in search results, consistent with its feed order. + return variant === 'macro' ? [...fused].sort(sortFn) : fused; + }, [connectionContext?.error, markets, variant, query]); + + // Only visible carousel tiles need candle sparklines; each symbol is a stream + // subscription (Hyperliquid). TileCarousel caps display at TILE_CAROUSEL_DEFAULT_MAX_TILES. + const symbols = useMemo( + () => + withTileExtras + ? filtered + .slice(0, TILE_CAROUSEL_DEFAULT_MAX_TILES) + .map((m) => m.symbol) + : [], + [filtered, withTileExtras], + ); + const { sparklines } = useHomepageSparklines(symbols); + const watchlistSymbols = + useSelector(selectPerpsWatchlistMarkets) ?? EMPTY_WATCHLIST_SYMBOLS; + + const data = useMemo( + () => + filtered.map((market) => ({ + market, + sparkline: withTileExtras ? sparklines[market.symbol] : undefined, + isWatchlisted: watchlistSymbols.includes(market.symbol), + })), + [filtered, sparklines, watchlistSymbols, withTileExtras], + ); + + return { + data, + isLoading: connectionContext?.error ? false : isLoading || isRefreshing, + refetch, + defaultSortOptionId: PERPS_VARIANT_SORT_OPTION[variant], + }; +}; diff --git a/app/components/Views/TrendingView/feeds/predictions/PredictionRowItem.tsx b/app/components/Views/TrendingView/feeds/predictions/PredictionRowItem.tsx new file mode 100644 index 00000000000..47140b46a6a --- /dev/null +++ b/app/components/Views/TrendingView/feeds/predictions/PredictionRowItem.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Box } from '@metamask/design-system-react-native'; +import PredictMarket from '../../../../UI/Predict/components/PredictMarket'; +import PredictMarketRowItem from '../../../../UI/Predict/components/PredictMarketRowItem'; +import type { PredictMarket as PredictMarketType } from '../../../../UI/Predict/types'; + +interface PredictionCarouselRowItemProps { + market: PredictMarketType; + testIdPrefix?: string; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; + /** Called when the user taps a buy button (before betslip opens). */ + onBuyButtonPress?: (marketId: string) => void; +} + +/** Carousel-style market card used inside Explore home tabs. */ +export const PredictionCarouselRowItem: React.FC< + PredictionCarouselRowItemProps +> = ({ market, testIdPrefix, onCardPress, onBuyButtonPress }) => ( + + + +); + +interface PredictionSearchRowItemProps { + market: PredictMarketType; +} + +/** Compact list row used inside the omni-search results. */ +export const PredictionSearchRowItem: React.FC< + PredictionSearchRowItemProps +> = ({ market }) => ; diff --git a/app/components/Views/TrendingView/feeds/predictions/PredictionsSkeleton.tsx b/app/components/Views/TrendingView/feeds/predictions/PredictionsSkeleton.tsx new file mode 100644 index 00000000000..b82dd4ca478 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/predictions/PredictionsSkeleton.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import PredictMarketSkeleton from '../../../../UI/Predict/components/PredictMarketSkeleton'; + +const PredictionsSkeleton: React.FC = () => ( + +); + +export default PredictionsSkeleton; diff --git a/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.test.ts b/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.test.ts new file mode 100644 index 00000000000..5aac5ab690f --- /dev/null +++ b/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.test.ts @@ -0,0 +1,41 @@ +import type { AppNavigationProp } from '../../../../../core/NavigationService/types'; +import Routes from '../../../../../constants/navigation/Routes'; +import { navigateToPredictionsList } from './predictionsNavigation'; + +describe('navigateToPredictionsList', () => { + it('navigates without tab params for trending variant', () => { + const navigate = jest.fn(); + const navigation = { navigate } as unknown as AppNavigationProp; + + navigateToPredictionsList(navigation, 'trending'); + + expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + params: undefined, + }); + }); + + it('includes tab param for sports variant', () => { + const navigate = jest.fn(); + const navigation = { navigate } as unknown as AppNavigationProp; + + navigateToPredictionsList(navigation, 'sports'); + + expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + params: { tab: 'sports' }, + }); + }); + + it('includes tab param for crypto variant', () => { + const navigate = jest.fn(); + const navigation = { navigate } as unknown as AppNavigationProp; + + navigateToPredictionsList(navigation, 'crypto'); + + expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + params: { tab: 'crypto' }, + }); + }); +}); diff --git a/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.ts b/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.ts new file mode 100644 index 00000000000..897ee5a89ca --- /dev/null +++ b/app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.ts @@ -0,0 +1,22 @@ +import type { AppNavigationProp } from '../../../../../core/NavigationService/types'; +import Routes from '../../../../../constants/navigation/Routes'; +import type { PredictionsVariant } from './usePredictionsFeed'; + +const VARIANT_TO_TAB: Record = { + trending: undefined, + sports: 'sports', + crypto: 'crypto', + politics: 'politics', +}; + +/** Navigate to the Predict market list, optionally pre-selecting a tab. */ +export const navigateToPredictionsList = ( + navigation: AppNavigationProp, + variant: PredictionsVariant, +): void => { + const tab = VARIANT_TO_TAB[variant]; + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + params: tab ? { tab } : undefined, + }); +}; diff --git a/app/components/Views/TrendingView/feeds/predictions/usePredictionsFeed.ts b/app/components/Views/TrendingView/feeds/predictions/usePredictionsFeed.ts new file mode 100644 index 00000000000..b2a11c99805 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/predictions/usePredictionsFeed.ts @@ -0,0 +1,43 @@ +import { useMemo } from 'react'; +import type { PredictMarket as PredictMarketType } from '../../../../UI/Predict/types'; +import { usePredictMarketData } from '../../../../UI/Predict/hooks/usePredictMarketData'; +import { useFeedRefresh } from '../../hooks/useFeedRefresh'; +import type { RefreshConfig } from '../../hooks/useExploreRefresh'; +import { fuseSearch, PREDICTIONS_FUSE_OPTIONS } from '../search-utils'; + +export type PredictionsVariant = 'trending' | 'sports' | 'crypto' | 'politics'; + +interface UsePredictionsFeedOptions { + /** @default 'trending' */ + variant?: PredictionsVariant; + query?: string; + refresh?: RefreshConfig; +} + +export interface UsePredictionsFeedResult { + data: PredictMarketType[]; + isLoading: boolean; + refetch: () => Promise; +} + +/** Predict markets feed; one shape covers home tabs and search via the variant + query knobs. */ +export const usePredictionsFeed = ({ + variant = 'trending', + query, + refresh, +}: UsePredictionsFeedOptions = {}): UsePredictionsFeedResult => { + const { marketData, isFetching, refetch } = usePredictMarketData({ + category: variant, + pageSize: query ? 20 : 6, + q: query || undefined, + }); + + useFeedRefresh(refresh, refetch); + + const filteredData = useMemo( + () => fuseSearch(marketData, query, PREDICTIONS_FUSE_OPTIONS), + [marketData, query], + ); + + return { data: filteredData, isLoading: isFetching, refetch }; +}; diff --git a/app/components/Views/TrendingView/feeds/predictions/useSportsMarketsFeed.test.ts b/app/components/Views/TrendingView/feeds/predictions/useSportsMarketsFeed.test.ts new file mode 100644 index 00000000000..6910a5821cb --- /dev/null +++ b/app/components/Views/TrendingView/feeds/predictions/useSportsMarketsFeed.test.ts @@ -0,0 +1,79 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { + usePredictMarketData, + type UsePredictMarketDataOptions, + type UsePredictMarketDataResult, +} from '../../../../UI/Predict/hooks/usePredictMarketData'; +import { useSportsMarketsFeed } from './useSportsMarketsFeed'; + +jest.mock('../../../../UI/Predict/hooks/usePredictMarketData'); + +const mockUsePredictMarketData = jest.mocked(usePredictMarketData); + +const createResult = (refetch: jest.Mock): UsePredictMarketDataResult => ({ + marketData: [], + isFetching: false, + isFetchingMore: false, + error: null, + hasMore: false, + refetch: refetch as unknown as () => Promise, + fetchMore: jest.fn().mockResolvedValue(undefined), +}); + +describe('useSportsMarketsFeed', () => { + const refetchBySport: Record = {}; + + beforeEach(() => { + jest.clearAllMocks(); + Object.keys(refetchBySport).forEach((k) => delete refetchBySport[k]); + + mockUsePredictMarketData.mockImplementation( + (opts?: UsePredictMarketDataOptions) => { + const customQueryParams = opts?.customQueryParams; + const key = + customQueryParams === 'tag_id=100350' + ? 'soccer' + : customQueryParams === 'tag_id=28' + ? 'basketball' + : 'tennis'; + if (!refetchBySport[key]) { + refetchBySport[key] = jest.fn().mockResolvedValue(undefined); + } + return createResult(refetchBySport[key]); + }, + ); + }); + + it('exposes three sport pills and defaults to soccer', () => { + const { result } = renderHook(() => useSportsMarketsFeed()); + + expect(result.current.pills).toHaveLength(3); + expect(result.current.pills.map((p) => p.key)).toEqual([ + 'soccer', + 'basketball', + 'tennis', + ]); + expect(result.current.activeKey).toBe('soccer'); + }); + + it('select loads an additional sport and refetch calls all loaded feeds', async () => { + const { result } = renderHook(() => useSportsMarketsFeed()); + + await act(async () => { + await result.current.refetch(); + }); + expect(refetchBySport.soccer).toHaveBeenCalledTimes(1); + expect(refetchBySport.basketball).not.toHaveBeenCalled(); + + act(() => { + result.current.select('basketball'); + }); + expect(result.current.activeKey).toBe('basketball'); + + await act(async () => { + await result.current.refetch(); + }); + expect(refetchBySport.soccer).toHaveBeenCalledTimes(2); + expect(refetchBySport.basketball).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/Views/TrendingView/feeds/predictions/useSportsMarketsFeed.ts b/app/components/Views/TrendingView/feeds/predictions/useSportsMarketsFeed.ts new file mode 100644 index 00000000000..aae91d83276 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/predictions/useSportsMarketsFeed.ts @@ -0,0 +1,110 @@ +import { useCallback, useMemo, useState } from 'react'; +import { + usePredictMarketData, + type UsePredictMarketDataResult, +} from '../../../../UI/Predict/hooks/usePredictMarketData'; +import { strings } from '../../../../../../locales/i18n'; +import { useFeedRefresh } from '../../hooks/useFeedRefresh'; +import type { RefreshConfig } from '../../hooks/useExploreRefresh'; +import type { PillOption } from '../../components/PillRow'; + +const PAGE_SIZE = 20; + +// Tag IDs from gamma-api.polymarket.com/tags. To add a sport: +// add a row here AND a usePredictMarketData call below (Rules of Hooks). +const SOCCER = { + key: 'soccer', + labelKey: 'trending.football', + customQueryParams: 'tag_id=100350', +} as const; +const BASKETBALL = { + key: 'basketball', + labelKey: 'trending.basketball', + customQueryParams: 'tag_id=28', +} as const; +const TENNIS = { + key: 'tennis', + labelKey: 'trending.tennis', + customQueryParams: 'tag_id=864', +} as const; + +const TABS = [SOCCER, BASKETBALL, TENNIS] as const; + +export interface UseSportsMarketsFeedResult { + pills: PillOption[]; + activeKey: string; + select: (key: string) => void; + active: UsePredictMarketDataResult; + isLoading: boolean; + refetch: () => Promise; +} + +interface UseSportsMarketsFeedOptions { + refresh?: RefreshConfig; +} + +/** + * Per-sport markets feed for the "All Sports" section. Owns pill state and + * lazily enables fetches for sports the user has visited. + */ +export const useSportsMarketsFeed = ({ + refresh, +}: UseSportsMarketsFeedOptions = {}): UseSportsMarketsFeedResult => { + const [activeKey, setActiveKey] = useState(SOCCER.key); + const [loadedKeys, setLoadedKeys] = useState>( + () => new Set([SOCCER.key]), + ); + + const soccer = usePredictMarketData({ + category: 'sports', + customQueryParams: SOCCER.customQueryParams, + pageSize: PAGE_SIZE, + enabled: loadedKeys.has(SOCCER.key), + }); + const basketball = usePredictMarketData({ + category: 'sports', + customQueryParams: BASKETBALL.customQueryParams, + pageSize: PAGE_SIZE, + enabled: loadedKeys.has(BASKETBALL.key), + }); + const tennis = usePredictMarketData({ + category: 'sports', + customQueryParams: TENNIS.customQueryParams, + pageSize: PAGE_SIZE, + enabled: loadedKeys.has(TENNIS.key), + }); + + const marketsByKey: Record = useMemo( + () => ({ soccer, basketball, tennis }), + [soccer, basketball, tennis], + ); + + const pills = useMemo( + () => TABS.map((tab) => ({ key: tab.key, name: strings(tab.labelKey) })), + [], + ); + + const select = useCallback((key: string) => { + setActiveKey(key); + setLoadedKeys((prev) => new Set(prev).add(key)); + }, []); + + const { refetch: refetchSoccer } = soccer; + const { refetch: refetchBasketball } = basketball; + const { refetch: refetchTennis } = tennis; + + const refetch = useCallback(async () => { + const tasks: Promise[] = []; + if (loadedKeys.has(SOCCER.key)) tasks.push(refetchSoccer()); + if (loadedKeys.has(BASKETBALL.key)) tasks.push(refetchBasketball()); + if (loadedKeys.has(TENNIS.key)) tasks.push(refetchTennis()); + await Promise.all(tasks); + }, [loadedKeys, refetchSoccer, refetchBasketball, refetchTennis]); + + useFeedRefresh(refresh, refetch); + + const active = marketsByKey[activeKey]; + const isLoading = active.isFetching && active.marketData.length === 0; + + return { pills, activeKey, select, active, isLoading, refetch }; +}; diff --git a/app/components/Views/TrendingView/feeds/search-utils.ts b/app/components/Views/TrendingView/feeds/search-utils.ts new file mode 100644 index 00000000000..dfc79f363fd --- /dev/null +++ b/app/components/Views/TrendingView/feeds/search-utils.ts @@ -0,0 +1,74 @@ +import Fuse, { type FuseOptions } from 'fuse.js'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import type { PerpsMarketData } from '@metamask/perps-controller'; +import type { PredictMarket as PredictMarketType } from '../../../UI/Predict/types'; +import { + TimeOption, + PriceChangeOption, +} from '../../../UI/Trending/components/TrendingTokensBottomSheet'; +import type { TrendingFilterContext } from '../../../UI/Trending/components/TrendingTokensList/TrendingTokensList'; + +const BASE_FUSE_OPTIONS = { + shouldSort: true, + threshold: 0.2, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, +} as const; + +export const TOKEN_FUSE_OPTIONS: FuseOptions = { + ...BASE_FUSE_OPTIONS, + keys: ['symbol', 'name', 'assetId'], +}; + +export const PERPS_FUSE_OPTIONS: FuseOptions = { + ...BASE_FUSE_OPTIONS, + keys: ['symbol', 'name'], +}; + +export const PREDICTIONS_FUSE_OPTIONS: FuseOptions = { + ...BASE_FUSE_OPTIONS, + keys: ['title', 'description'], +}; + +export const fuseSearch = ( + data: T[], + searchQuery: string | undefined, + fuseOptions: FuseOptions, + searchSortingFn?: (a: T, b: T) => number, +): T[] => { + const trimmed = searchQuery?.trim(); + if (!trimmed) return data; + const fuse = new Fuse(data, fuseOptions); + const results = fuse.search(trimmed); + return searchSortingFn ? results.sort(searchSortingFn) : results; +}; + +export const DEFAULT_TOKENS_FILTER_CONTEXT: TrendingFilterContext = { + timeFilter: TimeOption.TwentyFourHours, + sortOption: PriceChangeOption.PriceChange, + networkFilter: 'all', + isSearchResult: false, +}; + +export const SEARCH_TOKENS_FILTER_CONTEXT: TrendingFilterContext = { + timeFilter: TimeOption.TwentyFourHours, + sortOption: PriceChangeOption.PriceChange, + networkFilter: 'all', + isSearchResult: true, +}; + +export const CRYPTO_MOVERS_SEARCH_FILTER_CONTEXT: TrendingFilterContext = { + timeFilter: TimeOption.TwentyFourHours, + sortOption: PriceChangeOption.Volume, + networkFilter: 'all', + isSearchResult: true, +}; + +export const CRYPTO_MOVERS_HOME_FILTER_CONTEXT: TrendingFilterContext = { + timeFilter: TimeOption.TwentyFourHours, + sortOption: PriceChangeOption.Volume, + networkFilter: 'all', + isSearchResult: false, +}; diff --git a/app/components/Views/TrendingView/feeds/sites/SiteRowItem.tsx b/app/components/Views/TrendingView/feeds/sites/SiteRowItem.tsx new file mode 100644 index 00000000000..0c53d1687d3 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/sites/SiteRowItem.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { useDispatch } from 'react-redux'; +import SiteRowItemBase, { + bookmarkUrlForRemoval, + type SiteData, +} from '../../../../UI/Sites/components/SiteRowItem/SiteRowItem'; +import Routes from '../../../../../constants/navigation/Routes'; +import { removeBookmark } from '../../../../../actions/bookmarks'; +import type { AppNavigationProp } from '../../../../../core/NavigationService/types'; + +const openSiteInBrowser = (navigation: AppNavigationProp, site: SiteData) => { + navigation.navigate(Routes.BROWSER.HOME, { + screen: Routes.BROWSER.VIEW, + params: { + newTabUrl: site.url, + timestamp: Date.now(), + fromTrending: true, + }, + }); +}; + +interface SiteRowItemProps { + site: SiteData; + /** Called synchronously before the card's navigation press fires. */ + onCardPress?: () => void; +} + +/** Generic site row (sites + dapps_favorites without remove action). */ +export const SiteRowItem: React.FC = ({ + site, + onCardPress, +}) => { + const navigation = useNavigation(); + return ( + { + onCardPress?.(); + openSiteInBrowser(navigation, site); + }} + /> + ); +}; + +/** Favorite-site row with the "remove from favorites" affordance. */ +export const FavoriteSiteRowItem: React.FC = ({ + site, + onCardPress, +}) => { + const navigation = useNavigation(); + const dispatch = useDispatch(); + return ( + { + onCardPress?.(); + openSiteInBrowser(navigation, site); + }} + onRemoveFavorite={() => + dispatch( + removeBookmark({ + url: bookmarkUrlForRemoval(site), + name: site.name, + }), + ) + } + /> + ); +}; diff --git a/app/components/Views/TrendingView/feeds/sites/useSitesFeed.ts b/app/components/Views/TrendingView/feeds/sites/useSitesFeed.ts new file mode 100644 index 00000000000..e4e6c3c68c5 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/sites/useSitesFeed.ts @@ -0,0 +1,25 @@ +import { useSitesData } from '../../../../UI/Sites/hooks/useSiteData/useSitesData'; +import { useFeedRefresh } from '../../hooks/useFeedRefresh'; +import type { RefreshConfig } from '../../hooks/useExploreRefresh'; +import type { SiteData } from '../../../../UI/Sites/components/SiteRowItem/SiteRowItem'; + +interface UseSitesFeedOptions { + query?: string; + refresh?: RefreshConfig; +} + +export interface UseSitesFeedResult { + data: SiteData[]; + isLoading: boolean; + refetch: () => void; +} + +/** Curated sites feed (Explore "Sites" section + search). */ +export const useSitesFeed = ({ + query, + refresh, +}: UseSitesFeedOptions = {}): UseSitesFeedResult => { + const { sites, isLoading, refetch } = useSitesData(query); + useFeedRefresh(refresh, refetch); + return { data: sites, isLoading, refetch }; +}; diff --git a/app/components/Views/TrendingView/feeds/stocks/useStocksFeed.test.ts b/app/components/Views/TrendingView/feeds/stocks/useStocksFeed.test.ts new file mode 100644 index 00000000000..9c4bc9c15c2 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/stocks/useStocksFeed.test.ts @@ -0,0 +1,100 @@ +import { renderHook } from '@testing-library/react-native'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import { useRwaTokens } from '../../../../UI/Trending/hooks/useRwaTokens/useRwaTokens'; +import { useStocksFeed } from './useStocksFeed'; + +jest.mock('../../../../UI/Trending/hooks/useRwaTokens/useRwaTokens', () => ({ + useRwaTokens: jest.fn(), +})); + +const mockUseRwaTokens = jest.mocked(useRwaTokens); +const mockRefetch = jest.fn(); + +const makeAsset = (assetId: string, symbol: string): TrendingAsset => + ({ + assetId, + symbol, + name: symbol, + }) as unknown as TrendingAsset; + +const ETH_OUSG = makeAsset('eip155:1/erc20:0xaaa', 'OUSG'); +const ETH_BUIDL = makeAsset('eip155:1/erc20:0xbbb', 'BUIDL'); +const BNB_OUSG = makeAsset('eip155:56/erc20:0xccc', 'bOUSG'); + +const ALL_RWA_ASSETS = [ETH_OUSG, ETH_BUIDL, BNB_OUSG]; + +const arrangeRwaTokens = (assets = ALL_RWA_ASSETS) => { + mockUseRwaTokens.mockReturnValue({ + data: assets, + isLoading: false, + refetch: mockRefetch, + }); +}; + +describe('useStocksFeed', () => { + beforeEach(() => { + jest.clearAllMocks(); + arrangeRwaTokens(); + }); + + describe('no-query path (tab sections)', () => { + it('filters to Ethereum-only assets', () => { + const { result } = renderHook(() => useStocksFeed()); + const symbols = result.current.data.map((d) => d.symbol); + expect(symbols).toEqual(['OUSG', 'BUIDL']); + expect(symbols).not.toContain('bOUSG'); + }); + + it('passes undefined searchQuery to useRwaTokens', () => { + renderHook(() => useStocksFeed()); + expect(mockUseRwaTokens).toHaveBeenCalledWith( + expect.objectContaining({ searchQuery: undefined }), + ); + }); + }); + + describe('query path (omni-search)', () => { + it('includes tokens from all RWA chains, not just Ethereum', () => { + const { result } = renderHook(() => useStocksFeed({ query: 'OUSG' })); + const symbols = result.current.data.map((d) => d.symbol); + expect(symbols).toContain('OUSG'); + expect(symbols).toContain('bOUSG'); + }); + + it('does not filter out BNB tokens when a query is present', () => { + const { result } = renderHook(() => useStocksFeed({ query: 'token' })); + expect(result.current.data).toHaveLength(ALL_RWA_ASSETS.length); + }); + + it('passes the query through to useRwaTokens as searchQuery', () => { + renderHook(() => useStocksFeed({ query: 'OUSG' })); + expect(mockUseRwaTokens).toHaveBeenCalledWith( + expect.objectContaining({ searchQuery: 'OUSG' }), + ); + }); + + it('treats a whitespace-only query the same as no query (Ethereum-only)', () => { + const { result } = renderHook(() => useStocksFeed({ query: ' ' })); + const symbols = result.current.data.map((d) => d.symbol); + expect(symbols).toEqual(['OUSG', 'BUIDL']); + expect(symbols).not.toContain('bOUSG'); + }); + }); + + describe('loading and refetch passthrough', () => { + it('forwards isLoading from useRwaTokens', () => { + mockUseRwaTokens.mockReturnValue({ + data: [], + isLoading: true, + refetch: mockRefetch, + }); + const { result } = renderHook(() => useStocksFeed()); + expect(result.current.isLoading).toBe(true); + }); + + it('forwards refetch from useRwaTokens', () => { + const { result } = renderHook(() => useStocksFeed()); + expect(result.current.refetch).toBe(mockRefetch); + }); + }); +}); diff --git a/app/components/Views/TrendingView/feeds/stocks/useStocksFeed.ts b/app/components/Views/TrendingView/feeds/stocks/useStocksFeed.ts new file mode 100644 index 00000000000..73a6b7fc408 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/stocks/useStocksFeed.ts @@ -0,0 +1,53 @@ +import { useMemo } from 'react'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import { useRwaTokens } from '../../../../UI/Trending/hooks/useRwaTokens/useRwaTokens'; +import { useFeedRefresh } from '../../hooks/useFeedRefresh'; +import type { RefreshConfig } from '../../hooks/useExploreRefresh'; + +const ETHEREUM_CAIP_CHAIN_ID = 'eip155:1/'; + +interface UseStocksFeedOptions { + query?: string; + refresh?: RefreshConfig; +} + +export interface UseStocksFeedResult { + data: TrendingAsset[]; + isLoading: boolean; + refetch: () => Promise; +} + +/** + * Tokenized stocks (RWAs) feed. + * + * Tab sections (no query): only Ethereum mainnet tokens are shown, matching + * the design intent of the RWAs/Now tab. + * + * Search (query present): all chains in RWA_CHAIN_IDS are included so users + * can find stocks across Ethereum and BNB. + * + * Chain filtering is done client-side (not in the request) to share the same + * server-side cache across all surfaces. + */ +export const useStocksFeed = ({ + query, + refresh, +}: UseStocksFeedOptions = {}): UseStocksFeedResult => { + const { data, isLoading, refetch } = useRwaTokens({ + searchQuery: query, + }); + + const filteredData = useMemo(() => { + // During search, surface tokens from all supported RWA chains so the user + // can find any matching stock regardless of chain. + if (query?.trim()) return data; + // Tab sections only show Ethereum mainnet tokens. + return data.filter((asset) => + asset.assetId.startsWith(ETHEREUM_CAIP_CHAIN_ID), + ); + }, [data, query]); + + useFeedRefresh(refresh, refetch); + + return { data: filteredData, isLoading, refetch }; +}; diff --git a/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.test.tsx b/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.test.tsx new file mode 100644 index 00000000000..25931d393cf --- /dev/null +++ b/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.test.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import CryptoMoversPillItem from './CryptoMoversPillItem'; + +const mockOnPress = jest.fn(); + +jest.mock( + '../../../../UI/Trending/hooks/useTrendingTokenPress/useTrendingTokenPress', + () => ({ + useTrendingTokenPress: () => ({ onPress: mockOnPress }), + }), +); + +describe('CryptoMoversPillItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const baseToken = { + assetId: 'eip155:1/slip44:60', + symbol: 'ETH', + priceChangePct: {}, + } as unknown as TrendingAsset; + + it('invokes token press when the pill is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(`section-pill-${baseToken.assetId}`)); + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('omits change label when 24h change is missing or not a number', () => { + const { queryByText, rerender } = render( + , + ); + expect(queryByText(/%/)).toBeNull(); + + rerender( + , + ); + expect(queryByText(/%/)).toBeNull(); + }); + + it('renders zero and signed positive and negative 24h changes', () => { + const { getByText, rerender } = render( + , + ); + expect(getByText('0.00%')).toBeTruthy(); + + rerender( + , + ); + expect(getByText('+2.51%')).toBeTruthy(); + + rerender( + , + ); + expect(getByText('-1.20%')).toBeTruthy(); + }); +}); diff --git a/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.tsx b/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.tsx new file mode 100644 index 00000000000..3500053eebc --- /dev/null +++ b/app/components/Views/TrendingView/feeds/tokens/CryptoMoversPillItem.tsx @@ -0,0 +1,119 @@ +import React, { useMemo } from 'react'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import { isCaipChainId } from '@metamask/utils'; +import { TextColor } from '@metamask/design-system-react-native'; +import { TimeOption } from '../../../../UI/Trending/components/TrendingTokensBottomSheet'; +import { + getCaipChainIdFromAssetId, + getNetworkBadgeSource, + getPriceChangeFieldKey, +} from '../../../../UI/Trending/components/TrendingTokenRowItem/utils'; +import TrendingTokenLogo from '../../../../UI/Trending/components/TrendingTokenLogo'; +import Badge, { + BadgeVariant, +} from '../../../../../component-library/components/Badges/Badge'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../../component-library/components/Badges/BadgeWrapper'; +import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; +import { useTrendingTokenPress } from '../../../../UI/Trending/hooks/useTrendingTokenPress/useTrendingTokenPress'; +import { TokenDetailsSource } from '../../../../UI/TokenDetails/constants/constants'; +import { CRYPTO_MOVERS_HOME_FILTER_CONTEXT } from '../search-utils'; +import ExplorePill from '../../components/ExplorePill'; + +const LOGO_SIZE = 24; + +interface CryptoMoversPillItemProps { + token: TrendingAsset; + index: number; + /** Called synchronously before the card's press handler fires. */ + onCardPress?: () => void; +} + +const CryptoMoversPillItem: React.FC = ({ + token, + index, + onCardPress, +}) => { + const { onPress: defaultOnPress } = useTrendingTokenPress({ + token, + index, + filterContext: CRYPTO_MOVERS_HOME_FILTER_CONTEXT, + tokenDetailsSource: TokenDetailsSource.ExploreNowMovers, + }); + + const onPress = React.useCallback(async () => { + onCardPress?.(); + await defaultOnPress(); + }, [onCardPress, defaultOnPress]); + + const networkBadgeImageSource = useMemo(() => { + const caipChainId = getCaipChainIdFromAssetId(token.assetId); + if (!isCaipChainId(caipChainId)) return undefined; + return getNetworkBadgeSource(caipChainId); + }, [token.assetId]); + + const { changeLabel, changeTextColor } = useMemo(() => { + const key = getPriceChangeFieldKey(TimeOption.TwentyFourHours); + const raw = token.priceChangePct?.[key]; + const n = raw !== undefined && raw !== null ? parseFloat(String(raw)) : NaN; + if (isNaN(n)) { + return { + changeLabel: undefined as string | undefined, + changeTextColor: TextColor.TextAlternative, + }; + } + if (n === 0) { + return { + changeLabel: '0.00%', + changeTextColor: TextColor.TextAlternative, + }; + } + return { + changeLabel: `${n > 0 ? '+' : ''}${n.toFixed(2)}%`, + changeTextColor: + n > 0 + ? TextColor.SuccessDefault + : n < 0 + ? TextColor.ErrorDefault + : TextColor.TextAlternative, + }; + }, [token.priceChangePct]); + + const leading = useMemo( + () => ( + + } + > + + + ), + [networkBadgeImageSource, token.assetId, token.symbol], + ); + + return ( + + ); +}; + +export default React.memo(CryptoMoversPillItem); diff --git a/app/components/Views/TrendingView/feeds/tokens/CryptoMoversSkeleton.tsx b/app/components/Views/TrendingView/feeds/tokens/CryptoMoversSkeleton.tsx new file mode 100644 index 00000000000..66049c0bfd5 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/tokens/CryptoMoversSkeleton.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { View, StyleSheet, ScrollView } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, +} from '@metamask/design-system-react-native'; +import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; + +const SKELETON_PILLS_PER_ROW = 6; +const SKELETON_PILL_WIDTH = 104; +const SKELETON_PILL_HEIGHT = 32; + +const styles = StyleSheet.create({ + pill: { borderRadius: 9999 }, +}); + +const SkeletonRow: React.FC<{ prefix: string }> = ({ prefix }) => ( + + {Array.from({ length: SKELETON_PILLS_PER_ROW }).map((_, i) => ( + + + + ))} + +); + +const CryptoMoversSkeleton: React.FC = () => { + const tw = useTailwind(); + + return ( + + + + + + + + + ); +}; + +export default CryptoMoversSkeleton; diff --git a/app/components/Views/TrendingView/feeds/tokens/TokenRowItem.tsx b/app/components/Views/TrendingView/feeds/tokens/TokenRowItem.tsx new file mode 100644 index 00000000000..514e607e164 --- /dev/null +++ b/app/components/Views/TrendingView/feeds/tokens/TokenRowItem.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import TrendingTokenRowItem from '../../../../UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem'; +import { TokenDetailsSource } from '../../../../UI/TokenDetails/constants/constants'; +import { + DEFAULT_TOKENS_FILTER_CONTEXT, + SEARCH_TOKENS_FILTER_CONTEXT, + CRYPTO_MOVERS_SEARCH_FILTER_CONTEXT, +} from '../search-utils'; + +interface TokenRowItemProps { + token: TrendingAsset; + index: number; + /** When omitted, defaults to {@link TokenDetailsSource.Trending} in the row item. */ + tokenDetailsSource?: TokenDetailsSource; + /** Called synchronously before the card's press handler fires. */ + onCardPress?: () => void; +} + +/** Token row used inside the home tabs. */ +export const TokenRowItem: React.FC = ({ + token, + index, + tokenDetailsSource, + onCardPress, +}) => ( + +); + +/** Token row used inside the omni-search results. */ +export const TokenSearchRowItem: React.FC = ({ + token, + index, +}) => ( + +); + +/** "Crypto movers" search row uses a dedicated analytics context. */ +export const CryptoMoversSearchRowItem: React.FC = ({ + token, + index, +}) => ( + +); diff --git a/app/components/Views/TrendingView/feeds/tokens/useTokensFeed.test.ts b/app/components/Views/TrendingView/feeds/tokens/useTokensFeed.test.ts new file mode 100644 index 00000000000..2ea1454de1c --- /dev/null +++ b/app/components/Views/TrendingView/feeds/tokens/useTokensFeed.test.ts @@ -0,0 +1,208 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import { useTrendingSearch } from '../../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; +import type { RefreshConfig } from '../../hooks/useExploreRefresh'; +import { useTokensFeed } from './useTokensFeed'; + +jest.mock( + '../../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch', + () => ({ + useTrendingSearch: jest.fn(), + }), +); + +const mockUseTrendingSearch = jest.mocked(useTrendingSearch); + +describe('useTokensFeed', () => { + const mockRefetch = jest.fn().mockResolvedValue(undefined); + + const sampleTokens = [ + { + assetId: 'eip155:1/erc20:0xaaa', + symbol: 'AAA', + name: 'Alpha Token', + marketCap: 100, + }, + { + assetId: 'eip155:1/erc20:0xbtc', + symbol: 'WBTC', + name: 'Wrapped Bitcoin', + marketCap: 900, + }, + { + assetId: 'eip155:1/erc20:0xeth', + symbol: 'WETH', + name: 'Wrapped Ether', + marketCap: 500, + }, + ] as unknown as TrendingAsset[]; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseTrendingSearch.mockReturnValue({ + data: sampleTokens, + isLoading: false, + refetch: mockRefetch, + }); + }); + + it('returns underlying data unchanged when query is empty', () => { + const { result } = renderHook(() => useTokensFeed({ query: undefined })); + + expect(result.current.data).toEqual(sampleTokens); + expect(result.current.isLoading).toBe(false); + expect(result.current.refetch).toBe(mockRefetch); + }); + + it('filters by query and sorts matches by market cap descending', () => { + const { result } = renderHook(() => useTokensFeed({ query: 'wrap' })); + + expect(result.current.data.map((t) => t.symbol)).toEqual(['WBTC', 'WETH']); + }); + + it('refetches when refresh trigger increments past initial mount', async () => { + const refresh: RefreshConfig = { trigger: 0, silentRefresh: false }; + const { rerender } = renderHook( + ({ r }: { r: RefreshConfig }) => useTokensFeed({ refresh: r }), + { initialProps: { r: refresh } }, + ); + + expect(mockRefetch).not.toHaveBeenCalled(); + + rerender({ r: { trigger: 1, silentRefresh: false } }); + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + }); + + it('passes searchQuery from query option into useTrendingSearch', () => { + renderHook(() => useTokensFeed({ query: 'sol' })); + + expect(mockUseTrendingSearch).toHaveBeenCalledWith({ + searchQuery: 'sol', + enableDebounce: false, + }); + }); + + describe('hideRiskyTokens', () => { + const tokensWithSecurity = [ + { + assetId: 'eip155:1/erc20:0x1', + symbol: 'VER', + name: 'Verified Token', + marketCap: 900, + securityData: { resultType: 'Verified' }, + }, + { + assetId: 'eip155:1/erc20:0x2', + symbol: 'BEN', + name: 'Benign Token', + marketCap: 800, + securityData: { resultType: 'Benign' }, + }, + { + assetId: 'eip155:1/erc20:0x3', + symbol: 'WRN', + name: 'Warning Token', + marketCap: 700, + securityData: { resultType: 'Warning' }, + }, + { + assetId: 'eip155:1/erc20:0x4', + symbol: 'SPM', + name: 'Spam Token', + marketCap: 600, + securityData: { resultType: 'Spam' }, + }, + { + assetId: 'eip155:1/erc20:0x5', + symbol: 'MAL', + name: 'Malicious Token', + marketCap: 500, + securityData: { resultType: 'Malicious' }, + }, + { + assetId: 'eip155:1/erc20:0x6', + symbol: 'UNS', + name: 'Unscanned Token', + marketCap: 400, + }, + ] as unknown as TrendingAsset[]; + + beforeEach(() => { + mockUseTrendingSearch.mockReturnValue({ + data: tokensWithSecurity, + isLoading: false, + refetch: mockRefetch, + }); + }); + + it('returns all tokens when hideRiskyTokens is false (default)', () => { + const { result } = renderHook(() => useTokensFeed()); + + expect(result.current.data.map((t) => t.symbol)).toEqual([ + 'VER', + 'BEN', + 'WRN', + 'SPM', + 'MAL', + 'UNS', + ]); + }); + + it('keeps only Verified, Benign, and unscanned tokens when hideRiskyTokens is true', () => { + const { result } = renderHook(() => + useTokensFeed({ hideRiskyTokens: true }), + ); + + expect(result.current.data.map((t) => t.symbol)).toEqual([ + 'VER', + 'BEN', + 'UNS', + ]); + }); + + it('removes Warning tokens', () => { + mockUseTrendingSearch.mockReturnValue({ + data: [tokensWithSecurity[2]], + isLoading: false, + refetch: mockRefetch, + }); + + const { result } = renderHook(() => + useTokensFeed({ hideRiskyTokens: true }), + ); + + expect(result.current.data).toHaveLength(0); + }); + + it('removes Spam tokens', () => { + mockUseTrendingSearch.mockReturnValue({ + data: [tokensWithSecurity[3]], + isLoading: false, + refetch: mockRefetch, + }); + + const { result } = renderHook(() => + useTokensFeed({ hideRiskyTokens: true }), + ); + + expect(result.current.data).toHaveLength(0); + }); + + it('removes Malicious tokens', () => { + mockUseTrendingSearch.mockReturnValue({ + data: [tokensWithSecurity[4]], + isLoading: false, + refetch: mockRefetch, + }); + + const { result } = renderHook(() => + useTokensFeed({ hideRiskyTokens: true }), + ); + + expect(result.current.data).toHaveLength(0); + }); + }); +}); diff --git a/app/components/Views/TrendingView/feeds/tokens/useTokensFeed.ts b/app/components/Views/TrendingView/feeds/tokens/useTokensFeed.ts new file mode 100644 index 00000000000..e6f1d3072bb --- /dev/null +++ b/app/components/Views/TrendingView/feeds/tokens/useTokensFeed.ts @@ -0,0 +1,57 @@ +import { useMemo } from 'react'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import { useTrendingSearch } from '../../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; +import { useFeedRefresh } from '../../hooks/useFeedRefresh'; +import type { RefreshConfig } from '../../hooks/useExploreRefresh'; +import { fuseSearch, TOKEN_FUSE_OPTIONS } from '../search-utils'; + +interface UseTokensFeedOptions { + /** Search query; when present, results are sorted by market cap descending. */ + query?: string; + refresh?: RefreshConfig; + /** + * When true, only Verified and Benign tokens (or unscanned ones) are shown. + * Use for surfaces that don't display a security badge. + */ + hideRiskyTokens?: boolean; +} + +export interface UseTokensFeedResult { + data: TrendingAsset[]; + isLoading: boolean; + refetch: () => Promise; +} + +/** Trending tokens feed; same source for the home list, "crypto movers" pills, and search. */ +export const useTokensFeed = ({ + query, + refresh, + hideRiskyTokens = false, +}: UseTokensFeedOptions = {}): UseTokensFeedResult => { + const { data, isLoading, refetch } = useTrendingSearch({ + searchQuery: query, + enableDebounce: false, + }); + + useFeedRefresh(refresh, refetch); + + const filteredData = useMemo(() => { + const searched = fuseSearch( + data, + query, + TOKEN_FUSE_OPTIONS, + (a, b) => (b.marketCap ?? 0) - (a.marketCap ?? 0), + ); + + if (!hideRiskyTokens) return searched; + + return searched.filter(({ securityData }) => { + const { resultType } = securityData ?? {}; + return ( + !resultType || resultType === 'Verified' || resultType === 'Benign' + ); + }); + }, [data, query, hideRiskyTokens]); + + return { data: filteredData, isLoading, refetch }; +}; diff --git a/app/components/Views/TrendingView/hooks/useExploreRefresh.ts b/app/components/Views/TrendingView/hooks/useExploreRefresh.ts new file mode 100644 index 00000000000..3a84dc68e16 --- /dev/null +++ b/app/components/Views/TrendingView/hooks/useExploreRefresh.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useState } from 'react'; + +/** Refresh signal forwarded to feed hooks. */ +export interface RefreshConfig { + /** Incrementing counter; feed hooks refetch when this changes. */ + trigger: number; + /** When true, suppress skeletons during refresh (silent refresh). */ + silentRefresh: boolean; +} + +/** Props every Explore tab consumes. Matches `useExploreRefresh`'s return shape. */ +export interface TabProps { + refresh: RefreshConfig; + refreshing: boolean; + onRefresh: () => void; +} + +/** + * Owns pull-to-refresh state for the Explore page. Forward `refresh` to feed + * hooks; they refetch when `refresh.trigger` increments. + * + * `silentRefresh: true` means subsequent fetches should suppress skeletons; + * the first mount is non-silent so initial loads still show a skeleton. + */ +export const useExploreRefresh = (): TabProps => { + const [refreshing, setRefreshing] = useState(false); + const [refresh, setRefresh] = useState({ + trigger: 0, + silentRefresh: true, + }); + + // Hide the spinner shortly after pull-to-refresh fires; data hooks own the + // actual refetch lifecycle independently. + useEffect(() => { + if (!refreshing) return; + const timeoutId = setTimeout(() => setRefreshing(false), 1000); + return () => clearTimeout(timeoutId); + }, [refreshing]); + + const onRefresh = useCallback(() => { + setRefreshing(true); + setRefresh((prev) => ({ + trigger: prev.trigger + 1, + silentRefresh: true, + })); + }, []); + + return { refresh, refreshing, onRefresh }; +}; diff --git a/app/components/Views/TrendingView/hooks/useExploreSearch.test.ts b/app/components/Views/TrendingView/hooks/useExploreSearch.test.ts deleted file mode 100644 index 3485806a41a..00000000000 --- a/app/components/Views/TrendingView/hooks/useExploreSearch.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { renderHook, waitFor, act } from '@testing-library/react-native'; -import { useExploreSearch } from './useExploreSearch'; -import type { SectionId } from '../sections.config'; - -const mockTrendingTokens = [ - { assetId: '1', symbol: 'BTC', name: 'Bitcoin' }, - { assetId: '2', symbol: 'ETH', name: 'Ethereum' }, - { assetId: '3', symbol: 'SOL', name: 'Solana' }, - { assetId: '4', symbol: 'USDC', name: 'USD Coin' }, - { assetId: '5', symbol: 'USDT', name: 'Tether' }, -]; - -const mockPerpsMarkets = [ - { symbol: 'BTC-USD', name: 'Bitcoin' }, - { symbol: 'ETH-USD', name: 'Ethereum' }, - { symbol: 'SOL-USD', name: 'Solana' }, - { symbol: 'DOGE-USD', name: 'Dogecoin' }, -]; - -const mockPredictionMarkets = [ - { id: '1', title: 'Will Bitcoin reach 100k?' }, - { id: '2', title: 'Ethereum price prediction' }, - { id: '3', title: 'Solana network upgrade' }, - { id: '4', title: 'Trump election results' }, -]; - -const mockSites = [ - { - id: '1', - name: 'Uniswap', - url: 'https://uniswap.org', - displayUrl: 'uniswap.org', - }, - { - id: '2', - name: 'OpenSea', - url: 'https://opensea.io', - displayUrl: 'opensea.io', - }, - { id: '3', name: 'Aave', url: 'https://aave.com', displayUrl: 'aave.com' }, - { - id: '4', - name: 'Compound', - url: 'https://compound.finance', - displayUrl: 'compound.finance', - }, -]; - -let mockTrendingData = mockTrendingTokens; -let mockTrendingLoading = false; -let mockPerpsData = mockPerpsMarkets; -let mockPerpsLoading = false; -let mockPredictionsData = mockPredictionMarkets; -let mockPredictionsLoading = false; -let mockSitesData = mockSites; -let mockSitesLoading = false; - -jest.mock( - '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch', - () => ({ - useTrendingSearch: () => ({ - data: mockTrendingData, - isLoading: mockTrendingLoading, - refetch: jest.fn(), - }), - }), -); - -jest.mock('../../../UI/Perps/hooks/usePerpsMarkets', () => ({ - usePerpsMarkets: () => ({ - markets: mockPerpsData, - isLoading: mockPerpsLoading, - refresh: jest.fn(), - isRefreshing: false, - }), -})); - -jest.mock('../../../UI/Predict/hooks/usePredictMarketData', () => ({ - usePredictMarketData: () => ({ - marketData: mockPredictionsData, - isFetching: mockPredictionsLoading, - refetch: jest.fn(), - }), -})); - -jest.mock('../../../UI/Sites/hooks/useSiteData/useSitesData', () => ({ - useSitesData: () => ({ - sites: mockSitesData, - isLoading: mockSitesLoading, - refetch: jest.fn(), - }), -})); - -jest.mock('../../../UI/Trending/hooks/useRwaTokens/useRwaTokens', () => ({ - useRwaTokens: () => ({ - data: [], - isLoading: false, - refetch: jest.fn(), - }), -})); - -const mockSectionsArray: { id: SectionId }[] = [ - { id: 'tokens' }, - { id: 'stocks' }, - { id: 'perps' }, - { id: 'predictions' }, - { id: 'sites' }, -]; - -jest.mock('../sections.config', () => { - const actual = jest.requireActual('../sections.config'); - return { - ...actual, - useSearchSectionsArray: () => mockSectionsArray, - }; -}); - -describe('useExploreSearch', () => { - beforeEach(() => { - jest.useFakeTimers(); - - // Reset to default values - mockTrendingData = mockTrendingTokens; - mockTrendingLoading = false; - mockPerpsData = mockPerpsMarkets; - mockPerpsLoading = false; - mockPredictionsData = mockPredictionMarkets; - mockPredictionsLoading = false; - mockSitesData = mockSites; - mockSitesLoading = false; - }); - - afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - - it('returns top 3 items from each section when query is empty', () => { - const { result } = renderHook(() => useExploreSearch('')); - - expect(result.current.data.tokens).toHaveLength(3); - expect(result.current.data.perps).toHaveLength(3); - expect(result.current.data.predictions).toHaveLength(3); - expect(result.current.data.sites).toHaveLength(3); - }); - - it('returns top 3 items when query contains only whitespace', () => { - const { result } = renderHook(() => useExploreSearch(' ')); - - expect(result.current.data.tokens).toHaveLength(3); - expect(result.current.data.perps).toHaveLength(3); - expect(result.current.data.predictions).toHaveLength(3); - expect(result.current.data.sites).toHaveLength(3); - }); - - it('returns empty arrays when section hooks return no data', async () => { - mockTrendingData = []; - mockPerpsData = []; - mockPredictionsData = []; - mockSitesData = []; - - const { result } = renderHook(() => useExploreSearch('test')); - - await act(async () => { - jest.advanceTimersByTime(200); - }); - - await waitFor(() => { - expect(result.current.data.tokens).toHaveLength(0); - expect(result.current.data.perps).toHaveLength(0); - expect(result.current.data.predictions).toHaveLength(0); - expect(result.current.data.sites).toHaveLength(0); - }); - }); - - it('debounces query changes by 200ms', async () => { - const { result, rerender } = renderHook( - ({ query }) => useExploreSearch(query), - { initialProps: { query: '' } }, - ); - - const initialTokenCount = result.current.data.tokens.length; - - rerender({ query: 'btc' }); - - // Before debounce completes, should still show initial count (top 3) - await act(async () => { - jest.advanceTimersByTime(100); - }); - - expect(result.current.data.tokens.length).toBe(initialTokenCount); - - // After full debounce time, query should be processed - await act(async () => { - jest.advanceTimersByTime(100); - }); - - await waitFor(() => { - expect(result.current.data.tokens.length).toBeGreaterThan(0); - }); - }); - - it('shows loading state while debouncing', async () => { - const { result, rerender } = renderHook( - ({ query }) => useExploreSearch(query), - { initialProps: { query: '' } }, - ); - - expect(result.current.isLoading.tokens).toBe(false); - - rerender({ query: 'test' }); - - expect(result.current.isLoading.tokens).toBe(true); - expect(result.current.isLoading.perps).toBe(true); - expect(result.current.isLoading.predictions).toBe(true); - expect(result.current.isLoading.sites).toBe(true); - - await act(async () => { - jest.advanceTimersByTime(200); - }); - - await waitFor(() => { - expect(result.current.isLoading.tokens).toBe(false); - }); - }); - - it('aggregates loading states from section hooks', () => { - mockTrendingLoading = true; - mockPerpsLoading = true; - mockPredictionsLoading = true; - mockSitesLoading = true; - - const { result } = renderHook(() => useExploreSearch('')); - - expect(result.current.isLoading.tokens).toBe(true); - expect(result.current.isLoading.perps).toBe(true); - expect(result.current.isLoading.predictions).toBe(true); - expect(result.current.isLoading.sites).toBe(true); - }); - - it('processes all sections defined in config', () => { - const { result } = renderHook(() => useExploreSearch('')); - - mockSectionsArray.forEach((section) => { - expect(result.current.data[section.id]).toBeDefined(); - expect(result.current.isLoading[section.id]).toBeDefined(); - }); - }); - - it('returns default sectionsOrder when no options provided', () => { - const { result } = renderHook(() => useExploreSearch('')); - - expect(result.current.sectionsOrder).toEqual( - mockSectionsArray.map((s) => s.id), - ); - }); - - it('returns custom sectionsOrder when provided in options', () => { - const customOrder = [ - 'sites', - 'tokens', - 'stocks', - 'perps', - 'predictions', - ] as const; - const { result } = renderHook(() => - useExploreSearch('', { sectionsOrder: [...customOrder] }), - ); - - expect(result.current.sectionsOrder).toEqual(customOrder); - }); - - it('maintains backward compatibility - works without options parameter', () => { - const { result } = renderHook(() => useExploreSearch('test')); - - // Should not throw and should return expected structure - expect(result.current.data).toBeDefined(); - expect(result.current.isLoading).toBeDefined(); - expect(result.current.sectionsOrder).toBeDefined(); - }); -}); diff --git a/app/components/Views/TrendingView/hooks/useExploreSearch.ts b/app/components/Views/TrendingView/hooks/useExploreSearch.ts deleted file mode 100644 index 10f1c145ad3..00000000000 --- a/app/components/Views/TrendingView/hooks/useExploreSearch.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { useState, useEffect, useMemo } from 'react'; -import { - useSearchSectionsArray, - useSectionsData, - type SectionId, -} from '../sections.config'; - -export interface ExploreSearchResult { - data: Record; - isLoading: Record; - sectionsOrder: SectionId[]; -} - -export interface ExploreSearchOptions { - /** - * Custom order of sections for display. - * Defaults to SECTIONS_ARRAY order (tokens, perps, predictions, sites). - * Browser uses ['sites', 'tokens', 'perps', 'predictions'] to show Sites first. - */ - sectionsOrder?: SectionId[]; -} - -/** - * GENERIC EXPLORE SEARCH HOOK - * - * This hook is completely generic and processes data from any sections - * defined in sections.config.tsx. It handles: - * - Debouncing the search query - * - Filtering results based on section configurations - * - Returning top 3 items when no query is present - * - * @param query - Search query string - * @param options - Optional configuration including custom section order - * @returns Search results grouped by section - */ -export const useExploreSearch = ( - query: string, - options?: ExploreSearchOptions, -): ExploreSearchResult => { - const sectionsArray = useSearchSectionsArray(); - const sectionsOrder = useMemo( - () => options?.sectionsOrder ?? sectionsArray.map((s) => s.id), - [options?.sectionsOrder, sectionsArray], - ); - const [debouncedQuery, setDebouncedQuery] = useState(query); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedQuery(query); - }, 200); - - return () => clearTimeout(timer); - }, [query]); - - // Fetch data for all sections using centralized hook - const allSectionsData = useSectionsData(debouncedQuery); - - // Check if query is still debouncing (query changed but debounce hasn't completed) - const isDebouncing = query !== debouncedQuery; - - const filteredResults = useMemo(() => { - const isLoading: Record = {} as Record< - SectionId, - boolean - >; - const data: Record = {} as Record< - SectionId, - unknown[] - >; - - const shouldShowTopItems = !debouncedQuery.trim(); - - // Process each section generically - sectionsArray.forEach((section) => { - const sectionData = allSectionsData[section.id]; - // If we're debouncing, show loading state immediately - // Otherwise, use the actual loading state from the data fetch - isLoading[section.id] = isDebouncing || sectionData.isLoading; - - if (shouldShowTopItems) { - // Show top 3 items when no search query - data[section.id] = sectionData.data.slice(0, 3); - } else { - // Filter items based on section's searchable text - data[section.id] = sectionData.data; - } - }); - - return { data, isLoading, sectionsOrder }; - }, [ - debouncedQuery, - allSectionsData, - isDebouncing, - sectionsOrder, - sectionsArray, - ]); - - return filteredResults; -}; diff --git a/app/components/Views/TrendingView/hooks/useFeedRefresh.ts b/app/components/Views/TrendingView/hooks/useFeedRefresh.ts new file mode 100644 index 00000000000..4e410d0a622 --- /dev/null +++ b/app/components/Views/TrendingView/hooks/useFeedRefresh.ts @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; +import type { RefreshConfig } from './useExploreRefresh'; + +/** + * Wires a feed's `refetch` to the page's `refresh.trigger`. Skips trigger 0 + * (initial mount) since the underlying hook already fetches on first mount. + */ +export const useFeedRefresh = ( + refresh: RefreshConfig | undefined, + refetch: (() => Promise | void) | undefined, +): void => { + useEffect(() => { + if (!refresh || refresh.trigger === 0 || !refetch) return; + refetch(); + }, [refresh, refetch]); +}; diff --git a/app/components/Views/TrendingView/components/ExploreSearchResults/ExploreSearchResults.tsx b/app/components/Views/TrendingView/search/ExploreSearchResults.tsx similarity index 56% rename from app/components/Views/TrendingView/components/ExploreSearchResults/ExploreSearchResults.tsx rename to app/components/Views/TrendingView/search/ExploreSearchResults.tsx index 83c111f2000..df6f3fba162 100644 --- a/app/components/Views/TrendingView/components/ExploreSearchResults/ExploreSearchResults.tsx +++ b/app/components/Views/TrendingView/search/ExploreSearchResults.tsx @@ -1,7 +1,7 @@ -import React, { useMemo, useCallback, useRef, useEffect } from 'react'; -import { FlashList, ListRenderItem, FlashListRef } from '@shopify/flash-list'; -import { useNavigation, NavigationProp } from '@react-navigation/native'; -import type { RootStackParamList } from '../../../../../core/NavigationService/types'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { Pressable } from 'react-native'; +import { useNavigation, type NavigationProp } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; import { Box, Text, @@ -17,22 +17,23 @@ import { BoxJustifyContent, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { Pressable } from 'react-native'; -import { SECTIONS_CONFIG, type SectionId } from '../../sections.config'; -import { MetaMetricsEvents } from '../../../../../core/Analytics/MetaMetrics.events'; +import { FlashList, FlashListRef, ListRenderItem } from '@shopify/flash-list'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import type { RootStackParamList } from '../../../../core/NavigationService/types'; +import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; +import SitesSearchFooter from '../../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter'; +import { useSearchTracking } from '../../../UI/Trending/hooks/useSearchTracking/useSearchTracking'; +import { TimeOption } from '../../../UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet'; +import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; +import Routes from '../../../../constants/navigation/Routes'; +import { strings } from '../../../../../locales/i18n'; +import { trackExploreEvent, useScrollTracking } from './analytics'; import { - TrackedRowItem, - trackExploreEvent, - useScrollTracking, -} from '../../utils/exploreSearch'; -import { useExploreSearch } from '../../hooks/useExploreSearch'; -import { selectBasicFunctionalityEnabled } from '../../../../../selectors/settings'; -import SitesSearchFooter from '../../../../UI/Sites/components/SitesSearchFooter/SitesSearchFooter'; -import { useSelector } from 'react-redux'; -import { useSearchTracking } from '../../../../UI/Trending/hooks/useSearchTracking/useSearchTracking'; -import { TimeOption } from '../../../../UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet'; -import Routes from '../../../../../constants/navigation/Routes'; -import { strings } from '../../../../../../locales/i18n'; + useExploreSearch, + type SearchFeedId, + type SearchFeedSection, +} from './useExploreSearch'; +import SearchFeedRow, { SearchFeedSkeleton } from './SearchFeedRow'; const MAX_ITEMS_PER_SECTION = 3; @@ -42,20 +43,21 @@ interface ExploreSearchResultsProps { interface ListItemHeader { type: 'header'; - sectionId: SectionId; + feedId: SearchFeedId; title: string; hasMore: boolean; } interface ListItemData { type: 'item'; - sectionId: SectionId; + feedId: SearchFeedId; + title: string; data: unknown; } interface ListItemSkeleton { type: 'skeleton'; - sectionId: SectionId; + feedId: SearchFeedId; index: number; } @@ -66,7 +68,7 @@ const ExploreSearchResults: React.FC = ({ }) => { const navigation = useNavigation>(); const tw = useTailwind(); - const { data, isLoading, sectionsOrder } = useExploreSearch(searchQuery); + const { sections } = useExploreSearch(searchQuery); const flashListRef = useRef>(null); const isBasicFunctionalityEnabled = useSelector( selectBasicFunctionalityEnabled, @@ -82,24 +84,24 @@ const ExploreSearchResults: React.FC = ({ }, [searchQuery, resetScrollTracking]); const handleViewMore = useCallback( - (sectionId: SectionId, title: string) => { + (section: SearchFeedSection) => { trackExploreEvent(MetaMetricsEvents.EXPLORE_SEARCH_INTERACTED, { interaction_type: 'view_all_clicked', search_query: searchQuery, - section_name: title, + section_name: section.title, }); navigation.navigate(Routes.EXPLORE_SECTION_RESULTS_FULL_VIEW, { - sectionId, - title, + feedId: section.feedId, + title: section.title, searchQuery, - data: data[sectionId], + data: section.items, }); }, - [navigation, searchQuery, data], + [navigation, searchQuery], ); const renderSectionHeader = useCallback( - (item: ListItemHeader) => ( + (item: ListItemHeader, section: SearchFeedSection) => ( = ({ {item.hasMore && ( handleViewMore(item.sectionId, item.title)} + onPress={() => handleViewMore(section)} hitSlop={8} accessibilityRole="button" accessibilityLabel={`${strings('trending.view_all')} ${item.title}`} @@ -144,66 +146,44 @@ const ExploreSearchResults: React.FC = ({ [handleViewMore, tw], ); - const flatData = useMemo(() => { + const flatData = useMemo(() => { const result: FlatListItem[] = []; + const visibleSections = isBasicFunctionalityEnabled ? sections : []; - const sectionIdsToShow = isBasicFunctionalityEnabled ? sectionsOrder : []; + visibleSections.forEach((section) => { + const { feedId, title, items, isLoading } = section; + if (!isLoading && items.length === 0) return; - sectionIdsToShow.forEach((sectionId) => { - const section = SECTIONS_CONFIG[sectionId]; - if (!section) return; - - const items = data[sectionId]; - const sectionIsLoading = isLoading[sectionId]; - - if ((items && items.length > 0) || sectionIsLoading) { - const hasMore = - !sectionIsLoading && (items?.length ?? 0) > MAX_ITEMS_PER_SECTION; - - result.push({ - type: 'header', - sectionId, - title: section.title, - hasMore, - }); + const hasMore = !isLoading && items.length > MAX_ITEMS_PER_SECTION; + result.push({ type: 'header', feedId, title, hasMore }); - if (sectionIsLoading) { - for (let i = 0; i < MAX_ITEMS_PER_SECTION; i++) { - result.push({ - type: 'skeleton', - sectionId, - index: i, - }); - } - } else { - const visibleItems = items.slice(0, MAX_ITEMS_PER_SECTION); - visibleItems.forEach((item) => { - result.push({ - type: 'item', - sectionId, - data: item, - }); - }); + if (isLoading) { + for (let i = 0; i < MAX_ITEMS_PER_SECTION; i++) { + result.push({ type: 'skeleton', feedId, index: i }); } + } else { + const visibleItems = items.slice(0, MAX_ITEMS_PER_SECTION); + visibleItems.forEach((data) => { + result.push({ type: 'item', feedId, title, data }); + }); } }); return result; - }, [data, isLoading, isBasicFunctionalityEnabled, sectionsOrder]); + }, [isBasicFunctionalityEnabled, sections]); useEffect(() => { if (flatData.length > 0) { - flashListRef.current?.scrollToIndex({ - index: 0, - animated: false, - }); + flashListRef.current?.scrollToIndex({ index: 0, animated: false }); } }, [searchQuery, flatData.length]); + const tokensSection = sections.find((s) => s.feedId === 'tokens'); useSearchTracking({ searchQuery, - resultsCount: data.tokens?.length || 0, - isLoading: isLoading.tokens, + resultsCount: + (tokensSection?.items as TrendingAsset[] | undefined)?.length ?? 0, + isLoading: tokensSection?.isLoading ?? false, timeFilter: TimeOption.TwentyFourHours, sortOption: 'relevance', networkFilter: 'all', @@ -211,46 +191,38 @@ const ExploreSearchResults: React.FC = ({ const renderFooter = useMemo(() => { if (searchQuery.length === 0) return null; - return ; }, [searchQuery]); const renderFlatItem: ListRenderItem = useCallback( ({ item, index }) => { if (item.type === 'header') { - return renderSectionHeader(item); + const section = sections.find((s) => s.feedId === item.feedId); + if (!section) return null; + return renderSectionHeader(item, section); } - - const section = SECTIONS_CONFIG[item.sectionId]; - if (!section) return null; - if (item.type === 'skeleton') { - if (section.OverrideSkeletonSearch) { - return ; - } - return ; + return ; } - return ( - ); }, - [renderSectionHeader, searchQuery], + [renderSectionHeader, sections, searchQuery], ); const keyExtractor = useCallback((item: FlatListItem, index: number) => { - if (item.type === 'header') return `header-${item.sectionId}`; + if (item.type === 'header') return `header-${item.feedId}`; if (item.type === 'skeleton') - return `skeleton-${item.sectionId}-${item.index}`; - - const section = SECTIONS_CONFIG[item.sectionId]; - return section ? `${section.id}-${index}` : `item-${index}`; + return `skeleton-${item.feedId}-${item.index}`; + return `${item.feedId}-${index}`; }, []); return ( diff --git a/app/components/Views/TrendingView/search/SearchFeedRow.test.tsx b/app/components/Views/TrendingView/search/SearchFeedRow.test.tsx new file mode 100644 index 00000000000..5f91705f423 --- /dev/null +++ b/app/components/Views/TrendingView/search/SearchFeedRow.test.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { Pressable, Text } from 'react-native'; +import { render, fireEvent } from '@testing-library/react-native'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import type { PerpsMarketData } from '@metamask/perps-controller'; +import type { PredictMarket as PredictMarketType } from '../../../UI/Predict/types'; +import type { SiteData } from '../../../UI/Sites/components/SiteRowItem/SiteRowItem'; +import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; +import SearchFeedRow, { + SearchFeedSkeleton, + CryptoMoversFeedSearchRow, +} from './SearchFeedRow'; +import { trackExploreEvent } from './analytics'; + +const MockPressable = Pressable; +const MockText = Text; + +jest.mock('./TapView', () => ({ + __esModule: true, + default: ({ + children, + onTap, + }: { + children: React.ReactNode; + onTap?: () => void; + }) => ( + + {children} + + ), +})); + +jest.mock('./analytics', () => ({ + trackExploreEvent: jest.fn(), +})); + +jest.mock('../feeds/tokens/TokenRowItem', () => ({ + TokenSearchRowItem: ({ token }: { token: TrendingAsset }) => ( + {token.assetId} + ), + CryptoMoversSearchRowItem: ({ token }: { token: TrendingAsset }) => ( + {token.assetId} + ), +})); + +jest.mock('../feeds/perps/PerpsRowItem', () => ({ + __esModule: true, + default: ({ market }: { market: PerpsMarketData }) => ( + {market.symbol} + ), +})); + +jest.mock('../feeds/predictions/PredictionRowItem', () => ({ + PredictionSearchRowItem: ({ market }: { market: PredictMarketType }) => ( + {market.id} + ), +})); + +jest.mock('../feeds/sites/SiteRowItem', () => ({ + SiteRowItem: ({ site }: { site: SiteData }) => ( + {site.url} + ), +})); + +jest.mock( + '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton', + () => ({ + __esModule: true, + default: () => ( + sk + ), + }), +); + +jest.mock('../../../UI/Sites/components/SiteSkeleton/SiteSkeleton', () => ({ + __esModule: true, + default: () => sk, +})); + +const mockTrackExploreEvent = trackExploreEvent as jest.MockedFunction< + typeof trackExploreEvent +>; + +describe('SearchFeedRow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + ['tokens', 'asset-1', 'stub-token-row'], + ['stocks', 'asset-2', 'stub-token-row'], + ['perps', 'ETH', 'stub-perps-row'], + ['predictions', 'pred-9', 'stub-predict-row'], + ['sites', 'https://example.com', 'stub-site-row'], + ] as const)( + 'renders the row for feedId %s and sends analytics with the correct item id on tap', + (feedId, itemClicked, rowTestId) => { + const token = { assetId: 'asset-1' } as TrendingAsset; + const perpsMarket = { symbol: 'ETH' } as PerpsMarketData; + const predict = { id: 'pred-9' } as PredictMarketType; + const site = { url: 'https://example.com' } as SiteData; + + const itemByFeed = { + tokens: token, + stocks: { assetId: 'asset-2' } as TrendingAsset, + perps: perpsMarket, + predictions: predict, + sites: site, + }[feedId]; + + const { getByTestId } = render( + , + ); + + expect(getByTestId(rowTestId)).toBeTruthy(); + fireEvent.press(getByTestId('search-feed-tap')); + + expect(mockTrackExploreEvent).toHaveBeenCalledWith( + MetaMetricsEvents.EXPLORE_SEARCH_INTERACTED, + expect.objectContaining({ + interaction_type: 'row_tap', + search_query: 'q', + section_name: 'Tokens', + item_clicked: itemClicked, + }), + ); + }, + ); + + it('uses latest searchQuery from ref when tap fires', () => { + const token = { assetId: 'x' } as TrendingAsset; + const { getByTestId, rerender } = render( + , + ); + + rerender( + , + ); + + fireEvent.press(getByTestId('search-feed-tap')); + + expect(mockTrackExploreEvent).toHaveBeenCalledWith( + MetaMetricsEvents.EXPLORE_SEARCH_INTERACTED, + expect.objectContaining({ search_query: 'second' }), + ); + }); +}); + +describe('SearchFeedSkeleton', () => { + it('uses site skeleton for sites and predictions', () => { + const { getByTestId, rerender } = render( + , + ); + expect(getByTestId('stub-site-skeleton')).toBeTruthy(); + + rerender(); + expect(getByTestId('stub-site-skeleton')).toBeTruthy(); + }); + + it('uses token skeleton for tokens, stocks, and perps', () => { + const { getByTestId, rerender } = render( + , + ); + expect(getByTestId('stub-trending-token-skeleton')).toBeTruthy(); + + rerender(); + expect(getByTestId('stub-trending-token-skeleton')).toBeTruthy(); + + rerender(); + expect(getByTestId('stub-trending-token-skeleton')).toBeTruthy(); + }); +}); + +describe('CryptoMoversFeedSearchRow', () => { + it('renders CryptoMoversSearchRowItem with token and index', () => { + const token = { assetId: 'btc', symbol: 'BTC' } as TrendingAsset; + const { getByTestId } = render( + , + ); + + expect(getByTestId('stub-crypto-movers-search').props.children).toBe('btc'); + }); +}); diff --git a/app/components/Views/TrendingView/search/SearchFeedRow.tsx b/app/components/Views/TrendingView/search/SearchFeedRow.tsx new file mode 100644 index 00000000000..3712d622c98 --- /dev/null +++ b/app/components/Views/TrendingView/search/SearchFeedRow.tsx @@ -0,0 +1,106 @@ +import React, { useCallback, useRef } from 'react'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import type { PerpsMarketData } from '@metamask/perps-controller'; +import type { PredictMarket as PredictMarketType } from '../../../UI/Predict/types'; +import type { SiteData } from '../../../UI/Sites/components/SiteRowItem/SiteRowItem'; +import { + TokenSearchRowItem, + CryptoMoversSearchRowItem, +} from '../feeds/tokens/TokenRowItem'; +import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; +import PerpsRowItem from '../feeds/perps/PerpsRowItem'; +import { PredictionSearchRowItem } from '../feeds/predictions/PredictionRowItem'; +import { SiteRowItem } from '../feeds/sites/SiteRowItem'; +import SiteSkeleton from '../../../UI/Sites/components/SiteSkeleton/SiteSkeleton'; +import type { SearchFeedId } from './useExploreSearch'; +import TapView from './TapView'; +import { trackExploreEvent } from './analytics'; +import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; + +interface SearchFeedRowProps { + feedId: SearchFeedId; + item: unknown; + index: number; + searchQuery: string; + sectionTitle: string; + interactionType: string; +} + +const renderRow = (feedId: SearchFeedId, item: unknown, index: number) => { + switch (feedId) { + case 'tokens': + return ; + case 'stocks': + return ; + case 'perps': + return ; + case 'predictions': + return ; + case 'sites': + return ; + } +}; + +const getItemId = (feedId: SearchFeedId, item: unknown): string => { + switch (feedId) { + case 'tokens': + case 'stocks': + return (item as TrendingAsset).assetId ?? ''; + case 'perps': + return (item as PerpsMarketData).symbol ?? ''; + case 'predictions': + return (item as PredictMarketType).id ?? ''; + case 'sites': + return (item as SiteData).url ?? ''; + } +}; + +/** Renders a search-result row for any feed and tracks taps with analytics. */ +const SearchFeedRow: React.FC = ({ + feedId, + item, + index, + searchQuery, + sectionTitle, + interactionType, +}) => { + const searchQueryRef = useRef(searchQuery); + searchQueryRef.current = searchQuery; + + const handleTap = useCallback(() => { + trackExploreEvent(MetaMetricsEvents.EXPLORE_SEARCH_INTERACTED, { + interaction_type: interactionType, + search_query: searchQueryRef.current, + section_name: sectionTitle, + item_clicked: getItemId(feedId, item), + }); + }, [interactionType, sectionTitle, feedId, item]); + + return {renderRow(feedId, item, index)}; +}; + +/** Skeleton row appropriate for a given feed. */ +export const SearchFeedSkeleton: React.FC<{ feedId: SearchFeedId }> = ({ + feedId, +}) => { + switch (feedId) { + case 'sites': + case 'predictions': + return ; + case 'tokens': + case 'stocks': + case 'perps': + default: + return ; + } +}; + +/** Crypto-movers variant for the dedicated "Crypto movers" full-view header. */ +export const CryptoMoversFeedSearchRow: React.FC<{ + token: TrendingAsset; + index: number; +}> = ({ token, index }) => ( + +); + +export default SearchFeedRow; diff --git a/app/components/Views/TrendingView/search/TapView.tsx b/app/components/Views/TrendingView/search/TapView.tsx new file mode 100644 index 00000000000..72712f2a7f2 --- /dev/null +++ b/app/components/Views/TrendingView/search/TapView.tsx @@ -0,0 +1,38 @@ +import React, { useRef } from 'react'; +import { Box } from '@metamask/design-system-react-native'; + +/** Min vertical movement (px) treated as scroll, not a tap. Absorbs jitter. */ +const SCROLL_THRESHOLD = 8; + +/** + * Wraps children and fires `onTap` only when the touch ends without a scroll + * gesture. Uses raw touch events so movement is detected even while a parent + * FlashList is absorbing a scroll. + */ +const TapView: React.FC<{ + onTap?: () => void; + children: React.ReactNode; +}> = ({ onTap, children }) => { + const startY = useRef(0); + const didScroll = useRef(false); + return ( + { + startY.current = e.nativeEvent.pageY; + didScroll.current = false; + }} + onTouchMove={(e) => { + if (Math.abs(e.nativeEvent.pageY - startY.current) > SCROLL_THRESHOLD) { + didScroll.current = true; + } + }} + onTouchEnd={() => { + if (!didScroll.current) onTap?.(); + }} + > + {children} + + ); +}; + +export default TapView; diff --git a/app/components/Views/TrendingView/search/analytics.ts b/app/components/Views/TrendingView/search/analytics.ts new file mode 100644 index 00000000000..2676dca8487 --- /dev/null +++ b/app/components/Views/TrendingView/search/analytics.ts @@ -0,0 +1,106 @@ +import { useCallback, useRef } from 'react'; +import { analytics } from '../../../../util/analytics/analytics'; +import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; +import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; + +export type ExploreTabName = + | 'Now' + | 'Macro' + | 'RWAs' + | 'Crypto' + | 'Sports' + | 'Sites'; + +export type ExploreSectionName = + | 'tokens_movers' + | 'tokens_trending' + | 'perps_movers' + | 'perps_stocks_commodities' + | 'perps_markets' + | 'perps_crypto' + | 'stocks' + | 'predictions_trending' + | 'predictions_politics' + | 'predictions_crypto' + | 'predictions_sports' + | 'predictions_football' + | 'predictions_basketball' + | 'predictions_tennis' + | 'sites_recents' + | 'sites_favorites' + | 'sites_ecosystems' + | 'sites_popular'; + +export interface ExploreInteractedProperties { + interaction_type: + | 'tab_switched' + | 'section_see_all_tapped' + | 'section_item_tapped' + | 'prediction_voted'; + tab_name: ExploreTabName; + section_name?: ExploreSectionName; + position?: number; + asset_type?: 'token' | 'stock' | 'perp' | 'prediction' | 'dapp'; + previous_tab?: ExploreTabName; + token_address?: string; + token_symbol?: string; + chain_id?: string; + item_clicked?: string; +} + +export const trackExploreInteracted = ( + properties: ExploreInteractedProperties, +): void => { + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( + MetaMetricsEvents.EXPLORE_INTERACTED, + ) + .addProperties(properties as unknown as Record) + .build(), + ); +}; + +/** Single-line wrapper around the analytics builder boilerplate. */ +export const trackExploreEvent = ( + event: Parameters[0], + properties: Record, +): void => { + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder(event) + .addProperties(properties) + .build(), + ); +}; + +/** + * Returns a stable `onScrollBeginDrag` handler that fires a one-shot analytics + * event the first time the user begins scrolling. + */ +export const useScrollTracking = ( + interactionType: string, + searchQuery: string, + extraProperties?: Record, +) => { + const hasTracked = useRef(false); + const searchQueryRef = useRef(searchQuery); + searchQueryRef.current = searchQuery; + + const extraPropsRef = useRef(extraProperties); + extraPropsRef.current = extraProperties; + + const onScrollBeginDrag = useCallback(() => { + if (hasTracked.current) return; + hasTracked.current = true; + trackExploreEvent(MetaMetricsEvents.EXPLORE_SEARCH_INTERACTED, { + interaction_type: interactionType, + search_query: searchQueryRef.current, + ...extraPropsRef.current, + }); + }, [interactionType]); + + const resetScrollTracking = useCallback(() => { + hasTracked.current = false; + }, []); + + return { onScrollBeginDrag, resetScrollTracking }; +}; diff --git a/app/components/Views/TrendingView/search/useExploreSearch.ts b/app/components/Views/TrendingView/search/useExploreSearch.ts new file mode 100644 index 00000000000..0e8b3bfc6a8 --- /dev/null +++ b/app/components/Views/TrendingView/search/useExploreSearch.ts @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import type { PerpsMarketData } from '@metamask/perps-controller'; +import type { PredictMarket as PredictMarketType } from '../../../UI/Predict/types'; +import type { SiteData } from '../../../UI/Sites/components/SiteRowItem/SiteRowItem'; +import { selectPerpsEnabledFlag } from '../../../UI/Perps'; +import { strings } from '../../../../../locales/i18n'; +import { useTokensFeed } from '../feeds/tokens/useTokensFeed'; +import { usePerpsFeed } from '../feeds/perps/usePerpsFeed'; +import { useStocksFeed } from '../feeds/stocks/useStocksFeed'; +import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; +import { useSitesFeed } from '../feeds/sites/useSitesFeed'; + +/** Feeds that participate in the omni-search across the Explore page. */ +export type SearchFeedId = + | 'tokens' + | 'perps' + | 'stocks' + | 'predictions' + | 'sites'; + +const DEBOUNCE_MS = 200; +const TOP_ITEMS_WITHOUT_QUERY = 3; + +/** Result shape per feed so consumers can render with the right row item. */ +export type SearchFeedData = + | { feedId: 'tokens'; items: TrendingAsset[] } + | { feedId: 'stocks'; items: TrendingAsset[] } + | { feedId: 'perps'; items: PerpsMarketData[] } + | { feedId: 'predictions'; items: PredictMarketType[] } + | { feedId: 'sites'; items: SiteData[] }; + +export interface SearchFeedSection { + feedId: SearchFeedId; + title: string; + items: T[]; + isLoading: boolean; +} + +export interface ExploreSearchResult { + /** Ordered sections to render. Perps is omitted when the flag is disabled. */ + sections: SearchFeedSection[]; +} + +/** + * Aggregates the 5 search-relevant feeds (tokens, perps, stocks, predictions, + * sites) and applies common search behavior: debouncing and top-N truncation + * when no query is present. + */ +export const useExploreSearch = (query: string): ExploreSearchResult => { + const [debouncedQuery, setDebouncedQuery] = useState(query); + const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [query]); + + const isDebouncing = query !== debouncedQuery; + + const tokens = useTokensFeed({ query: debouncedQuery }); + const perps = usePerpsFeed({ query: debouncedQuery }); + const stocks = useStocksFeed({ query: debouncedQuery }); + const predictions = usePredictionsFeed({ + variant: 'trending', + query: debouncedQuery, + }); + const sites = useSitesFeed({ query: debouncedQuery }); + + return useMemo(() => { + const showTopItems = !debouncedQuery.trim(); + const trim = (arr: T[]) => + showTopItems ? arr.slice(0, TOP_ITEMS_WITHOUT_QUERY) : arr; + + const sections: SearchFeedSection[] = [ + { + feedId: 'tokens', + title: strings('trending.crypto'), + items: trim(tokens.data), + isLoading: isDebouncing || tokens.isLoading, + }, + ]; + + if (isPerpsEnabled) { + sections.push({ + feedId: 'perps', + title: strings('trending.perps'), + items: trim(perps.data.map((d) => d.market)), + isLoading: isDebouncing || perps.isLoading, + }); + } + + sections.push( + { + feedId: 'stocks', + title: strings('trending.stocks'), + items: trim(stocks.data), + isLoading: isDebouncing || stocks.isLoading, + }, + { + feedId: 'predictions', + title: strings('wallet.predict'), + items: trim(predictions.data), + isLoading: isDebouncing || predictions.isLoading, + }, + { + feedId: 'sites', + title: strings('trending.sites'), + items: trim(sites.data), + isLoading: isDebouncing || sites.isLoading, + }, + ); + + return { sections }; + }, [ + debouncedQuery, + isDebouncing, + isPerpsEnabled, + tokens.data, + tokens.isLoading, + perps.data, + perps.isLoading, + stocks.data, + stocks.isLoading, + predictions.data, + predictions.isLoading, + sites.data, + sites.isLoading, + ]); +}; diff --git a/app/components/Views/TrendingView/sections.config.test.tsx b/app/components/Views/TrendingView/sections.config.test.tsx deleted file mode 100644 index 8bd31d6b131..00000000000 --- a/app/components/Views/TrendingView/sections.config.test.tsx +++ /dev/null @@ -1,296 +0,0 @@ -/** - * Tests for sections.config.tsx - * Covers the getItemIdentifier functions added for analytics tracking. - */ - -// Mock all heavy dependencies before importing SECTIONS_CONFIG -jest.mock('../../../constants/navigation/Routes', () => ({ - __esModule: true, - default: { - WALLET: { - TRENDING_TOKENS_FULL_VIEW: 'TrendingTokensFullView', - RWA_TOKENS_FULL_VIEW: 'RwaTokensFullView', - }, - PERPS: { - ROOT: 'PerpsRoot', - MARKET_LIST: 'PerpsMarketList', - MARKET_DETAILS: 'PerpsMarketDetails', - }, - PREDICT: { - ROOT: 'PredictRoot', - MARKET_LIST: 'PredictMarketList', - }, - SITES_FULL_VIEW: 'SitesFullView', - }, -})); - -jest.mock('../../../../locales/i18n', () => ({ - strings: (key: string) => key, -})); - -jest.mock('react-redux', () => ({ - useSelector: jest.fn(), - useContext: jest.fn(), -})); - -jest.mock( - '../../UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem', - () => () => null, -); -jest.mock( - '../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton', - () => () => null, -); -jest.mock('../../UI/Perps/components/PerpsMarketRowItem', () => () => null); -jest.mock('../../UI/Perps/hooks', () => ({ usePerpsMarkets: jest.fn() })); -jest.mock('@metamask/perps-controller', () => ({ - filterMarketsByQuery: jest.fn((markets: unknown[]) => markets), - PERPS_EVENT_VALUE: { SOURCE: { EXPLORE: 'explore' } }, -})); -jest.mock('../../UI/Predict/hooks/usePredictMarketData', () => ({ - usePredictMarketData: jest.fn(), -})); -jest.mock('../../UI/Predict/components/PredictMarketRowItem', () => () => null); -jest.mock( - '../../UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper', - () => () => null, -); -jest.mock( - '../../UI/Sites/components/SiteSkeleton/SiteSkeleton', - () => () => null, -); -jest.mock('../../UI/Sites/hooks/useSiteData/useSitesData', () => ({ - useSitesData: jest.fn(), -})); -jest.mock( - '../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch', - () => ({ useTrendingSearch: jest.fn() }), -); -jest.mock('../../UI/Trending/hooks/useRwaTokens/useRwaTokens', () => ({ - useRwaTokens: jest.fn(), -})); -jest.mock('../../UI/Perps', () => ({ selectPerpsEnabledFlag: jest.fn() })); -jest.mock('../../UI/Perps/providers/PerpsConnectionProvider', () => ({ - PerpsConnectionContext: {}, - PerpsConnectionProvider: ({ children }: { children: React.ReactNode }) => - children, -})); -jest.mock('../../UI/Perps/providers/PerpsStreamManager', () => ({ - PerpsStreamProvider: ({ children }: { children: React.ReactNode }) => - children, -})); -jest.mock('./components/Sections/SectionTypes/SectionCard', () => () => null); -jest.mock( - './components/Sections/SectionTypes/SectionCarrousel', - () => () => null, -); -jest.mock( - './components/Sections/SectionTypes/PerpsExploreSection', - () => () => null, -); -jest.mock('../../UI/Predict/components/PredictMarket', () => () => null); -jest.mock( - '../../UI/Predict/components/PredictMarketSkeleton', - () => () => null, -); -jest.mock( - '../Homepage/Sections/Perpetuals/components/PerpsMarketTileCard', - () => () => null, -); -jest.mock( - '../Homepage/Sections/Perpetuals/components/PerpsMarketTileCardSkeleton', - () => () => null, -); -jest.mock('fuse.js', () => - jest.fn().mockImplementation(() => ({ - search: jest.fn().mockReturnValue([]), - })), -); - -import { SECTIONS_CONFIG } from './sections.config'; -import { renderHook } from '@testing-library/react-native'; -import { usePerpsMarkets } from '../../UI/Perps/hooks'; - -describe('SECTIONS_CONFIG getItemIdentifier', () => { - describe('tokens section', () => { - it('extracts assetId from a token item', () => { - const item = { assetId: 'token-abc-123', symbol: 'BTC', name: 'Bitcoin' }; - - const result = SECTIONS_CONFIG.tokens.getItemIdentifier(item); - - expect(result).toBe('token-abc-123'); - }); - - it('extracts assetId with special characters', () => { - const item = { assetId: 'eip155:1/erc20:0xabc', symbol: 'USDC' }; - - const result = SECTIONS_CONFIG.tokens.getItemIdentifier(item); - - expect(result).toBe('eip155:1/erc20:0xabc'); - }); - }); - - describe('perps section', () => { - it('extracts symbol from a perps market item', () => { - const item = { symbol: 'BTC-USD', name: 'Bitcoin', price: 50000 }; - - const result = SECTIONS_CONFIG.perps.getItemIdentifier(item); - - expect(result).toBe('BTC-USD'); - }); - - it('extracts symbol with various market pairs', () => { - const item = { symbol: 'ETH-PERP', name: 'Ethereum Perpetual' }; - - const result = SECTIONS_CONFIG.perps.getItemIdentifier(item); - - expect(result).toBe('ETH-PERP'); - }); - }); - - describe('stocks section', () => { - it('extracts assetId from a stocks item', () => { - const item = { assetId: 'stock-aapl-456', symbol: 'AAPL', name: 'Apple' }; - - const result = SECTIONS_CONFIG.stocks.getItemIdentifier(item); - - expect(result).toBe('stock-aapl-456'); - }); - }); - - describe('predictions section', () => { - it('extracts id from a prediction market item', () => { - const item = { id: 'predict-market-789', title: 'Will BTC reach 100k?' }; - - const result = SECTIONS_CONFIG.predictions.getItemIdentifier(item); - - expect(result).toBe('predict-market-789'); - }); - - it('extracts id when item has additional fields', () => { - const item = { - id: 'market-42', - title: 'Election outcome', - description: 'Who will win?', - volume: 1000, - }; - - const result = SECTIONS_CONFIG.predictions.getItemIdentifier(item); - - expect(result).toBe('market-42'); - }); - }); - - describe('sites section', () => { - it('extracts url from a site item', () => { - const item = { - url: 'https://uniswap.org', - name: 'Uniswap', - displayUrl: 'uniswap.org', - }; - - const result = SECTIONS_CONFIG.sites.getItemIdentifier(item); - - expect(result).toBe('https://uniswap.org'); - }); - - it('extracts url with path components', () => { - const item = { - url: 'https://app.aave.com/markets', - name: 'Aave Markets', - }; - - const result = SECTIONS_CONFIG.sites.getItemIdentifier(item); - - expect(result).toBe('https://app.aave.com/markets'); - }); - }); - - describe('getItemIdentifier presence', () => { - it('is defined for all sections', () => { - const sectionIds = Object.keys( - SECTIONS_CONFIG, - ) as (keyof typeof SECTIONS_CONFIG)[]; - - sectionIds.forEach((sectionId) => { - expect(SECTIONS_CONFIG[sectionId].getItemIdentifier).toBeDefined(); - }); - }); - }); -}); - -describe('SECTIONS_CONFIG perps useSectionData sorting', () => { - const mockUsePerpsMarkets = usePerpsMarkets as jest.MockedFunction< - typeof usePerpsMarkets - >; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('sorts markets by change24hPercent descending when no search query', () => { - const unsortedMarkets = [ - { symbol: 'ETH', change24hPercent: '2.5' }, - { symbol: 'BTC', change24hPercent: '10.0' }, - { symbol: 'SOL', change24hPercent: '-3.0' }, - { symbol: 'DOGE', change24hPercent: '5.0' }, - ]; - - mockUsePerpsMarkets.mockReturnValue({ - markets: unsortedMarkets, - isLoading: false, - refresh: jest.fn(), - isRefreshing: false, - } as never); - - const { result } = renderHook(() => SECTIONS_CONFIG.perps.useSectionData()); - - const symbols = result.current.data.map( - (m: unknown) => (m as { symbol: string }).symbol, - ); - expect(symbols).toEqual(['BTC', 'DOGE', 'ETH', 'SOL']); - }); - - it('places markets with invalid change24hPercent at the end', () => { - const markets = [ - { symbol: 'ETH', change24hPercent: '5.0' }, - { symbol: 'BAD', change24hPercent: 'invalid' }, - { symbol: 'BTC', change24hPercent: '10.0' }, - ]; - - mockUsePerpsMarkets.mockReturnValue({ - markets, - isLoading: false, - refresh: jest.fn(), - isRefreshing: false, - } as never); - - const { result } = renderHook(() => SECTIONS_CONFIG.perps.useSectionData()); - - const symbols = result.current.data.map( - (m: unknown) => (m as { symbol: string }).symbol, - ); - expect(symbols).toEqual(['BTC', 'ETH', 'BAD']); - }); - - it('does not sort when search query is provided (delegates to fuse)', () => { - const markets = [ - { symbol: 'ETH', change24hPercent: '2.5' }, - { symbol: 'BTC', change24hPercent: '10.0' }, - ]; - - mockUsePerpsMarkets.mockReturnValue({ - markets, - isLoading: false, - refresh: jest.fn(), - isRefreshing: false, - } as never); - - const { result } = renderHook(() => - SECTIONS_CONFIG.perps.useSectionData('btc'), - ); - - // fuse.search is mocked to return [], verifying search path is taken - expect(result.current.data).toEqual([]); - }); -}); diff --git a/app/components/Views/TrendingView/sections.config.tsx b/app/components/Views/TrendingView/sections.config.tsx deleted file mode 100644 index 324c6123595..00000000000 --- a/app/components/Views/TrendingView/sections.config.tsx +++ /dev/null @@ -1,520 +0,0 @@ -import React, { PropsWithChildren, useContext, useMemo } from 'react'; -import Fuse, { type FuseOptions } from 'fuse.js'; -import type { NavigationProp } from '@react-navigation/native'; -import type { TrendingAsset } from '@metamask/assets-controllers'; -import type { AppNavigationProp } from '../../../core/NavigationService/types'; -import { useSelector } from 'react-redux'; -import Routes from '../../../constants/navigation/Routes'; -import { strings } from '../../../../locales/i18n'; -import TrendingTokenRowItem from '../../UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem'; -import TrendingTokensSkeleton from '../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; -import PerpsMarketRowItem from '../../UI/Perps/components/PerpsMarketRowItem'; -import { - filterMarketsByQuery, - PERPS_EVENT_VALUE, - type PerpsMarketData, -} from '@metamask/perps-controller'; -import type { PredictMarket as PredictMarketType } from '../../UI/Predict/types'; -import type { PerpsNavigationParamList } from '../../UI/Perps/types/navigation'; -import { usePredictMarketData } from '../../UI/Predict/hooks/usePredictMarketData'; -import { selectPerpsEnabledFlag } from '../../UI/Perps'; -import { selectExploreSectionsOrder } from '../../../selectors/featureFlagController/exploreSectionsOrder'; -import { usePerpsMarkets } from '../../UI/Perps/hooks'; -import { - PerpsConnectionContext, - PerpsConnectionProvider, -} from '../../UI/Perps/providers/PerpsConnectionProvider'; -import { PerpsStreamProvider } from '../../UI/Perps/providers/PerpsStreamManager'; -import { - Box, - IconName as DSIconName, -} from '@metamask/design-system-react-native'; -import { IconName as LocalIconName } from '../../../component-library/components/Icons/Icon/Icon.types'; -import type { SiteData } from '../../UI/Sites/components/SiteRowItem/SiteRowItem'; -import SiteRowItemWrapper from '../../UI/Sites/components/SiteRowItemWrapper/SiteRowItemWrapper'; -import SiteSkeleton from '../../UI/Sites/components/SiteSkeleton/SiteSkeleton'; -import { useSitesData } from '../../UI/Sites/hooks/useSiteData/useSitesData'; -import { useTrendingSearch } from '../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; -import { - TimeOption, - PriceChangeOption, -} from '../../UI/Trending/components/TrendingTokensBottomSheet'; -import type { TrendingFilterContext } from '../../UI/Trending/components/TrendingTokensList/TrendingTokensList'; -import PredictMarketRowItem from '../../UI/Predict/components/PredictMarketRowItem'; -import SectionCard from './components/Sections/SectionTypes/SectionCard'; -import { useRwaTokens } from '../../UI/Trending/hooks/useRwaTokens/useRwaTokens'; -import SectionCarrousel from './components/Sections/SectionTypes/SectionCarrousel'; -import PredictMarket from '../../UI/Predict/components/PredictMarket'; -import PredictMarketSkeleton from '../../UI/Predict/components/PredictMarketSkeleton'; -import PerpsMarketTileCard from '../Homepage/Sections/Perpetuals/components/PerpsMarketTileCard'; -import PerpsMarketTileCardSkeleton from '../Homepage/Sections/Perpetuals/components/PerpsMarketTileCardSkeleton'; -import PerpsExploreSection from './components/Sections/SectionTypes/PerpsExploreSection'; - -export type SectionId = 'predictions' | 'tokens' | 'perps' | 'stocks' | 'sites'; - -export type SectionIcon = - | { source: 'local'; name: LocalIconName } - | { source: 'design-system'; name: DSIconName }; - -interface SectionData { - data: unknown[]; - isLoading: boolean; -} - -export interface SectionConfig { - id: SectionId; - title: string; - icon: SectionIcon; - viewAllAction: (navigation: AppNavigationProp) => void; - /** Returns a stable identifier for an item (e.g. assetId, symbol, url) used in analytics */ - getItemIdentifier: (item: unknown) => string; - RowItem: React.ComponentType<{ - item: unknown; - index: number; - navigation: AppNavigationProp; - }>; - OverrideRowItemSearch?: React.ComponentType<{ - item: unknown; - index?: number; - navigation: AppNavigationProp; - }>; - Skeleton: React.ComponentType; - OverrideSkeletonSearch?: React.ComponentType; - Section: React.ComponentType<{ - sectionId: SectionId; - data: unknown[]; - isLoading: boolean; - }>; - useSectionData: (searchQuery?: string) => { - data: unknown[]; - isLoading: boolean; - refetch: () => Promise | void; - }; - SectionWrapper?: React.ComponentType; -} - -const BASE_FUSE_OPTIONS = { - shouldSort: true, - // Tweak threshold search strictness (0.0 = strict, 1.0 = lenient) - threshold: 0.2, - location: 0, - distance: 100, - maxPatternLength: 32, - minMatchCharLength: 1, -} as const; - -const fuseSearch = ( - data: T[], - searchQuery: string | undefined, - fuseOptions: FuseOptions, - searchSortingFn?: (a: T, b: T) => number, -): T[] => { - searchQuery = searchQuery?.trim(); - if (!searchQuery) { - return data; - } - const fuse = new Fuse(data, fuseOptions); - const results = fuse.search(searchQuery); - - if (searchSortingFn) { - return results.sort(searchSortingFn); - } - - return results; -}; - -const TOKEN_FUSE_OPTIONS: FuseOptions = { - ...BASE_FUSE_OPTIONS, - keys: ['symbol', 'name', 'assetId'], -}; - -const PERPS_FUSE_OPTIONS: FuseOptions = { - ...BASE_FUSE_OPTIONS, - keys: ['symbol', 'name'], -}; - -const PREDICTIONS_FUSE_OPTIONS: FuseOptions = { - ...BASE_FUSE_OPTIONS, - keys: ['title', 'description'], -}; - -/** - * Centralized configuration for all Trending View sections. - * This config is used by QuickActions, SectionHeaders, Search, and TrendingView rendering. - * - * To add a new section (EVERYTHING IN THIS FILE): - * 1. Add the section ID to the SectionId type above - * 2. Add the config to SECTIONS_CONFIG, HOME_SECTIONS_ARRAY, and SECTIONS_ARRAY below - * 3. Add the hook to useSectionsData below - * - * The section will automatically appear in: - * - TrendingView main feed - * - QuickActions buttons - * - Search results - * - Section headers with "View All" navigation - */ - -/** - * Default filter context for tokens in the Trending View home section. - * Used for analytics tracking of token clicks from the home page. - */ -const DEFAULT_TOKENS_FILTER_CONTEXT: TrendingFilterContext = { - timeFilter: TimeOption.TwentyFourHours, - sortOption: PriceChangeOption.PriceChange, - networkFilter: 'all', - isSearchResult: false, -}; - -/** - * Filter context for tokens in search results on the Explore page. - * Used for analytics tracking of token clicks from search results. - */ -const SEARCH_TOKENS_FILTER_CONTEXT: TrendingFilterContext = { - timeFilter: TimeOption.TwentyFourHours, - sortOption: PriceChangeOption.PriceChange, - networkFilter: 'all', - isSearchResult: true, -}; - -export const SECTIONS_CONFIG: Record = { - tokens: { - id: 'tokens', - title: strings('trending.trending_tokens'), - icon: { source: 'design-system', name: DSIconName.Ethereum }, - viewAllAction: (navigation) => { - navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW); - }, - getItemIdentifier: (item) => (item as Partial).assetId ?? '', - RowItem: ({ item, index }) => ( - - ), - OverrideRowItemSearch: ({ item, index }) => ( - - ), - Skeleton: TrendingTokensSkeleton, - Section: SectionCard, - useSectionData: (searchQuery) => { - const { data, isLoading, refetch } = useTrendingSearch({ - searchQuery, - enableDebounce: false, // Disable debouncing here because useExploreSearch already handles it - }); - const filteredData = useMemo( - () => - fuseSearch( - data, - searchQuery, - TOKEN_FUSE_OPTIONS, - // Penalize zero marketCap tokens - (a, b) => (b.marketCap ?? 0) - (a.marketCap ?? 0), - ), - [data, searchQuery], - ); - return { data: filteredData, isLoading, refetch }; - }, - }, - perps: { - id: 'perps', - title: strings('trending.perps'), - icon: { source: 'design-system', name: DSIconName.Candlestick }, - viewAllAction: (navigation) => { - navigation.navigate(Routes.PERPS.ROOT, { - screen: Routes.PERPS.MARKET_LIST, - params: { - defaultMarketTypeFilter: 'all', - source: PERPS_EVENT_VALUE.SOURCE.EXPLORE, - }, - }); - }, - getItemIdentifier: (item) => - (item as Partial).symbol ?? '', - RowItem: ({ item, index: _index, navigation }) => ( - { - (navigation as NavigationProp)?.navigate( - Routes.PERPS.ROOT, - { - screen: Routes.PERPS.MARKET_DETAILS, - params: { - market: item as PerpsMarketData, - source: PERPS_EVENT_VALUE.SOURCE.EXPLORE, - }, - }, - ); - }} - /> - ), - OverrideRowItemSearch: ({ item, index: _index, navigation }) => ( - { - (navigation as NavigationProp)?.navigate( - Routes.PERPS.ROOT, - { - screen: Routes.PERPS.MARKET_DETAILS, - params: { - market: item as PerpsMarketData, - source: PERPS_EVENT_VALUE.SOURCE.EXPLORE, - }, - }, - ); - }} - showBadge={false} - compact - /> - ), - - Skeleton: PerpsMarketTileCardSkeleton, - OverrideSkeletonSearch: TrendingTokensSkeleton, - SectionWrapper: ({ children }) => ( - - {children} - - ), - Section: PerpsExploreSection, - useSectionData: (searchQuery) => { - const connectionContext = useContext(PerpsConnectionContext); - const { markets, isLoading, refresh, isRefreshing } = usePerpsMarkets(); - - const filteredMarkets = useMemo(() => { - if (connectionContext?.error) return []; - if (!searchQuery) { - return [...markets].sort( - (a, b) => - (parseFloat(b.change24hPercent) || 0) - - (parseFloat(a.change24hPercent) || 0), - ); - } - const filteredByQuery = filterMarketsByQuery(markets, searchQuery); - return fuseSearch(filteredByQuery, searchQuery, PERPS_FUSE_OPTIONS); - }, [markets, searchQuery, connectionContext?.error]); - - return { - data: filteredMarkets, - isLoading: connectionContext?.error ? false : isLoading || isRefreshing, - refetch: refresh, - }; - }, - }, - stocks: { - id: 'stocks', - title: strings('trending.stocks'), - icon: { source: 'local', name: LocalIconName.CorporateFare }, - viewAllAction: (navigation) => { - navigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW); - }, - getItemIdentifier: (item) => (item as Partial).assetId ?? '', - RowItem: ({ item, index }) => ( - - ), - OverrideRowItemSearch: ({ item, index }) => ( - - ), - Skeleton: TrendingTokensSkeleton, - Section: SectionCard, - useSectionData: (searchQuery) => { - const { data, isLoading, refetch } = useRwaTokens({ searchQuery }); - return { data, isLoading, refetch }; - }, - }, - predictions: { - id: 'predictions', - title: strings('wallet.predict'), - icon: { source: 'design-system', name: DSIconName.Speedometer }, - viewAllAction: (navigation) => { - navigation.navigate(Routes.PREDICT.ROOT, { - screen: Routes.PREDICT.MARKET_LIST, - }); - }, - getItemIdentifier: (item) => (item as Partial).id ?? '', - RowItem: ({ item, index: _index }) => ( - - - - ), - OverrideRowItemSearch: ({ item }) => ( - - ), - Skeleton: () => , - // Using sites skeleton cause PredictMarketSkeleton has too much spacing - OverrideSkeletonSearch: SiteSkeleton, - Section: SectionCarrousel, - useSectionData: (searchQuery) => { - const { marketData, isFetching, refetch } = usePredictMarketData({ - category: 'trending', - pageSize: searchQuery ? 20 : 6, - q: searchQuery || undefined, - }); - - const filteredData = useMemo( - () => fuseSearch(marketData, searchQuery, PREDICTIONS_FUSE_OPTIONS), - [marketData, searchQuery], - ); - - return { data: filteredData, isLoading: isFetching, refetch }; - }, - }, - sites: { - id: 'sites', - title: strings('trending.sites'), - icon: { source: 'design-system', name: DSIconName.Global }, - viewAllAction: (navigation) => { - navigation.navigate(Routes.SITES_FULL_VIEW); - }, - getItemIdentifier: (item) => (item as Partial).url ?? '', - RowItem: ({ item, index: _index, navigation }) => ( - - ), - Skeleton: SiteSkeleton, - Section: SectionCard, - useSectionData: (searchQuery) => { - const { sites, isLoading, refetch } = useSitesData(searchQuery); - return { data: sites, isLoading, refetch }; - }, - }, -}; - -const DEFAULT_HOME_ORDER: SectionId[] = [ - SECTIONS_CONFIG.predictions.id, - SECTIONS_CONFIG.tokens.id, - SECTIONS_CONFIG.perps.id, - SECTIONS_CONFIG.stocks.id, - SECTIONS_CONFIG.sites.id, -]; -const DEFAULT_QUICK_ACTIONS_ORDER: SectionId[] = [ - SECTIONS_CONFIG.tokens.id, - SECTIONS_CONFIG.perps.id, - SECTIONS_CONFIG.stocks.id, - SECTIONS_CONFIG.predictions.id, - SECTIONS_CONFIG.sites.id, -]; -const DEFAULT_SEARCH_ORDER: SectionId[] = [ - SECTIONS_CONFIG.tokens.id, - SECTIONS_CONFIG.perps.id, - SECTIONS_CONFIG.stocks.id, - SECTIONS_CONFIG.predictions.id, - SECTIONS_CONFIG.sites.id, -]; - -const buildSections = ( - order: SectionId[], - isPerpsEnabled: boolean, -): (SectionConfig & { id: SectionId })[] => - order - .filter((id) => isPerpsEnabled || id !== 'perps') - .map((id) => SECTIONS_CONFIG[id]); - -export const useHomeSections = (): (SectionConfig & { id: SectionId })[] => { - const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); - const orderConfig = useSelector(selectExploreSectionsOrder); - - return useMemo( - () => - buildSections(orderConfig?.home ?? DEFAULT_HOME_ORDER, isPerpsEnabled), - [isPerpsEnabled, orderConfig], - ); -}; - -export const useQuickActionsSectionsArray = (): (SectionConfig & { - id: SectionId; -})[] => { - const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); - const orderConfig = useSelector(selectExploreSectionsOrder); - - return useMemo( - () => - buildSections( - orderConfig?.quickActions ?? DEFAULT_QUICK_ACTIONS_ORDER, - isPerpsEnabled, - ), - [isPerpsEnabled, orderConfig], - ); -}; - -export const useSearchSectionsArray = (): (SectionConfig & { - id: SectionId; -})[] => { - const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); - const orderConfig = useSelector(selectExploreSectionsOrder); - - return useMemo( - () => - buildSections( - orderConfig?.search ?? DEFAULT_SEARCH_ORDER, - isPerpsEnabled, - ), - [isPerpsEnabled, orderConfig], - ); -}; - -/** - * Centralized hook that fetches data for all sections. - * When adding a new section, add its hook call here. - * This keeps all section-related logic in one file. - * - * @param searchQuery - Optional search query for sections that support search - * @returns Data and loading state for all sections - */ -export const useSectionsData = ( - searchQuery: string, -): Record => { - const { data: trendingTokens, isLoading: isTokensLoading } = - SECTIONS_CONFIG.tokens.useSectionData(searchQuery); - - const { data: perpsMarkets, isLoading: isPerpsLoading } = - SECTIONS_CONFIG.perps.useSectionData(searchQuery); - - const { data: stocks, isLoading: isStocksLoading } = - SECTIONS_CONFIG.stocks.useSectionData(searchQuery); - - const { data: predictionMarkets, isLoading: isPredictionsLoading } = - SECTIONS_CONFIG.predictions.useSectionData(searchQuery); - - const { data: sites, isLoading: isSitesLoading } = - SECTIONS_CONFIG.sites.useSectionData(searchQuery); - - return { - tokens: { - data: trendingTokens, - isLoading: isTokensLoading, - }, - perps: { - data: perpsMarkets, - isLoading: isPerpsLoading, - }, - stocks: { - // Avoids making 2 API calls to the search endpoint when searching on the main search - data: stocks, - isLoading: isStocksLoading, - }, - predictions: { - data: predictionMarkets, - isLoading: isPredictionsLoading, - }, - sites: { - data: sites, - isLoading: isSitesLoading, - }, - }; -}; diff --git a/app/components/Views/TrendingView/tabs/CryptoTab.tsx b/app/components/Views/TrendingView/tabs/CryptoTab.tsx new file mode 100644 index 00000000000..9557b4579b0 --- /dev/null +++ b/app/components/Views/TrendingView/tabs/CryptoTab.tsx @@ -0,0 +1,214 @@ +import React, { useCallback } from 'react'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { Box } from '@metamask/design-system-react-native'; +import type { ListRenderItem } from '@shopify/flash-list'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import type { PredictMarket as PredictMarketType } from '../../../UI/Predict/types'; +import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; +import type { AppNavigationProp } from '../../../../core/NavigationService/types'; +import { selectPerpsEnabledFlag } from '../../../UI/Perps'; +import Routes from '../../../../constants/navigation/Routes'; +import { strings } from '../../../../../locales/i18n'; +import { TokenDetailsSource } from '../../../UI/TokenDetails/constants/constants'; +import { useTokensFeed } from '../feeds/tokens/useTokensFeed'; +import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/TrendingTokenRowItem/utils'; +import { TokenRowItem } from '../feeds/tokens/TokenRowItem'; +import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; +import { usePerpsFeed, type PerpsFeedItem } from '../feeds/perps/usePerpsFeed'; +import type { SortOptionId } from '@metamask/perps-controller'; +import PerpsSectionProvider from '../feeds/perps/PerpsSectionProvider'; +import PerpsTileRowItem from '../feeds/perps/PerpsTileRowItem'; +import PerpsMarketTileCardSkeleton from '../../Homepage/Sections/Perpetuals/components/PerpsMarketTileCardSkeleton'; +import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation'; +import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; +import { PredictionCarouselRowItem } from '../feeds/predictions/PredictionRowItem'; +import PredictionsSkeleton from '../feeds/predictions/PredictionsSkeleton'; +import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavigation'; +import CardList from '../components/CardList'; +import ExploreScroll from '../components/ExploreScroll'; +import HorizontalCarousel from '../components/HorizontalCarousel'; +import SectionHeader from '../components/SectionHeader'; +import TileCarousel from '../components/TileCarousel'; +import type { TabProps } from '../hooks/useExploreRefresh'; +import { trackExploreInteracted } from '../search/analytics'; + +interface CryptoPerpsBlockProps { + refresh: TabProps['refresh']; + onViewAll: (sortOptionId: SortOptionId) => void; +} + +const CryptoPerpsBlock: React.FC = ({ + refresh, + onViewAll, +}) => { + const perps = usePerpsFeed({ + variant: 'crypto', + refresh, + withTileExtras: true, + }); + + if (!perps.isLoading && perps.data.length === 0) return null; + + return ( + + onViewAll(perps.defaultSortOptionId)} + testID="section-header-view-all-crypto_perps" + tabName="Crypto" + sectionName="perps_crypto" + /> + + data={perps.data} + isLoading={perps.isLoading} + renderItem={(item, index) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Crypto', + section_name: 'perps_crypto', + asset_type: 'perp', + position: index, + item_clicked: item.market.symbol, + }) + } + /> + )} + keyExtractor={(item) => item.market.symbol} + Skeleton={PerpsMarketTileCardSkeleton} + onViewMore={() => onViewAll(perps.defaultSortOptionId)} + testID="explore-crypto_perps-carousel" + viewMoreTestID="crypto_perps-view-more-card" + /> + + ); +}; + +const CryptoTab: React.FC = ({ refresh, refreshing, onRefresh }) => { + const navigation = useNavigation(); + const perpsNavigation = + useNavigation>(); + const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); + + const tokens = useTokensFeed({ refresh }); + const cryptoPredictions = usePredictionsFeed({ + variant: 'crypto', + refresh, + }); + + const renderTokenItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Crypto', + section_name: 'tokens_trending', + asset_type: 'token', + position: index, + token_symbol: item.symbol, + chain_id: getCaipChainIdFromAssetId(item.assetId), + item_clicked: item.assetId, + }) + } + /> + ), + [], + ); + + const renderPredictionItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Crypto', + section_name: 'predictions_crypto', + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'Crypto', + section_name: 'predictions_crypto', + item_clicked: marketId, + }) + } + /> + ), + [], + ); + + const showTokens = tokens.isLoading || tokens.data.length > 0; + const showCryptoPredictions = + cryptoPredictions.isLoading || cryptoPredictions.data.length > 0; + + return ( + + {showTokens && ( + + + navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW) + } + testID="section-header-view-all-tokens" + tabName="Crypto" + sectionName="tokens_trending" + /> + + data={tokens.data} + isLoading={tokens.isLoading} + renderItem={renderTokenItem} + Skeleton={TrendingTokensSkeleton} + idPrefix="tokens" + /> + + )} + + {isPerpsEnabled && ( + + + navigateToPerpsMarketList(perpsNavigation, 'crypto', sortOptionId) + } + /> + + )} + + {showCryptoPredictions && ( + + navigateToPredictionsList(navigation, 'crypto')} + testID="section-header-view-all-crypto_predictions" + tabName="Crypto" + sectionName="predictions_crypto" + /> + + data={cryptoPredictions.data} + isLoading={cryptoPredictions.isLoading} + renderItem={renderPredictionItem} + Skeleton={PredictionsSkeleton} + idPrefix="crypto_predictions" + /> + + )} + + ); +}; + +export default CryptoTab; diff --git a/app/components/Views/TrendingView/tabs/DappsTab.tsx b/app/components/Views/TrendingView/tabs/DappsTab.tsx new file mode 100644 index 00000000000..6e5ad0b18e4 --- /dev/null +++ b/app/components/Views/TrendingView/tabs/DappsTab.tsx @@ -0,0 +1,181 @@ +import React, { useCallback } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { Box } from '@metamask/design-system-react-native'; +import type { ListRenderItem } from '@shopify/flash-list'; +import type { AppNavigationProp } from '../../../../core/NavigationService/types'; +import type { SiteData } from '../../../UI/Sites/components/SiteRowItem/SiteRowItem'; +import Routes from '../../../../constants/navigation/Routes'; +import { strings } from '../../../../../locales/i18n'; +import SiteTileRowItem from '../feeds/dapps/SiteTileRowItem'; +import SiteTileSkeleton from '../feeds/dapps/SiteTileSkeleton'; +import { useFavoritesFeed } from '../feeds/dapps/useFavoritesFeed'; +import { useNetworksFeed } from '../feeds/dapps/useNetworksFeed'; +import { useRecentsFeed } from '../feeds/dapps/useRecentsFeed'; +import { FavoriteSiteRowItem, SiteRowItem } from '../feeds/sites/SiteRowItem'; +import SiteSkeleton from '../../../UI/Sites/components/SiteSkeleton/SiteSkeleton'; +import { useSitesFeed } from '../feeds/sites/useSitesFeed'; +import CardList from '../components/CardList'; +import ExploreScroll from '../components/ExploreScroll'; +import SectionHeader from '../components/SectionHeader'; +import TileCarousel from '../components/TileCarousel'; +import type { TabProps } from '../hooks/useExploreRefresh'; +import { trackExploreInteracted } from '../search/analytics'; + +const DappsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { + const navigation = useNavigation(); + + const recents = useRecentsFeed({ refresh }); + const favorites = useFavoritesFeed({ refresh }); + const networks = useNetworksFeed(); + const sites = useSitesFeed({ refresh }); + + const renderFavorite: ListRenderItem = useCallback( + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sites', + section_name: 'sites_favorites', + asset_type: 'dapp', + position: index, + item_clicked: item.url, + }) + } + /> + ), + [], + ); + + const renderSite: ListRenderItem = useCallback( + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sites', + section_name: 'sites_popular', + asset_type: 'dapp', + position: index, + item_clicked: item.url, + }) + } + /> + ), + [], + ); + + const showRecents = recents.isLoading || recents.data.length > 0; + const showFavorites = favorites.isLoading || favorites.data.length > 0; + const showSites = sites.isLoading || sites.data.length > 0; + + return ( + + {showRecents && ( + + + + data={recents.data} + isLoading={recents.isLoading} + renderItem={(site, index) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sites', + section_name: 'sites_recents', + asset_type: 'dapp', + position: index, + item_clicked: site.url, + }) + } + /> + )} + keyExtractor={(site) => site.url} + Skeleton={SiteTileSkeleton} + compactSectionTail + testID="explore-dapps_recents-carousel" + /> + + )} + + {showFavorites && ( + + + navigation.navigate(Routes.SITES_FULL_VIEW, { mode: 'favorites' }) + } + testID="section-header-view-all-dapps_favorites" + tabName="Sites" + sectionName="sites_favorites" + /> + + data={favorites.data} + isLoading={favorites.isLoading} + renderItem={renderFavorite} + Skeleton={SiteSkeleton} + idPrefix="dapps_favorites" + /> + + )} + + + + + data={networks.data} + isLoading={false} + renderItem={(site, index) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sites', + section_name: 'sites_ecosystems', + asset_type: 'dapp', + position: index, + item_clicked: site.url, + }) + } + /> + )} + keyExtractor={(site) => site.url} + Skeleton={SiteTileSkeleton} + testID="explore-dapps_networks-carousel" + /> + + + {showSites && ( + + navigation.navigate(Routes.SITES_FULL_VIEW)} + testID="section-header-view-all-sites" + tabName="Sites" + sectionName="sites_popular" + /> + + data={sites.data} + isLoading={sites.isLoading} + renderItem={renderSite} + Skeleton={SiteSkeleton} + idPrefix="sites" + /> + + )} + + ); +}; + +export default DappsTab; diff --git a/app/components/Views/TrendingView/tabs/MacroTab.tsx b/app/components/Views/TrendingView/tabs/MacroTab.tsx new file mode 100644 index 00000000000..471f5aee9e5 --- /dev/null +++ b/app/components/Views/TrendingView/tabs/MacroTab.tsx @@ -0,0 +1,156 @@ +import React, { useCallback, useMemo } from 'react'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { Box } from '@metamask/design-system-react-native'; +import type { ListRenderItem } from '@shopify/flash-list'; +import type { PredictMarket as PredictMarketType } from '../../../UI/Predict/types'; +import type { PerpsMarketData, SortOptionId } from '@metamask/perps-controller'; +import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; +import type { AppNavigationProp } from '../../../../core/NavigationService/types'; +import { selectPerpsEnabledFlag } from '../../../UI/Perps'; +import { selectPredictEnabledFlag } from '../../../UI/Predict'; +import { strings } from '../../../../../locales/i18n'; +import { usePerpsFeed } from '../feeds/perps/usePerpsFeed'; +import PerpsSectionProvider from '../feeds/perps/PerpsSectionProvider'; +import PerpsToggleBlock from '../feeds/perps/PerpsToggleBlock'; +import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation'; +import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; +import { PredictionCarouselRowItem } from '../feeds/predictions/PredictionRowItem'; +import PredictionsSkeleton from '../feeds/predictions/PredictionsSkeleton'; +import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavigation'; +import ExploreScroll from '../components/ExploreScroll'; +import HorizontalCarousel from '../components/HorizontalCarousel'; +import type { PillToggleCardListTab } from '../components/PillToggleCardList'; +import SectionHeader from '../components/SectionHeader'; +import type { TabProps } from '../hooks/useExploreRefresh'; +import { trackExploreInteracted } from '../search/analytics'; + +interface MacroPerpsBlockProps { + refresh: TabProps['refresh']; + onViewAll: (filter: string, sortOptionId: SortOptionId) => void; +} + +const MacroPerpsBlock: React.FC = ({ + refresh, + onViewAll, +}) => { + const perps = usePerpsFeed({ variant: 'macro', refresh }); + + const tabs = useMemo[]>(() => { + const byType = (type: PerpsMarketData['marketType']) => + perps.data + .filter((d) => d.market.marketType === type) + .slice(0, 3) + .map((d) => d.market); + return [ + { + key: 'stocks', + name: strings('trending.macro_pill_stocks'), + items: byType('equity'), + }, + { + key: 'commodities', + name: strings('trending.macro_pill_commodities'), + items: byType('commodity'), + }, + ]; + }, [perps.data]); + + if (!perps.isLoading && perps.data.length === 0) return null; + + return ( + + ); +}; + +const MacroTab: React.FC = ({ refresh, refreshing, onRefresh }) => { + const appNavigation = useNavigation(); + const perpsNavigation = + useNavigation>(); + const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); + const isPredictEnabled = useSelector(selectPredictEnabledFlag); + + const politics = usePredictionsFeed({ variant: 'politics', refresh }); + + const renderPredictionItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Macro', + section_name: 'predictions_politics', + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'Macro', + section_name: 'predictions_politics', + item_clicked: marketId, + }) + } + /> + ), + [], + ); + + const showPolitics = + isPredictEnabled && (politics.isLoading || politics.data.length > 0); + + return ( + + {showPolitics && ( + + + navigateToPredictionsList(appNavigation, 'politics') + } + testID="section-header-view-all-politics_predictions" + tabName="Macro" + sectionName="predictions_politics" + /> + + data={politics.data} + isLoading={politics.isLoading} + renderItem={renderPredictionItem} + Skeleton={PredictionsSkeleton} + idPrefix="politics_predictions" + /> + + )} + + {isPerpsEnabled && ( + + + navigateToPerpsMarketList(perpsNavigation, filter, sortOptionId) + } + /> + + )} + + ); +}; + +export default MacroTab; diff --git a/app/components/Views/TrendingView/tabs/NowTab.test.tsx b/app/components/Views/TrendingView/tabs/NowTab.test.tsx new file mode 100644 index 00000000000..35927344c0c --- /dev/null +++ b/app/components/Views/TrendingView/tabs/NowTab.test.tsx @@ -0,0 +1,215 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react-native'; +import { NavigationContainer } from '@react-navigation/native'; + +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +// Feed hooks — return empty/not-loading so NowTab renders without network calls. +jest.mock('../feeds/tokens/useTokensFeed', () => ({ + useTokensFeed: jest.fn(() => ({ data: [], isLoading: false })), +})); + +const mockUsePerpsFeed = jest.fn(() => ({ + data: [], + isLoading: false, + refetch: jest.fn(), + defaultSortOptionId: 'priceChange' as const, +})); + +jest.mock('../feeds/perps/usePerpsFeed', () => ({ + usePerpsFeed: () => mockUsePerpsFeed(), +})); + +const mockNavigateToPerpsMarketList = jest.fn(); +jest.mock('../feeds/perps/perpsNavigation', () => ({ + navigateToPerpsMarketList: ( + nav: unknown, + filter: unknown, + sortOptionId: unknown, + ) => mockNavigateToPerpsMarketList(nav, filter, sortOptionId), +})); + +jest.mock('../feeds/predictions/usePredictionsFeed', () => ({ + usePredictionsFeed: jest.fn(() => ({ data: [], isLoading: false })), +})); + +jest.mock('../feeds/stocks/useStocksFeed', () => ({ + useStocksFeed: jest.fn(() => ({ data: [], isLoading: false })), +})); + +// Mock PerpsSectionProvider as a transparent passthrough. +jest.mock('../feeds/perps/PerpsSectionProvider', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createElement } = require('react'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { View } = require('react-native'); + return ({ children }: { children: unknown }) => + createElement(View, null, children); +}); + +// Mock WhatsHappeningSection to keep its transitive deps (Engine, analytics) +// out of this unit test. We control rendering via mockWhatsHappeningImpl. +const mockWhatsHappeningImpl = jest.fn( + () => null, +); + +jest.mock('../../Homepage/Sections/WhatsHappening', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { forwardRef } = require('react'); + return { + __esModule: true, + default: forwardRef((_props: unknown, ref: unknown) => + mockWhatsHappeningImpl(ref), + ), + }; +}); + +import { useSelector } from 'react-redux'; +import { selectPerpsEnabledFlag } from '../../../UI/Perps'; +import { selectPredictEnabledFlag } from '../../../UI/Predict'; +import { selectWhatsHappeningEnabled } from '../../../../selectors/featureFlagController/whatsHappening'; +import NowTab from './NowTab'; +import type { RefreshConfig } from '../hooks/useExploreRefresh'; + +const defaultRefresh: RefreshConfig = { trigger: 0, silentRefresh: true }; +const defaultTabProps = { + refresh: defaultRefresh, + refreshing: false, + onRefresh: jest.fn(), +}; + +const renderNowTab = (props = defaultTabProps) => + render( + + + , + ); + +describe('NowTab — WhatsHappeningSection integration', () => { + const mockUseSelector = useSelector as jest.MockedFunction< + typeof useSelector + >; + + const mockSelectorBase = (selector: unknown) => { + if (selector === selectPerpsEnabledFlag) return false; + if (selector === selectPredictEnabledFlag) return false; + return undefined; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockImplementation(mockSelectorBase); + // Default: section mock renders nothing; individual tests override as needed. + mockWhatsHappeningImpl.mockReturnValue(null); + }); + + it('mounts WhatsHappeningSection and renders it when the feature flag is enabled', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectWhatsHappeningEnabled) return true; + return mockSelectorBase(selector); + }); + (mockWhatsHappeningImpl as jest.Mock).mockReturnValue( + React.createElement('View', { + testID: 'homepage-whats-happening-carousel', + }), + ); + + renderNowTab(); + + expect( + screen.getByTestId('homepage-whats-happening-carousel'), + ).toBeOnTheScreen(); + }); + + it('does not mount WhatsHappeningSection when the feature flag is disabled', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectWhatsHappeningEnabled) return false; + return mockSelectorBase(selector); + }); + + renderNowTab(); + + // Section is not even mounted, so the mock should never have been called. + expect(mockWhatsHappeningImpl).not.toHaveBeenCalled(); + expect( + screen.queryByTestId('homepage-whats-happening-carousel'), + ).toBeNull(); + }); + + it('passes a ref to WhatsHappeningSection so pull-to-refresh can trigger it', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectWhatsHappeningEnabled) return true; + return mockSelectorBase(selector); + }); + + renderNowTab(); + + // The mock's first argument is the forwarded ref (we dropped props in the mock). + // It should be a React ref object so the useEffect bridge can call .refresh(). + expect(mockWhatsHappeningImpl).toHaveBeenCalled(); + const [forwardedRef] = mockWhatsHappeningImpl.mock.calls[0]; + expect(forwardedRef).not.toBeNull(); + }); +}); + +describe('NowTab — Perps Movers "View All" navigation', () => { + const mockUseSelector = useSelector as jest.MockedFunction< + typeof useSelector + >; + + // Selector base: perps enabled, everything else off. + const mockSelectorBase = (selector: unknown) => { + if (selector === selectPerpsEnabledFlag) return true; + if (selector === selectPredictEnabledFlag) return false; + return undefined; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockImplementation(mockSelectorBase); + mockWhatsHappeningImpl.mockReturnValue(null); + }); + + it('calls navigateToPerpsMarketList with "all" filter and the defaultSortOptionId from usePerpsFeed', () => { + // Return one market so PerpsBlock does not bail out with an early null return. + mockUsePerpsFeed.mockReturnValue({ + data: [{ market: { symbol: 'BTC' } }] as never, + isLoading: false, + refetch: jest.fn(), + defaultSortOptionId: 'priceChange' as const, + }); + + renderNowTab(); + + fireEvent.press(screen.getByTestId('section-header-view-all-perps')); + + expect(mockNavigateToPerpsMarketList).toHaveBeenCalledTimes(1); + expect(mockNavigateToPerpsMarketList).toHaveBeenCalledWith( + expect.anything(), // navigation object + 'all', + 'priceChange', + ); + }); + + it('does not render the Perps Movers section when the perps flag is disabled', () => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectPerpsEnabledFlag) return false; + if (selector === selectPredictEnabledFlag) return false; + return undefined; + }); + + renderNowTab(); + + expect(screen.queryByTestId('section-header-view-all-perps')).toBeNull(); + }); +}); diff --git a/app/components/Views/TrendingView/tabs/NowTab.tsx b/app/components/Views/TrendingView/tabs/NowTab.tsx new file mode 100644 index 00000000000..5369fae39f3 --- /dev/null +++ b/app/components/Views/TrendingView/tabs/NowTab.tsx @@ -0,0 +1,277 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { Box } from '@metamask/design-system-react-native'; +import type { ListRenderItem } from '@shopify/flash-list'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import type { PredictMarket as PredictMarketType } from '../../../UI/Predict/types'; +import type { AppNavigationProp } from '../../../../core/NavigationService/types'; +import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; +import { selectPerpsEnabledFlag } from '../../../UI/Perps'; +import { selectPredictEnabledFlag } from '../../../UI/Predict'; +import Routes from '../../../../constants/navigation/Routes'; +import { strings } from '../../../../../locales/i18n'; +import { TrendingViewSelectorsIDs } from '../TrendingView.testIds'; +import { TokenDetailsSource } from '../../../UI/TokenDetails/constants/constants'; +import { useTokensFeed } from '../feeds/tokens/useTokensFeed'; +import { TokenRowItem } from '../feeds/tokens/TokenRowItem'; +import CryptoMoversPillItem from '../feeds/tokens/CryptoMoversPillItem'; +import CryptoMoversSkeleton from '../feeds/tokens/CryptoMoversSkeleton'; +import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; +import { usePerpsFeed, type PerpsFeedItem } from '../feeds/perps/usePerpsFeed'; +import PerpsSectionProvider from '../feeds/perps/PerpsSectionProvider'; +import PerpsPillItem from '../feeds/perps/PerpsPillItem'; +import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation'; +import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; +import { PredictionCarouselRowItem } from '../feeds/predictions/PredictionRowItem'; +import PredictionsSkeleton from '../feeds/predictions/PredictionsSkeleton'; +import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavigation'; +import { useStocksFeed } from '../feeds/stocks/useStocksFeed'; +import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/TrendingTokenRowItem/utils'; +import CardList from '../components/CardList'; +import ExploreScroll from '../components/ExploreScroll'; +import HorizontalCarousel from '../components/HorizontalCarousel'; +import PillScrollList from '../components/PillScrollList'; +import SectionHeader from '../components/SectionHeader'; +import type { TabProps } from '../hooks/useExploreRefresh'; +import { trackExploreInteracted } from '../search/analytics'; +import WhatsHappeningSection from '../../Homepage/Sections/WhatsHappening'; +import type { SectionRefreshHandle } from '../../Homepage/types'; +import { selectWhatsHappeningEnabled } from '../../../../selectors/featureFlagController/whatsHappening'; + +interface PerpsBlockProps { + refresh: TabProps['refresh']; + navigation: NavigationProp; +} + +const PerpsBlock: React.FC = ({ refresh, navigation }) => { + const perps = usePerpsFeed({ + variant: 'all', + refresh, + withTileExtras: false, + }); + + if (!perps.isLoading && perps.data.length === 0) return null; + + return ( + + + navigateToPerpsMarketList( + navigation, + 'all', + perps.defaultSortOptionId, + ) + } + testID="section-header-view-all-perps" + tabName="Now" + sectionName="perps_movers" + /> + + data={perps.data} + isLoading={perps.isLoading} + renderItem={(item, index) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Now', + section_name: 'perps_movers', + asset_type: 'perp', + position: index, + item_clicked: item.market.symbol, + }) + } + /> + )} + keyExtractor={(item) => item.market.symbol} + Skeleton={CryptoMoversSkeleton} + listTestId="explore-perps-pills-list" + /> + + ); +}; + +const NowTab: React.FC = ({ refresh, refreshing, onRefresh }) => { + const navigation = useNavigation(); + const perpsNavigation = + useNavigation>(); + const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); + const isPredictEnabled = useSelector(selectPredictEnabledFlag); + const isWhatsHappeningEnabled = useSelector(selectWhatsHappeningEnabled); + + const whatsHappeningRef = useRef(null); + + useEffect(() => { + if (refresh.trigger === 0) return; + whatsHappeningRef.current?.refresh(); + }, [refresh.trigger]); + + const predictions = usePredictionsFeed({ refresh }); + const cryptoMovers = useTokensFeed({ refresh, hideRiskyTokens: true }); + const stocks = useStocksFeed({ refresh }); + + const renderPredictionItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Now', + section_name: 'predictions_trending', + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'Now', + section_name: 'predictions_trending', + item_clicked: marketId, + }) + } + /> + ), + [], + ); + + const renderTokenItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Now', + section_name: 'stocks', + asset_type: 'stock', + position: index, + token_symbol: item.symbol, + chain_id: getCaipChainIdFromAssetId(item.assetId), + item_clicked: item.assetId, + }) + } + /> + ), + [], + ); + + const showPredictions = + isPredictEnabled && (predictions.isLoading || predictions.data.length > 0); + const showCryptoMovers = + cryptoMovers.isLoading || cryptoMovers.data.length > 0; + const showStocks = stocks.isLoading || stocks.data.length > 0; + + return ( + + {isWhatsHappeningEnabled && ( + + + + )} + + {showPredictions && ( + + navigateToPredictionsList(navigation, 'trending')} + testID="section-header-view-all-predictions" + tabName="Now" + sectionName="predictions_trending" + /> + + data={predictions.data} + isLoading={predictions.isLoading} + renderItem={renderPredictionItem} + Skeleton={PredictionsSkeleton} + idPrefix="predictions" + /> + + )} + + {showCryptoMovers && ( + + + navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW) + } + testID="section-header-view-all-crypto_movers" + tabName="Now" + sectionName="tokens_movers" + /> + + data={cryptoMovers.data} + isLoading={cryptoMovers.isLoading} + renderItem={(token, index) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Now', + section_name: 'tokens_movers', + asset_type: 'token', + position: index, + token_symbol: token.symbol, + chain_id: getCaipChainIdFromAssetId(token.assetId), + item_clicked: token.assetId, + }) + } + /> + )} + keyExtractor={(token) => token.assetId ?? ''} + Skeleton={CryptoMoversSkeleton} + listTestId="explore-crypto_movers-pills-list" + /> + + )} + + {isPerpsEnabled && ( + + + + )} + + {showStocks && ( + + + navigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW) + } + testID="section-header-view-all-stocks" + tabName="Now" + sectionName="stocks" + /> + + data={stocks.data} + isLoading={stocks.isLoading} + renderItem={renderTokenItem} + Skeleton={TrendingTokensSkeleton} + idPrefix="stocks" + /> + + )} + + ); +}; + +export default NowTab; diff --git a/app/components/Views/TrendingView/tabs/RwasTab.tsx b/app/components/Views/TrendingView/tabs/RwasTab.tsx new file mode 100644 index 00000000000..8b9ed6cadeb --- /dev/null +++ b/app/components/Views/TrendingView/tabs/RwasTab.tsx @@ -0,0 +1,215 @@ +import React, { useCallback, useMemo } from 'react'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { Box } from '@metamask/design-system-react-native'; +import type { ListRenderItem } from '@shopify/flash-list'; +import type { TrendingAsset } from '@metamask/assets-controllers'; +import type { PerpsMarketData, SortOptionId } from '@metamask/perps-controller'; +import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; +import type { PredictMarket as PredictMarketType } from '../../../UI/Predict/types'; +import type { AppNavigationProp } from '../../../../core/NavigationService/types'; +import { selectPerpsEnabledFlag } from '../../../UI/Perps'; +import { selectPredictEnabledFlag } from '../../../UI/Predict'; +import Routes from '../../../../constants/navigation/Routes'; +import { strings } from '../../../../../locales/i18n'; +import { TokenDetailsSource } from '../../../UI/TokenDetails/constants/constants'; +import { TokenRowItem } from '../feeds/tokens/TokenRowItem'; +import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; +import { useStocksFeed } from '../feeds/stocks/useStocksFeed'; +import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/TrendingTokenRowItem/utils'; +import { usePerpsFeed } from '../feeds/perps/usePerpsFeed'; +import PerpsSectionProvider from '../feeds/perps/PerpsSectionProvider'; +import PerpsToggleBlock from '../feeds/perps/PerpsToggleBlock'; +import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation'; +import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; +import { PredictionCarouselRowItem } from '../feeds/predictions/PredictionRowItem'; +import PredictionsSkeleton from '../feeds/predictions/PredictionsSkeleton'; +import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavigation'; +import CardList from '../components/CardList'; +import ExploreScroll from '../components/ExploreScroll'; +import HorizontalCarousel from '../components/HorizontalCarousel'; +import type { PillToggleCardListTab } from '../components/PillToggleCardList'; +import SectionHeader from '../components/SectionHeader'; +import type { TabProps } from '../hooks/useExploreRefresh'; +import { trackExploreInteracted } from '../search/analytics'; + +interface RwaPerpsBlockProps { + refresh: TabProps['refresh']; + onViewAll: (filter: string, sortOptionId: SortOptionId) => void; +} + +const RwaPerpsBlock: React.FC = ({ + refresh, + onViewAll, +}) => { + const perps = usePerpsFeed({ variant: 'rwa', refresh }); + + const tabs = useMemo[]>(() => { + const byType = (type: PerpsMarketData['marketType']) => + perps.data + .filter((d) => d.market.marketType === type) + .slice(0, 3) + .map((d) => d.market); + return [ + { + key: 'commodities', + name: strings('trending.rwa_pill_commodities'), + items: byType('commodity'), + }, + { + key: 'stocks', + name: strings('trending.rwa_pill_stocks'), + items: byType('equity'), + }, + { + key: 'forex', + name: strings('trending.rwa_pill_forex'), + items: byType('forex'), + }, + ]; + }, [perps.data]); + + if (!perps.isLoading && perps.data.length === 0) return null; + + return ( + + ); +}; + +const RwasTab: React.FC = ({ refresh, refreshing, onRefresh }) => { + const appNavigation = useNavigation(); + const perpsNavigation = + useNavigation>(); + const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); + const isPredictEnabled = useSelector(selectPredictEnabledFlag); + + const politics = usePredictionsFeed({ variant: 'politics', refresh }); + const stocks = useStocksFeed({ refresh }); + + const renderPredictionItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'RWAs', + section_name: 'predictions_politics', + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'RWAs', + section_name: 'predictions_politics', + item_clicked: marketId, + }) + } + /> + ), + [], + ); + + const renderStockItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'RWAs', + section_name: 'stocks', + asset_type: 'stock', + position: index, + token_symbol: item.symbol, + chain_id: getCaipChainIdFromAssetId(item.assetId), + item_clicked: item.assetId, + }) + } + /> + ), + [], + ); + + const showPolitics = + isPredictEnabled && (politics.isLoading || politics.data.length > 0); + const showStocks = stocks.isLoading || stocks.data.length > 0; + + return ( + + {showPolitics && ( + + + navigateToPredictionsList(appNavigation, 'politics') + } + testID="section-header-view-all-politics_predictions" + tabName="RWAs" + sectionName="predictions_politics" + /> + + data={politics.data} + isLoading={politics.isLoading} + renderItem={renderPredictionItem} + Skeleton={PredictionsSkeleton} + idPrefix="politics_predictions" + /> + + )} + + {showStocks && ( + + + appNavigation.navigate(Routes.WALLET.RWA_TOKENS_FULL_VIEW) + } + testID="section-header-view-all-stocks" + tabName="RWAs" + sectionName="stocks" + /> + + data={stocks.data} + isLoading={stocks.isLoading} + renderItem={renderStockItem} + Skeleton={TrendingTokensSkeleton} + idPrefix="stocks" + /> + + )} + + {isPerpsEnabled && ( + + + navigateToPerpsMarketList(perpsNavigation, filter, sortOptionId) + } + /> + + )} + + ); +}; + +export default RwasTab; diff --git a/app/components/Views/TrendingView/tabs/SportsTab.tsx b/app/components/Views/TrendingView/tabs/SportsTab.tsx new file mode 100644 index 00000000000..e7d6d0ed1e3 --- /dev/null +++ b/app/components/Views/TrendingView/tabs/SportsTab.tsx @@ -0,0 +1,276 @@ +import React, { useCallback, useRef } from 'react'; +import { + ActivityIndicator, + TouchableOpacity, + RefreshControl, +} from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import { + Box, + BoxFlexDirection, + BoxJustifyContent, + FontWeight, + TabEmptyState, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import { FlashList, type ListRenderItem } from '@shopify/flash-list'; +import type { PredictMarket as PredictMarketType } from '../../../UI/Predict/types'; +import { useTheme } from '../../../../util/theme'; +import { selectPredictEnabledFlag } from '../../../UI/Predict'; +import PredictMarket from '../../../UI/Predict/components/PredictMarket'; +import type { AppNavigationProp } from '../../../../core/NavigationService/types'; +import { strings } from '../../../../../locales/i18n'; +import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed'; +import { + useSportsMarketsFeed, + type UseSportsMarketsFeedResult, +} from '../feeds/predictions/useSportsMarketsFeed'; +import { PredictionCarouselRowItem } from '../feeds/predictions/PredictionRowItem'; +import PredictionsSkeleton from '../feeds/predictions/PredictionsSkeleton'; +import { navigateToPredictionsList } from '../feeds/predictions/predictionsNavigation'; +import HorizontalCarousel from '../components/HorizontalCarousel'; +import PillRow from '../components/PillRow'; +import SectionHeader from '../components/SectionHeader'; +import type { TabProps } from '../hooks/useExploreRefresh'; +import { + trackExploreInteracted, + type ExploreSectionName, +} from '../search/analytics'; + +const SPORT_KEY_TO_SECTION: Record = { + soccer: 'predictions_football', + basketball: 'predictions_basketball', + tennis: 'predictions_tennis', +}; + +interface SportsListHeaderProps { + showSportsPredictions: boolean; + sportsPredictionsData: PredictMarketType[]; + sportsPredictionsLoading: boolean; + sportsMarkets: UseSportsMarketsFeedResult; + showAllSportsSkeleton: boolean; + showAllSportsEmpty: boolean; + navigation: AppNavigationProp; +} + +const renderPredictionItem: ListRenderItem = ({ + item, + index, +}) => ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sports', + section_name: 'predictions_sports', + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'Sports', + section_name: 'predictions_sports', + item_clicked: marketId, + }) + } + /> +); + +const SportsListHeader: React.FC = ({ + showSportsPredictions, + sportsPredictionsData, + sportsPredictionsLoading, + sportsMarkets, + showAllSportsSkeleton, + showAllSportsEmpty, + navigation, +}) => ( + + {showSportsPredictions && ( + + navigateToPredictionsList(navigation, 'sports')} + testID="section-header-view-all-sports_predictions" + tabName="Sports" + sectionName="predictions_sports" + /> + + data={sportsPredictionsData} + isLoading={sportsPredictionsLoading} + renderItem={renderPredictionItem} + Skeleton={PredictionsSkeleton} + idPrefix="sports_predictions" + /> + + )} + + + + + + + + {showAllSportsSkeleton && ( + + {[0, 1, 2].map((i) => ( + + + + ))} + + )} + + {showAllSportsEmpty && ( + + + + )} + + +); + +const SportsTab: React.FC = ({ refresh, refreshing, onRefresh }) => { + const tw = useTailwind(); + const navigation = useNavigation(); + const isPredictEnabled = useSelector(selectPredictEnabledFlag); + const { colors } = useTheme(); + + const sportsPredictions = usePredictionsFeed({ variant: 'sports', refresh }); + const sportsMarkets = useSportsMarketsFeed({ refresh }); + + const { active, activeKey } = sportsMarkets; + const activeKeyRef = useRef(activeKey); + activeKeyRef.current = activeKey; + + const renderActiveMarketItem: ListRenderItem = useCallback( + ({ item, index }) => { + const sectionName = + SPORT_KEY_TO_SECTION[activeKeyRef.current] ?? 'predictions_football'; + return ( + + trackExploreInteracted({ + interaction_type: 'section_item_tapped', + tab_name: 'Sports', + section_name: sectionName, + asset_type: 'prediction', + position: index, + item_clicked: item.id, + }) + } + onBuyButtonPress={(marketId) => + trackExploreInteracted({ + interaction_type: 'prediction_voted', + tab_name: 'Sports', + section_name: sectionName, + item_clicked: marketId, + }) + } + /> + ); + }, + [], + ); + + const showSportsPredictions = + isPredictEnabled && + (sportsPredictions.isLoading || sportsPredictions.data.length > 0); + const showAllSportsSkeleton = + active.isFetching && active.marketData.length === 0; + const showAllSportsEmpty = + !showAllSportsSkeleton && active.marketData.length === 0; + + const listHeader = ( + + ); + + const listFooter = + active.hasMore && active.marketData.length > 0 ? ( + + + {active.isFetchingMore ? ( + + ) : ( + + {strings('trending.load_more')} + + )} + + + ) : ( + + ); + + // When loading or empty, data is empty — header renders those states. + const listData = + showAllSportsSkeleton || showAllSportsEmpty ? [] : active.marketData; + + return ( + + data={listData} + renderItem={renderActiveMarketItem} + keyExtractor={(_, index) => `all_sports-${activeKey}-${index}`} + getItemType={() => 'market'} + ListHeaderComponent={listHeader} + ListFooterComponent={listFooter} + keyboardShouldPersistTaps="handled" + showsVerticalScrollIndicator={false} + contentContainerStyle={tw.style('px-4')} + testID={`all-sports-list-${activeKey}`} + refreshControl={ + + } + /> + ); +}; + +export default SportsTab; diff --git a/app/components/Views/TrendingView/utils/exploreSearch.test.tsx b/app/components/Views/TrendingView/utils/exploreSearch.test.tsx deleted file mode 100644 index b0bb638fc4a..00000000000 --- a/app/components/Views/TrendingView/utils/exploreSearch.test.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; -import { View, Text } from 'react-native'; -import { TapView, trackExploreEvent } from './exploreSearch'; -import { analytics } from '../../../../util/analytics/analytics'; -import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; - -const mockBuild = jest.fn().mockReturnValue({}); -const mockAddProperties = jest.fn().mockReturnThis(); -const mockBuilderInstance = { - addProperties: mockAddProperties, - addSensitiveProperties: jest.fn().mockReturnThis(), - removeProperties: jest.fn().mockReturnThis(), - removeSensitiveProperties: jest.fn().mockReturnThis(), - setSaveDataRecording: jest.fn().mockReturnThis(), - build: mockBuild, -}; - -jest.mock('../../../../util/analytics/analytics', () => { - const { createAnalyticsMockModule } = jest.requireActual( - '../../../../util/test/analyticsMock', - ); - return createAnalyticsMockModule(); -}); - -jest.mock('../../../../util/analytics/AnalyticsEventBuilder', () => ({ - AnalyticsEventBuilder: { - createEventBuilder: jest.fn(), - }, -})); - -const mockAnalyticsTrackEvent = analytics.trackEvent as jest.MockedFunction< - typeof analytics.trackEvent ->; -const mockCreateEventBuilderFn = - AnalyticsEventBuilder.createEventBuilder as jest.MockedFunction< - typeof AnalyticsEventBuilder.createEventBuilder - >; - -describe('TapView', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders children', () => { - const { getByText } = render( - - Child content - , - ); - - expect(getByText('Child content')).toBeOnTheScreen(); - }); - - it('calls onTap when touch ends without scroll movement', () => { - const onTap = jest.fn(); - const { getByTestId } = render( - - - , - ); - - const content = getByTestId('content'); - fireEvent(content, 'touchStart', { nativeEvent: { pageY: 100 } }); - fireEvent(content, 'touchEnd', {}); - - expect(onTap).toHaveBeenCalledTimes(1); - }); - - it('does not call onTap when vertical movement exceeds scroll threshold', () => { - const onTap = jest.fn(); - const { getByTestId } = render( - - - , - ); - - const content = getByTestId('content'); - fireEvent(content, 'touchStart', { nativeEvent: { pageY: 100 } }); - fireEvent(content, 'touchMove', { nativeEvent: { pageY: 120 } }); - fireEvent(content, 'touchEnd', {}); - - expect(onTap).not.toHaveBeenCalled(); - }); - - it('does not call onTap for upward scroll exceeding threshold', () => { - const onTap = jest.fn(); - const { getByTestId } = render( - - - , - ); - - const content = getByTestId('content'); - fireEvent(content, 'touchStart', { nativeEvent: { pageY: 100 } }); - fireEvent(content, 'touchMove', { nativeEvent: { pageY: 80 } }); - fireEvent(content, 'touchEnd', {}); - - expect(onTap).not.toHaveBeenCalled(); - }); - - it('calls onTap when vertical movement is within threshold (micro-jitter)', () => { - const onTap = jest.fn(); - const { getByTestId } = render( - - - , - ); - - const content = getByTestId('content'); - fireEvent(content, 'touchStart', { nativeEvent: { pageY: 100 } }); - fireEvent(content, 'touchMove', { nativeEvent: { pageY: 105 } }); - fireEvent(content, 'touchEnd', {}); - - expect(onTap).toHaveBeenCalledTimes(1); - }); - - it('resets scroll detection on each new touch start', () => { - const onTap = jest.fn(); - const { getByTestId } = render( - - - , - ); - - const content = getByTestId('content'); - - // First interaction: scroll (no tap) - fireEvent(content, 'touchStart', { nativeEvent: { pageY: 100 } }); - fireEvent(content, 'touchMove', { nativeEvent: { pageY: 120 } }); - fireEvent(content, 'touchEnd', {}); - expect(onTap).not.toHaveBeenCalled(); - - // Second interaction: tap (should fire) - fireEvent(content, 'touchStart', { nativeEvent: { pageY: 200 } }); - fireEvent(content, 'touchEnd', {}); - expect(onTap).toHaveBeenCalledTimes(1); - }); - - it('does not throw when onTap is not provided', () => { - const { getByTestId } = render( - - - , - ); - - const content = getByTestId('content'); - expect(() => { - fireEvent(content, 'touchStart', { nativeEvent: { pageY: 100 } }); - fireEvent(content, 'touchEnd', {}); - }).not.toThrow(); - }); -}); - -describe('trackExploreEvent', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockCreateEventBuilderFn.mockReturnValue(mockBuilderInstance); - }); - - it('calls analytics.trackEvent with a built event', () => { - const mockEvent = { category: 'Explore', action: 'Click' }; - const properties = { search_query: 'bitcoin', section_name: 'Tokens' }; - - trackExploreEvent(mockEvent as never, properties); - - expect(mockCreateEventBuilderFn).toHaveBeenCalledWith(mockEvent); - expect(mockAddProperties).toHaveBeenCalledWith(properties); - expect(mockBuild).toHaveBeenCalled(); - expect(mockAnalyticsTrackEvent).toHaveBeenCalledWith( - mockBuild.mock.results[0].value, - ); - }); - - it('passes all properties to the event builder', () => { - const mockEvent = { category: 'Explore', action: 'Scroll' }; - const properties = { - search_query: 'eth', - section_name: 'Perps', - item_clicked: 'ETH-USD', - }; - - trackExploreEvent(mockEvent as never, properties); - - expect(mockAddProperties).toHaveBeenCalledWith(properties); - }); -}); diff --git a/app/components/Views/TrendingView/utils/exploreSearch.tsx b/app/components/Views/TrendingView/utils/exploreSearch.tsx deleted file mode 100644 index 0cae65b6594..00000000000 --- a/app/components/Views/TrendingView/utils/exploreSearch.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React, { useCallback, useRef } from 'react'; -import { Box } from '@metamask/design-system-react-native'; -import { - useNavigation, - type NavigationProp, - type ParamListBase, -} from '@react-navigation/native'; -import { analytics } from '../../../../util/analytics/analytics'; -import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; -import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; -import type { SectionConfig } from '../sections.config'; - -/** - * Minimum vertical movement (in pixels) to consider a touch gesture a scroll - * rather than a tap. Absorbs micro-jitter from real taps while staying far - * below any intentional scroll distance. - */ -const SCROLL_THRESHOLD = 8; - -/** - * Wraps children and fires `onTap` only when the touch ends without a scroll - * gesture. Uses raw touch events (onTouchStart/Move/End) which fire - * independently of the scroll responder system, so movement is reliably - * detected even while a parent FlashList is absorbing a scroll. - */ -export const TapView: React.FC<{ - onTap?: () => void; - children: React.ReactNode; -}> = ({ onTap, children }) => { - const startY = useRef(0); - const didScroll = useRef(false); - return ( - { - startY.current = e.nativeEvent.pageY; - didScroll.current = false; - }} - onTouchMove={(e) => { - if (Math.abs(e.nativeEvent.pageY - startY.current) > SCROLL_THRESHOLD) { - didScroll.current = true; - } - }} - onTouchEnd={() => { - if (!didScroll.current) onTap?.(); - }} - > - {children} - - ); -}; - -/** - * Thin wrapper around the analytics event builder pattern. - * Reduces the 5-line boilerplate at every call site to a single line. - */ -export const trackExploreEvent = ( - event: Parameters[0], - properties: Record, -): void => { - analytics.trackEvent( - AnalyticsEventBuilder.createEventBuilder(event) - .addProperties(properties) - .build(), - ); -}; - -/** - * Returns a stable `onScrollBeginDrag` handler that fires a one-shot - * analytics event the first time the user begins scrolling. - * Uses a ref for `searchQuery` so the callback identity never changes. - */ -export const useScrollTracking = ( - interactionType: string, - searchQuery: string, - extraProperties?: Record, -) => { - const hasTracked = useRef(false); - const searchQueryRef = useRef(searchQuery); - searchQueryRef.current = searchQuery; - - const extraPropsRef = useRef(extraProperties); - extraPropsRef.current = extraProperties; - - const onScrollBeginDrag = useCallback(() => { - if (hasTracked.current) return; - hasTracked.current = true; - trackExploreEvent(MetaMetricsEvents.EXPLORE_SEARCH_INTERACTED, { - interaction_type: interactionType, - search_query: searchQueryRef.current, - ...extraPropsRef.current, - }); - }, [interactionType]); - - const resetScrollTracking = useCallback(() => { - hasTracked.current = false; - }, []); - - return { onScrollBeginDrag, resetScrollTracking }; -}; - -interface TrackedRowItemProps { - section: SectionConfig; - item: unknown; - index: number; - searchQuery: string; - interactionType: string; -} - -/** - * Renders a section RowItem (or its search override) wrapped in a TapView - * that fires an analytics event on tap with the item identifier. - */ -export const TrackedRowItem: React.FC = ({ - section, - item, - index, - searchQuery, - interactionType, -}) => { - const navigation = useNavigation>(); - const RowComponent = section.OverrideRowItemSearch ?? section.RowItem; - - const searchQueryRef = useRef(searchQuery); - searchQueryRef.current = searchQuery; - - const handleItemTouch = useCallback(() => { - trackExploreEvent(MetaMetricsEvents.EXPLORE_SEARCH_INTERACTED, { - interaction_type: interactionType, - search_query: searchQueryRef.current, - section_name: section.title, - item_clicked: section.getItemIdentifier(item), - }); - }, [interactionType, section, item]); - - return ( - - - - ); -}; diff --git a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx index 1013058594a..f011703e9a0 100644 --- a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx +++ b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.test.tsx @@ -1,16 +1,48 @@ import React, { ComponentType } from 'react'; import { RefreshControl } from 'react-native'; -import { TransactionStatus } from '@metamask/transaction-controller'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import { + TransactionStatus, + type TransactionMeta, +} from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; import UnifiedTransactionsView from './UnifiedTransactionsView'; -import renderWithProvider from '../../../util/test/renderWithProvider'; +import _renderWithProvider from '../../../util/test/renderWithProvider'; import { backgroundState } from '../../../util/test/initial-root-state'; import { updateIncomingTransactions } from '../../../util/transaction-controller'; import { useUnifiedTxActions } from './useUnifiedTxActions'; +import { useTransactionsQuery } from './useTransactionsQuery'; +import { selectTransactions } from './helpers/transformations'; // Type helper for UNSAFE_queryByType with mocked string components const asComponentType = (name: string) => name as unknown as ComponentType; +type TransactionsQueryData = ReturnType>; + +const emptyTransactionsQueryData: TransactionsQueryData = { + pageParams: [], + pages: [], +}; + +const createUseTransactionsQueryResult = ( + data: TransactionsQueryData = emptyTransactionsQueryData, +) => ({ + data, + fetchNextPage: jest.fn(), + hasNextPage: false, + isFetchingNextPage: false, + refetch: jest.fn().mockResolvedValue(undefined), +}); + +const mockUseTransactionsQuery = ( + data: TransactionsQueryData = emptyTransactionsQueryData, +) => { + (useTransactionsQuery as jest.Mock).mockReturnValue( + createUseTransactionsQueryResult(data), + ); +}; + const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ @@ -77,6 +109,10 @@ jest.mock('./useUnifiedTxActions', () => ({ useUnifiedTxActions: jest.fn(() => mockDefaultUnifiedTxActionsReturn), })); +jest.mock('./useTransactionsQuery', () => ({ + useTransactionsQuery: jest.fn(() => createUseTransactionsQueryResult()), +})); + jest.mock('./useTransactionAutoScroll', () => ({ useTransactionAutoScroll: () => ({ handleScroll: jest.fn(), @@ -185,6 +221,33 @@ jest.mock( }), ); +const renderWithProvider = ( + component: React.ReactElement, + providerValues?: Parameters[1], + includeNavigationContainer?: Parameters[2], + includeFeatureFlagOverrideProvider?: Parameters< + typeof _renderWithProvider + >[3], +) => + _renderWithProvider( + + {component} + , + providerValues, + includeNavigationContainer, + includeFeatureFlagOverrideProvider, + ); + describe('UnifiedTransactionsView', () => { const initialState = { engine: { @@ -194,6 +257,7 @@ describe('UnifiedTransactionsView', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseTransactionsQuery(); (useUnifiedTxActions as jest.Mock).mockImplementation( () => mockDefaultUnifiedTxActionsReturn, ); @@ -319,6 +383,13 @@ describe('UnifiedTransactionsView', () => { }); describe('UnifiedTransactionsView with transactions', () => { + const ACTIVE_EVM_ADDRESS = '0x0000000000000000000000000000000000000abc'; + const BRIDGE_TX_HASH = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + const OTHER_TX_HASH = + '0x1111111111111111111111111111111111111111111111111111111111111111'; + const BRIDGE_TX_ID = 'bridge-tx-id'; + const stateWithTransactions = { engine: { backgroundState: { @@ -344,8 +415,154 @@ describe('UnifiedTransactionsView with transactions', () => { }, }; + const stateWithConfirmedBridgeTransaction = { + engine: { + backgroundState: { + ...backgroundState, + AccountsController: { + ...backgroundState.AccountsController, + internalAccounts: { + accounts: { + 'evm-account-id': { + id: 'evm-account-id', + type: 'eip155:eoa' as const, + address: ACTIVE_EVM_ADDRESS, + options: {}, + methods: [], + metadata: { + name: 'Account 1', + keyring: { type: 'HD Key Tree' }, + }, + }, + }, + selectedAccount: 'evm-account-id', + }, + }, + TransactionController: { + ...backgroundState.TransactionController, + transactions: [ + { + id: BRIDGE_TX_ID, + chainId: '0x1' as const, + hash: BRIDGE_TX_HASH, + status: TransactionStatus.confirmed, + time: Date.now(), + txParams: { + from: ACTIVE_EVM_ADDRESS, + to: '0x1111111111111111111111111111111111111111', + value: '0x0', + nonce: '0x1', + }, + }, + ], + }, + }, + }, + }; + + const createConfirmedEvmQueryData = ( + transactions: V1TransactionByHashResponse[] = [], + ) => + selectTransactions({ + address: ACTIVE_EVM_ADDRESS, + })({ + pageParams: [undefined], + pages: [ + { + data: transactions, + unprocessedNetworks: [], + pageInfo: { + count: transactions.length, + endCursor: undefined, + hasNextPage: false, + }, + }, + ], + }); + + const createConfirmedBridgeTransaction = (hash = BRIDGE_TX_HASH) => + createConfirmedEvmQueryData([ + { + accountId: `eip155:1:${ACTIVE_EVM_ADDRESS}`, + blockHash: '0xblock', + blockNumber: 1, + chainId: 1, + cumulativeGasUsed: 21000, + effectiveGasPrice: '1', + from: ACTIVE_EVM_ADDRESS, + gas: 21000, + gasPrice: '1', + gasUsed: 21000, + hash, + isError: false, + logs: [], + methodId: '0x', + nonce: 1, + readable: 'Transfer', + timestamp: '2026-04-29T19:28:41.000Z', + to: '0x1111111111111111111111111111111111111111', + transactionCategory: 'TRANSFER', + transactionType: 'SIMPLE_SEND', + value: '1', + valueTransfers: [], + } as V1TransactionByHashResponse, + ]); + + const bridgeHistory = { + [BRIDGE_TX_ID]: { + txMetaId: BRIDGE_TX_ID, + account: ACTIVE_EVM_ADDRESS, + quote: { + srcChainId: 1, + destChainId: 8453, + srcAsset: { + symbol: 'ETH', + chainId: 1, + decimals: 18, + address: 'native', + }, + destAsset: { + symbol: 'ETH', + chainId: 8453, + decimals: 18, + address: 'native', + }, + }, + status: { + srcChain: { + txHash: BRIDGE_TX_HASH, + chainId: 1, + amount: '1', + }, + destChain: { + txHash: + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + chainId: 8453, + amount: '1', + }, + status: 'COMPLETE', + }, + estimatedProcessingTimeInSeconds: 60, + slippagePercentage: 0, + completionTime: Date.now(), + startTime: Date.now() - 60000, + }, + }; + + const getRenderedTransactionIds = ( + queryAllByType: ReturnType< + typeof renderWithProvider + >['UNSAFE_queryAllByType'], + ) => + queryAllByType(asComponentType('TransactionElement')).map( + ({ props }) => props.tx.id, + ); + + const apiBridgeTransactionId = `${BRIDGE_TX_HASH}-1`; + beforeEach(() => { jest.clearAllMocks(); + mockUseTransactionsQuery(); (useUnifiedTxActions as jest.Mock).mockImplementation( () => mockDefaultUnifiedTxActionsReturn, ); @@ -366,6 +583,311 @@ describe('UnifiedTransactionsView with transactions', () => { ); expect(transactionElements.length).toBeGreaterThanOrEqual(0); }); + + it('uses the Accounts API bridge transaction when the source hash matches bridge history', () => { + mockUseTransactionsQuery(createConfirmedBridgeTransaction()); + mockSelectBridgeHistoryForAccount.mockReturnValue(bridgeHistory); + + const { UNSAFE_queryAllByType } = renderWithProvider( + , + { + state: stateWithConfirmedBridgeTransaction, + }, + ); + + const transactionIds = getRenderedTransactionIds(UNSAFE_queryAllByType); + + expect(transactionIds).toContain(apiBridgeTransactionId); + expect(transactionIds).not.toContain(BRIDGE_TX_ID); + }); + + it('keeps the local bridge transaction when Accounts API only has a nonce collision', () => { + mockUseTransactionsQuery(createConfirmedBridgeTransaction(OTHER_TX_HASH)); + mockSelectBridgeHistoryForAccount.mockReturnValue(bridgeHistory); + + const { UNSAFE_queryAllByType } = renderWithProvider( + , + { + state: stateWithConfirmedBridgeTransaction, + }, + ); + + const transactionIds = getRenderedTransactionIds(UNSAFE_queryAllByType); + + expect(transactionIds).toContain(BRIDGE_TX_ID); + }); +}); + +describe('UnifiedTransactionsView - EVM network filter for unified list', () => { + const WALLET = '0xabc'; + + /** Required so selectLocalTransactions includes pending txs for UnifiedTransactionsView filtering. */ + const accountsControllerForWallet = { + ...backgroundState.AccountsController, + internalAccounts: { + accounts: { + 'wallet-evm-id': { + id: 'wallet-evm-id', + type: 'eip155:eoa' as const, + address: WALLET, + options: {}, + methods: [], + metadata: { + name: 'Account 1', + keyring: { type: 'HD Key Tree' }, + }, + }, + }, + selectedAccount: 'wallet-evm-id', + }, + }; + + const emptyEvmChainsState = { + engine: { + backgroundState: { + ...backgroundState, + AccountsController: accountsControllerForWallet, + NetworkEnablementController: { + ...backgroundState.NetworkEnablementController, + enabledNetworkMap: { + ...backgroundState.NetworkEnablementController.enabledNetworkMap, + eip155: {}, + }, + }, + }, + }, + }; + + const stateOnlyOptimism = { + engine: { + backgroundState: { + ...backgroundState, + AccountsController: accountsControllerForWallet, + NetworkEnablementController: { + ...backgroundState.NetworkEnablementController, + enabledNetworkMap: { + ...backgroundState.NetworkEnablementController.enabledNetworkMap, + eip155: { '0xa': true }, + }, + }, + }, + }, + }; + + const stateOnlyMainnet = { + engine: { + backgroundState: { + ...backgroundState, + AccountsController: accountsControllerForWallet, + NetworkEnablementController: { + ...backgroundState.NetworkEnablementController, + enabledNetworkMap: { + ...backgroundState.NetworkEnablementController.enabledNetworkMap, + eip155: { '0x1': true }, + }, + }, + }, + }, + }; + + const createOutgoingMainnetConfirmed = (): V1TransactionByHashResponse => + ({ + accountId: `eip155:1:${WALLET}`, + blockHash: '0xblock', + blockNumber: 1, + chainId: 1, + cumulativeGasUsed: 21000, + effectiveGasPrice: '1', + from: WALLET, + gas: 21000, + gasPrice: '1', + gasUsed: 21000, + hash: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + isError: false, + logs: [], + methodId: '0x', + nonce: 1, + readable: 'Transfer', + timestamp: '2026-04-29T19:28:41.000Z', + to: '0x1111111111111111111111111111111111111111', + transactionCategory: 'TRANSFER', + transactionType: 'SIMPLE_SEND', + value: '1', + valueTransfers: [], + }) as V1TransactionByHashResponse; + + const queryDataMainnetConfirmed = selectTransactions({ + address: WALLET, + })({ + pageParams: [undefined], + pages: [ + { + data: [createOutgoingMainnetConfirmed()], + unprocessedNetworks: [], + pageInfo: { + count: 1, + endCursor: undefined, + hasNextPage: false, + }, + }, + ], + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseTransactionsQuery(); + (useUnifiedTxActions as jest.Mock).mockImplementation( + () => mockDefaultUnifiedTxActionsReturn, + ); + }); + + it('hides confirmed EVM rows when no EVM chains are enabled', () => { + mockUseTransactionsQuery(queryDataMainnetConfirmed); + + const { getByText } = renderWithProvider(, { + state: emptyEvmChainsState, + }); + + expect(getByText('You have no transactions')).toBeOnTheScreen(); + }); + + it('hides confirmed EVM rows when their chain is not in the enabled filter', () => { + mockUseTransactionsQuery(queryDataMainnetConfirmed); + + const { getByText } = renderWithProvider(, { + state: stateOnlyOptimism, + }); + + expect(getByText('You have no transactions')).toBeOnTheScreen(); + }); + + it('shows confirmed EVM rows when their chain matches the enabled filter', () => { + mockUseTransactionsQuery(queryDataMainnetConfirmed); + + const { UNSAFE_queryAllByType } = renderWithProvider( + , + { state: stateOnlyMainnet }, + ); + + expect( + UNSAFE_queryAllByType(asComponentType('TransactionElement')).length, + ).toBeGreaterThanOrEqual(1); + }); + + it('hides pending EVM transactions when their chain is not in the enabled filter', () => { + mockUseTransactionsQuery(emptyTransactionsQueryData); + + const stateWithPendingMainnet = { + engine: { + backgroundState: { + ...backgroundState, + AccountsController: accountsControllerForWallet, + NetworkEnablementController: + stateOnlyOptimism.engine.backgroundState + .NetworkEnablementController, + TransactionController: { + ...backgroundState.TransactionController, + transactions: [ + { + id: 'pending-mainnet', + chainId: '0x1' as const, + status: TransactionStatus.submitted, + time: Date.now(), + txParams: { + from: WALLET, + to: '0x1111111111111111111111111111111111111111', + value: '0x0', + nonce: '0x1', + }, + }, + ], + }, + }, + }, + }; + + const { getByText } = renderWithProvider(, { + state: stateWithPendingMainnet, + }); + + expect(getByText('You have no transactions')).toBeOnTheScreen(); + }); + + it('hides pending EVM transactions when no EVM chains are enabled', () => { + mockUseTransactionsQuery(emptyTransactionsQueryData); + + const stateWithPendingButNoEvmNetworks = { + engine: { + backgroundState: { + ...backgroundState, + AccountsController: accountsControllerForWallet, + NetworkEnablementController: + emptyEvmChainsState.engine.backgroundState + .NetworkEnablementController, + TransactionController: { + ...backgroundState.TransactionController, + transactions: [ + { + id: 'pending-no-enabled-evm', + chainId: '0x1' as const, + status: TransactionStatus.submitted, + time: Date.now(), + txParams: { + from: WALLET, + to: '0x1111111111111111111111111111111111111111', + value: '0x0', + nonce: '0x1', + }, + }, + ], + }, + }, + }, + }; + + const { getByText } = renderWithProvider(, { + state: stateWithPendingButNoEvmNetworks, + }); + + expect(getByText('You have no transactions')).toBeOnTheScreen(); + }); + + it('hides pending EVM transactions when chainId is missing', () => { + mockUseTransactionsQuery(emptyTransactionsQueryData); + + const pendingWithoutChainId = { + id: 'pending-missing-chain', + status: TransactionStatus.submitted, + time: Date.now(), + txParams: { + from: WALLET, + to: '0x1111111111111111111111111111111111111111', + value: '0x0', + nonce: '0x1', + }, + } as unknown as TransactionMeta; + + const stateWithPendingMissingChainId = { + engine: { + backgroundState: { + ...backgroundState, + AccountsController: accountsControllerForWallet, + NetworkEnablementController: + stateOnlyMainnet.engine.backgroundState.NetworkEnablementController, + TransactionController: { + ...backgroundState.TransactionController, + transactions: [pendingWithoutChainId], + }, + }, + }, + }; + + const { getByText } = renderWithProvider(, { + state: stateWithPendingMissingChainId, + }); + + expect(getByText('You have no transactions')).toBeOnTheScreen(); + }); }); describe('UnifiedTransactionsView - Speed up / Cancel modal', () => { @@ -377,6 +899,7 @@ describe('UnifiedTransactionsView - Speed up / Cancel modal', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseTransactionsQuery(); (useUnifiedTxActions as jest.Mock).mockImplementation( () => mockDefaultUnifiedTxActionsReturn, ); @@ -433,6 +956,7 @@ describe('UnifiedTransactionsView - refresh', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseTransactionsQuery(); (useUnifiedTxActions as jest.Mock).mockImplementation( () => mockDefaultUnifiedTxActionsReturn, ); @@ -454,135 +978,125 @@ describe('UnifiedTransactionsView - refresh', () => { }); describe('UnifiedTransactionsView - token poisoning protection', () => { - const { - buildTrustedAddressSet: mockBuildTrustedAddressSet, - filterByAddress: mockFilterByAddress, - isTransactionOnChains: mockIsTransactionOnChains, - } = jest.requireMock('../../../util/activity'); - const FRIEND_ADDRESS = '0x1234000000000000000000000000000000000001'; + const ACTIVE_EVM_ADDRESS = '0xabc'; const baseState = { engine: { backgroundState } }; + const createConfirmedEvmTransaction = ( + overrides: Partial = {}, + ) => + ({ + accountId: `eip155:1:${ACTIVE_EVM_ADDRESS}`, + blockHash: '0xblock', + blockNumber: 1, + chainId: 1, + cumulativeGasUsed: 21000, + effectiveGasPrice: '1', + from: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + gas: 21000, + gasPrice: '1', + gasUsed: 21000, + hash: '0xhash', + isError: false, + logs: [], + methodId: '0x', + nonce: 1, + readable: 'Transfer', + timestamp: '2026-04-29T19:28:41.000Z', + to: ACTIVE_EVM_ADDRESS, + transactionCategory: 'TRANSFER', + transactionType: 'SIMPLE_SEND', + value: '1', + valueTransfers: [], + ...overrides, + }) as V1TransactionByHashResponse; + + const createConfirmedEvmQueryData = ( + transactions: V1TransactionByHashResponse[] = [], + ) => + selectTransactions({ + address: ACTIVE_EVM_ADDRESS, + })({ + pageParams: [undefined], + pages: [ + { + data: transactions, + unprocessedNetworks: [], + pageInfo: { + count: transactions.length, + endCursor: undefined, + hasNextPage: false, + }, + }, + ], + }); + // State with a single incoming ERC-20 transfer from an unknown sender - const stateWithIncomingTransfer = { - engine: { - backgroundState: { - ...backgroundState, - TransactionController: { - ...backgroundState.TransactionController, - transactions: [ - { - id: 'tx-erc20', - chainId: '0x1' as const, - status: TransactionStatus.confirmed, - time: Date.now(), - isTransfer: true, - transferInformation: { - contractAddress: '0x3333333333333333333333333333333333333333', - decimals: 18, - symbol: 'TKN', - }, - txParams: { - from: '0x9999999999999999999999999999999999999999', - to: '0xabc', - value: '0x0', - nonce: '0x1', - }, - }, - ], + const stateWithIncomingTransfer = createConfirmedEvmQueryData([ + createConfirmedEvmTransaction({ + hash: '0xpoison-erc20', + transactionType: 'TOKEN_TRANSFER', + valueTransfers: [ + { + amount: '1', + contractAddress: '0x3333333333333333333333333333333333333333', + decimal: 18, + from: '0x9999999999999999999999999999999999999999', + name: 'Test Token', + symbol: 'TKN', + to: ACTIVE_EVM_ADDRESS, + transferType: 'ERC20', }, - }, - }, - }; + ], + }), + ]); + + const stateWithIncomingNativeTransfer = createConfirmedEvmQueryData([ + createConfirmedEvmTransaction({ + hash: '0xpoison-native', + valueTransfers: [ + { + amount: '1', + contractAddress: '', + decimal: 18, + from: '0x9999999999999999999999999999999999999999', + name: 'Ether', + symbol: 'ETH', + to: ACTIVE_EVM_ADDRESS, + transferType: 'NATIVE', + }, + ], + }), + ]); beforeEach(() => { jest.clearAllMocks(); + mockUseTransactionsQuery(); (useUnifiedTxActions as jest.Mock).mockImplementation( () => mockDefaultUnifiedTxActionsReturn, ); - // Re-set implementations after any prior resetAllMocks() calls - (mockBuildTrustedAddressSet as jest.Mock).mockReturnValue( - new Set(), - ); - (mockFilterByAddress as jest.Mock).mockReturnValue(true); - // isTransactionOnChains gates the second chain filter at line 252 of the - // component; restore it so confirmed transactions aren't silently dropped - (mockIsTransactionOnChains as jest.Mock).mockReturnValue(true); - }); - - it('calls buildTrustedAddressSet on every render', () => { - renderWithProvider(, { state: baseState }); - - expect(mockBuildTrustedAddressSet).toHaveBeenCalled(); - }); - - it('calls buildTrustedAddressSet with the addressBook from state and an array of account addresses', () => { - const mockAddressBook = { - '0x1': { - [FRIEND_ADDRESS]: { - address: FRIEND_ADDRESS, - name: 'Friend', - chainId: '0x1' as Hex, - memo: '', - isEns: false, - }, - }, - }; - const stateWithAddressBook = { - engine: { - backgroundState: { - ...backgroundState, - AddressBookController: { addressBook: mockAddressBook }, - }, - }, - }; - - renderWithProvider(, { - state: stateWithAddressBook, - }); - - expect(mockBuildTrustedAddressSet).toHaveBeenCalledWith( - mockAddressBook, - expect.any(Array), - ); - }); - - it('passes a pre-built Set to filterByAddress (not the raw addressBook)', () => { - renderWithProvider(, { - state: stateWithIncomingTransfer, - }); - - expect(mockFilterByAddress).toHaveBeenCalled(); - (mockFilterByAddress as jest.Mock).mock.calls.forEach((args) => { - // arg[5] is trustedAddresses — must be a Set, not a plain object - expect(args[5]).toBeInstanceOf(Set); - // There is no arg[6]; the old addressBook + internalAccountAddresses - // params have been replaced by a single Set - expect(args[6]).toBeUndefined(); - }); }); it('hides incoming ERC-20 transfer when filterByAddress returns false (unknown sender)', () => { - (mockFilterByAddress as jest.Mock).mockReturnValue(false); + mockUseTransactionsQuery(stateWithIncomingTransfer); const { getByText } = renderWithProvider(, { - state: stateWithIncomingTransfer, + state: baseState, }); // Transaction is filtered out → data is empty → empty state is shown expect(getByText('You have no transactions')).toBeOnTheScreen(); }); - it('shows incoming ERC-20 transfer when filterByAddress returns true (trusted sender)', () => { - (mockFilterByAddress as jest.Mock).mockReturnValue(true); + it('hides incoming native transfer when sender is unknown', () => { + mockUseTransactionsQuery(stateWithIncomingNativeTransfer); - const { queryByText } = renderWithProvider(, { - state: stateWithIncomingTransfer, + const { getByText } = renderWithProvider(, { + state: baseState, }); - // Transaction passes filter → data is non-empty → empty state is absent - expect(queryByText('You have no transactions')).not.toBeOnTheScreen(); + expect(getByText('You have no transactions')).toBeOnTheScreen(); }); }); @@ -644,6 +1158,7 @@ describe('UnifiedTransactionsView - cross-chain bridge visibility', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseTransactionsQuery(); (useUnifiedTxActions as jest.Mock).mockImplementation( () => mockDefaultUnifiedTxActionsReturn, ); diff --git a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx index 491048c8e52..c666fcd88e2 100644 --- a/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx +++ b/app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx @@ -4,18 +4,19 @@ import { SmartTransaction } from '@metamask/smart-transactions-controller'; import { TransactionMeta } from '@metamask/transaction-controller'; import { numberToHex } from '@metamask/utils'; import { useNavigation } from '@react-navigation/native'; -import { FlashList, FlashListRef } from '@shopify/flash-list'; +import { + FlashList, + type FlashListRef, + type ViewToken, +} from '@shopify/flash-list'; import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { RefreshControl, View } from 'react-native'; +import { ActivityIndicator, RefreshControl, View } from 'react-native'; import { useSelector } from 'react-redux'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { strings } from '../../../../locales/i18n'; import ExtendedKeyringTypes from '../../../constants/keyringTypes'; import { RPC } from '../../../constants/network'; -import { - selectSelectedInternalAccount, - selectInternalAccounts, -} from '../../../selectors/accountsController'; -import { selectAddressBook } from '../../../selectors/addressBookController'; +import { selectSelectedInternalAccount } from '../../../selectors/accountsController'; import { selectCurrentCurrency } from '../../../selectors/currencyRateController'; import { selectNonEvmTransactionsForSelectedAccountGroup } from '../../../selectors/multichain/multichain'; import { selectSelectedAccountGroupInternalAccounts } from '../../../selectors/multichainAccounts/accountTreeController'; @@ -27,20 +28,15 @@ import { selectEVMEnabledNetworks, selectNonEVMEnabledNetworks, } from '../../../selectors/networkEnablementController'; -import { selectTokens } from '../../../selectors/tokensController'; -import { selectSortedEVMTransactionsForSelectedAccountGroup } from '../../../selectors/transactionController'; -import { baseStyles } from '../../../styles/common'; import { - filterByAddress, - isTransactionOnChains, - sortTransactions, - buildTrustedAddressSet, -} from '../../../util/activity'; + selectLocalTransactions, + selectRelatedChainIdsByTransactionId, +} from '../../../selectors/transactionController'; +import { baseStyles } from '../../../styles/common'; import { areAddressesEqual, isHardwareAccount } from '../../../util/address'; import { getBlockExplorerAddressUrl } from '../../../util/networks'; import { useTheme } from '../../../util/theme'; import { updateIncomingTransactions } from '../../../util/transaction-controller'; -import { addAccountTimeFlagFilter } from '../../../util/transactions'; import { useStyles } from '../../hooks/useStyles'; import PriceChartContext, { PriceChartProvider, @@ -49,7 +45,6 @@ import { useBridgeHistoryItemBySrcTxHash } from '../../UI/Bridge/hooks/useBridge import MultichainBridgeTransactionListItem from '../../UI/MultichainBridgeTransactionListItem'; import MultichainTransactionListItem from '../../UI/MultichainTransactionListItem'; import TransactionElement from '../../UI/TransactionElement'; -import { filterDuplicateOutgoingTransactions } from '../../UI/Transactions/utils'; import TransactionsFooter from '../../UI/Transactions/TransactionsFooter'; import MultichainTransactionsFooter from '../MultichainTransactionsView/MultichainTransactionsFooter'; import { getAddressUrl } from '../../../core/Multichain/utils'; @@ -64,30 +59,41 @@ import { TabEmptyState } from '../../../component-library/components-temp/TabEmp import { UnifiedTransactionsViewSelectorsIDs } from './UnifiedTransactionsView.testIds'; import { useMultichainActivityMaliciousTokenKeys } from '../../hooks/useMultichainActivityMaliciousTokenKeys/useMultichainActivityMaliciousTokenKeys'; import { filterMultichainTransactionsExcludingMaliciousTokenActivity } from '../../../util/multichain/multichainTransactionTokenScan'; +import { useTransactionsQuery } from './useTransactionsQuery'; +import { + type EvmTransaction, + TransactionKind, + type TransactionViewModel, + type UnifiedItem, +} from './types'; +import { + isBridgeHistoryForEvmTransaction, + mergeTransactionsByTime, +} from './helpers/transformations'; -type SmartTransactionWithId = SmartTransaction & { id: string }; -type EvmTransaction = TransactionMeta | SmartTransactionWithId; -type TransactionMetaWithImport = TransactionMeta & { - insertImportTime?: boolean; -}; +const confirmedEvmOverscan = 5; +const visibilityConfig = { itemVisiblePercentThreshold: 1 }; const getTransactionId = (tx: EvmTransaction) => tx.id; - -const isTransactionMetaLike = (tx: EvmTransaction): tx is TransactionMeta => - 'chainId' in tx && typeof tx.chainId === 'string'; +const isTransactionMetaLike = ( + tx: TransactionMeta | SmartTransaction, +): tx is EvmTransaction => 'id' in tx && typeof tx.id === 'string'; const getEvmTransactionTime = (tx: EvmTransaction) => tx.time ?? 0; const getEvmChainId = (tx: EvmTransaction) => tx.chainId; -enum TransactionKind { - Evm = 'evm', - NonEvm = 'nonEvm', -} +const generateKey = (item: UnifiedItem) => { + if (item.kind === TransactionKind.Evm) { + return getTransactionId(item.tx); + } + + if (item.kind === TransactionKind.ConfirmedEvm) { + return getTransactionId(item.tx.transactionMeta); + } -type UnifiedItem = - | { kind: TransactionKind.Evm; tx: TransactionMeta | SmartTransactionWithId } - | { kind: TransactionKind.NonEvm; tx: NonEvmTransaction }; + return String(item.tx.id ?? `${item.tx.chain}-${item.tx.timestamp ?? '0'}`); +}; interface UnifiedTransactionsViewProps { header?: React.ReactElement; @@ -103,12 +109,26 @@ const UnifiedTransactionsView = ({ }: UnifiedTransactionsViewProps) => { const navigation = useNavigation(); const { colors } = useTheme(); + const tw = useTailwind(); const { styles } = useStyles(styleSheet, {}); const { bridgeHistoryItemsBySrcTxHash } = useBridgeHistoryItemBySrcTxHash(); - const evmTransactions = useSelector( - selectSortedEVMTransactionsForSelectedAccountGroup, + const { + data: evmTransactions, + fetchNextPage, + hasNextPage, + isInitialLoading, + isFetchingNextPage, + refetch, + } = useTransactionsQuery(); + + const allConfirmedFiltered = useMemo( + () => evmTransactions?.pages.flatMap((page) => page.data) ?? [], + [evmTransactions], ); + + const submittedTxs = useSelector(selectLocalTransactions); + const nonEvmState = useSelector( selectNonEvmTransactionsForSelectedAccountGroup, ); @@ -121,12 +141,9 @@ const UnifiedTransactionsView = ({ // Inputs required to reproduce EVM filtering pipeline const selectedInternalAccount = useSelector(selectSelectedInternalAccount); - const tokens = useSelector(selectTokens); const selectedAccountGroupInternalAccounts = useSelector( selectSelectedAccountGroupInternalAccounts, ); - const selectedAccountGroupInternalAccountsAddresses = - selectedAccountGroupInternalAccounts.map((account) => account.address); const selectedAccountGroupEvmAddress = useMemo(() => { const evmAccount = selectedAccountGroupInternalAccounts.find( (account) => @@ -153,6 +170,24 @@ const UnifiedTransactionsView = ({ [enabledNonEVMNetworks], ); + const relatedChainIdsByTransactionId = useSelector( + selectRelatedChainIdsByTransactionId, + ); + + /** Drop confirmed rows not on currently enabled EVM chains (guards stale query pages). */ + const allConfirmedForEnabledChains = useMemo(() => { + const chains = enabledEVMChainIds ?? []; + if (chains.length === 0) { + return []; + } + const allowed = new Set(chains.map((c) => c.toLowerCase())); + return allConfirmedFiltered.filter( + (tx) => + typeof tx.hexChainId === 'string' && + allowed.has(tx.hexChainId.toLowerCase()), + ); + }, [allConfirmedFiltered, enabledEVMChainIds]); + const { maliciousTokenKeys } = useMultichainActivityMaliciousTokenKeys(nonEvmTransactions); @@ -162,161 +197,77 @@ const UnifiedTransactionsView = ({ ); const bridgeHistory = useSelector(selectBridgeHistoryForAccount); - const addressBook = useSelector(selectAddressBook); - const internalAccounts = useSelector(selectInternalAccounts); - - const trustedAddresses = useMemo( - () => - buildTrustedAddressSet( - addressBook, - internalAccounts.map((account) => account.address), - ), - [addressBook, internalAccounts], - ); const unifiedTransactionSource = useMemo<{ - evmPendingItems: UnifiedItem[]; - evmConfirmedItems: UnifiedItem[]; + evmPendingTxs: EvmTransaction[]; + evmConfirmedTxs: TransactionViewModel[]; chainFilteredNonEvmTransactionsForSelectedChain: NonEvmTransaction[]; }>(() => { - // Build EVM submitted/confirmed with full filtering pipeline - let accountAddedTimeInsertPointFound = false; - const addedAccountTime = selectedInternalAccount?.metadata?.importTime; - const submittedTxs: EvmTransaction[] = []; - - const sortedTransactions = sortTransactions( - evmTransactions ?? [], - ) as EvmTransaction[]; - - const allTransactionsSorted = sortedTransactions.filter( - (tx, index, self) => { - const key = getTransactionId(tx); - return self.findIndex((_tx) => getTransactionId(_tx) === key) === index; - }, + const bridgeHistoryValues = Object.values(bridgeHistory ?? {}); + const enabledEvmSet = new Set( + (enabledEVMChainIds ?? []).map((id) => id.toLowerCase()), ); - - const transactionMetaPool = allTransactionsSorted.filter( - isTransactionMetaLike, - ) as TransactionMeta[]; - - const allConfirmed = allTransactionsSorted.filter((tx) => { - if (!isTransactionMetaLike(tx)) { - const status = tx.status; - if ( - status === 'submitted' || - status === 'signed' || - status === 'unapproved' || - status === 'approved' || - status === 'pending' - ) { - submittedTxs.push(tx as SmartTransactionWithId); + const submittedTxsFiltered = submittedTxs.filter( + (tx): tx is EvmTransaction => { + if (!isTransactionMetaLike(tx)) { + return false; } - return false; - } - - const isReceivedOrSentTransaction = - selectedAccountGroupInternalAccountsAddresses.some((addr) => - filterByAddress( - tx, - tokens, - addr, - transactionMetaPool, - bridgeHistory, - trustedAddresses, - ), - ); - if (!isReceivedOrSentTransaction) return false; - const insertImportTime = addAccountTimeFlagFilter( - tx as unknown as object, - addedAccountTime as unknown as object, - accountAddedTimeInsertPointFound as unknown as object, - ); - const updatedTx = { ...tx, insertImportTime }; - if (updatedTx.insertImportTime) accountAddedTimeInsertPointFound = true; - - // not sure if pending is a valid status for EVM transactions, but keeping - // it for now to avoid breaking changes - const status = tx.status as TransactionMeta['status'] | 'pending'; - switch (status) { - case 'submitted': - case 'signed': - case 'unapproved': - case 'approved': - case 'pending': - submittedTxs.push(updatedTx); - return false; - case 'confirmed': - break; - } - return isReceivedOrSentTransaction; - }) as TransactionMetaWithImport[]; + const { chainId: _chainId, txParams } = tx; - // Network filtering for confirmed EVM txs - const allConfirmedFiltered: TransactionMetaWithImport[] = - allConfirmed.filter((tx) => - isTransactionOnChains(tx, enabledEVMChainIds, transactionMetaPool), - ); - // Deduplicate submitted by (address + chain + nonce) and drop if already confirmed - const seenSubmittedNonces = new Set(); - const submittedTxsFiltered = submittedTxs.filter( - ({ chainId: _chainId, txParams }) => { - const { from, nonce, actionId } = txParams || {}; - // Some txs don't have nonce, like intent based swaps - const hasNonce = nonce !== undefined && nonce !== null; - if ( - !selectedAccountGroupInternalAccountsAddresses.some((addr) => - areAddressesEqual(from, addr), - ) - ) { + if (!enabledEvmSet.size) { return false; } - const dedupeKeyPrefix = `${_chainId}-${String(from).toLowerCase()}`; - const dedupeKey = hasNonce - ? `${dedupeKeyPrefix}-${nonce}` - : `${dedupeKeyPrefix}-${actionId}`; - if (seenSubmittedNonces.has(dedupeKey)) { + + const relatedChainIds = relatedChainIdsByTransactionId.get(tx.id) ?? [ + String(_chainId ?? '').toLowerCase(), + ]; + if (!relatedChainIds.some((id) => enabledEvmSet.has(id))) { return false; } - const alreadyConfirmed = allConfirmedFiltered.find( + const isBridgeTransaction = isBridgeHistoryForEvmTransaction( + tx, + bridgeHistoryValues, + ); + const hash = 'hash' in tx ? tx.hash : undefined; + const { from, nonce } = txParams || {}; + const hasNonce = nonce !== undefined && nonce !== null; + + const matchingConfirmedByHash = allConfirmedForEnabledChains.some( + (confirmedTx) => + typeof hash === 'string' && + confirmedTx.hash.toLowerCase() === hash.toLowerCase() && + confirmedTx.hexChainId?.toLowerCase() === _chainId?.toLowerCase(), + ); + const matchingConfirmedByNonce = allConfirmedForEnabledChains.some( (confirmedTx) => hasNonce && - confirmedTx.txParams?.nonce === nonce && - selectedAccountGroupInternalAccountsAddresses.some((addr) => - areAddressesEqual(confirmedTx.txParams?.from, addr), - ) && - confirmedTx.chainId === _chainId, + confirmedTx.nonce === nonce && + confirmedTx.hexChainId?.toLowerCase() === _chainId?.toLowerCase() && + Boolean(from) && + areAddressesEqual(confirmedTx.from, from), ); - if (alreadyConfirmed) { + if ( + matchingConfirmedByHash || + (!isBridgeTransaction && matchingConfirmedByNonce) + ) { return false; } - seenSubmittedNonces.add(dedupeKey); return true; }, ); - // Ensure insertImportTime appears at least once if applicable - if (!accountAddedTimeInsertPointFound && allConfirmedFiltered?.length) { - const lastIndex = allConfirmedFiltered.length - 1; - allConfirmedFiltered[lastIndex] = { - ...allConfirmedFiltered[lastIndex], - insertImportTime: true, - }; - } // EVM: pending/submitted first (desc), then confirmed (dedup outgoing) const evmPendingFirst = [...submittedTxsFiltered].sort( (a, b) => getEvmTransactionTime(b) - getEvmTransactionTime(a), ); - const evmConfirmedDeduped = - filterDuplicateOutgoingTransactions(allConfirmedFiltered); // Non-EVM: filter by enabled chains, also include bridge txs // whose destination chain is enabled (e.g. Solana→Optimism bridge // should appear when viewing Optimism activity) - const bridgeHistoryValues = Object.values(bridgeHistory ?? {}); const chainFilteredNonEvmTransactionsForSelectedChain = nonEvmTransactions .filter((tx) => { if (enabledNonEVMChainIds.includes(tx.chain)) return true; @@ -333,30 +284,19 @@ const UnifiedTransactionsView = ({ (tx, index, self) => index === self.findIndex((t) => t.id === tx.id), ); - const evmPendingItems: UnifiedItem[] = evmPendingFirst.map((tx) => ({ - kind: TransactionKind.Evm, - tx, - })); - const evmConfirmedItems: UnifiedItem[] = evmConfirmedDeduped.map((tx) => ({ - kind: TransactionKind.Evm, - tx, - })); - return { - evmPendingItems, - evmConfirmedItems, + evmPendingTxs: evmPendingFirst, + evmConfirmedTxs: allConfirmedForEnabledChains, chainFilteredNonEvmTransactionsForSelectedChain, }; }, [ - evmTransactions, + allConfirmedForEnabledChains, + submittedTxs, nonEvmTransactions, - selectedAccountGroupInternalAccountsAddresses, enabledEVMChainIds, enabledNonEVMChainIds, - selectedInternalAccount, - tokens, bridgeHistory, - trustedAddresses, + relatedChainIdsByTransactionId, ]); const { data, nonEvmTransactionsForSelectedChain } = useMemo<{ @@ -364,8 +304,8 @@ const UnifiedTransactionsView = ({ nonEvmTransactionsForSelectedChain: NonEvmTransaction[]; }>(() => { const { - evmPendingItems, - evmConfirmedItems, + evmPendingTxs, + evmConfirmedTxs, chainFilteredNonEvmTransactionsForSelectedChain, } = unifiedTransactionSource; @@ -375,28 +315,14 @@ const UnifiedTransactionsView = ({ maliciousTokenKeys, ); - const nonEvmItems: UnifiedItem[] = - filteredNonEvmTransactionsForSelectedChain.map((tx) => ({ - kind: TransactionKind.NonEvm, - tx, - })); - - const confirmedUnified = [...evmConfirmedItems, ...nonEvmItems].sort( - (a, b) => { - const ta = - a.kind === TransactionKind.Evm - ? getEvmTransactionTime(a.tx) - : (a.tx.timestamp ?? 0) * 1000; - const tb = - b.kind === TransactionKind.Evm - ? getEvmTransactionTime(b.tx) - : (b.tx.timestamp ?? 0) * 1000; - return tb - ta; - }, + const data = mergeTransactionsByTime( + evmPendingTxs, + evmConfirmedTxs, + filteredNonEvmTransactionsForSelectedChain, ); return { - data: [...evmPendingItems, ...confirmedUnified], + data, nonEvmTransactionsForSelectedChain: filteredNonEvmTransactionsForSelectedChain, }; @@ -531,6 +457,14 @@ const UnifiedTransactionsView = ({ }, [navigation, nonEvmExplorerUrl]); const footerComponent = useMemo(() => { + if (isFetchingNextPage) { + return ( + + + + ); + } + if (showEvmFooter) { return ( { setRefreshing(true); try { - await updateIncomingTransactions(); + await Promise.all([updateIncomingTransactions(), refetch()]); } finally { setRefreshing(false); } - }, []); + }, [refetch]); - const listRef = useRef>(null); + const lastConfirmedEvmIndex = useMemo(() => { + for (let index = data.length - 1; index >= 0; index -= 1) { + if (data[index].kind === TransactionKind.ConfirmedEvm) { + return index; + } + } - // Auto-scroll to top when new transactions are added - const { handleScroll } = useTransactionAutoScroll(data, listRef, { - keyExtractor: (item: UnifiedItem) => { - if (item.kind === TransactionKind.Evm) { - return getTransactionId(item.tx) ?? null; + return -1; + }, [data]); + + const lastConfirmedEvmKey = + lastConfirmedEvmIndex >= 0 + ? generateKey(data[lastConfirmedEvmIndex]) + : undefined; + + const onViewableItemsChanged = useCallback( + ({ viewableItems }: { viewableItems: ViewToken[] }) => { + if ( + !hasNextPage || + isFetchingNextPage || + !lastConfirmedEvmKey || + lastConfirmedEvmIndex < 0 + ) { + return; } - // For non-EVM (Solana, Bitcoin, Tron, etc.) - // Use same fallback as keyExtractor to ensure consistency - return String( - item.tx?.id ?? `${item.tx?.chain}-${item.tx?.timestamp ?? '0'}`, + + const prefetchIndex = Math.max( + lastConfirmedEvmIndex - confirmedEvmOverscan, + 0, ); + const isNearPrefetchThreshold = viewableItems.some( + ({ index }) => typeof index === 'number' && index >= prefetchIndex, + ); + + if (!isNearPrefetchThreshold) { + return; + } + + fetchNextPage(); }, + [ + fetchNextPage, + hasNextPage, + isFetchingNextPage, + lastConfirmedEvmIndex, + lastConfirmedEvmKey, + ], + ); + const listRef = useRef>(null); + + // Auto-scroll to top when new transactions are added + const { handleScroll } = useTransactionAutoScroll(data, listRef, { + keyExtractor: generateKey, }); const renderEmptyList = () => ( @@ -617,6 +592,15 @@ const UnifiedTransactionsView = ({ ); + const renderInitialLoading = () => ( + + + + ); + + const shouldShowTransactionList = !isInitialLoading && data.length > 0; + const items = shouldShowTransactionList ? data : []; + const renderItem = ({ item, index, @@ -632,7 +616,9 @@ const UnifiedTransactionsView = ({ i={index} navigation={navigation} txChainId={getEvmChainId(item.tx)} - selectedAddress={selectedInternalAccount?.address} + selectedAddress={ + selectedAccountGroupEvmAddress || selectedInternalAccount?.address + } onSpeedUpAction={onSpeedUpAction} onCancelAction={onCancelAction} signQRTransaction={signQRTransaction} @@ -653,6 +639,23 @@ const UnifiedTransactionsView = ({ ); } + if (item.kind === TransactionKind.ConfirmedEvm) { + return ( + + ); + } + // Render non-EVM transactions const srcTxHash = item.tx.id; // id is unique for multichain tx const bridgeHistoryItem = bridgeHistoryItemsBySrcTxHash[srcTxHash]; @@ -686,19 +689,14 @@ const UnifiedTransactionsView = ({ {({ isChartBeingTouched }) => ( - listItem.kind === TransactionKind.Evm - ? getTransactionId(listItem.tx) - : String( - listItem.tx.id ?? - `${listItem.tx.chain}-${listItem.tx.timestamp ?? '0'}`, - ) - } + keyExtractor={generateKey} ListHeaderComponent={header} - ListEmptyComponent={renderEmptyList} + ListEmptyComponent={ + isInitialLoading ? renderInitialLoading : renderEmptyList + } ListFooterComponent={footerComponent} style={baseStyles.flexGrow} refreshControl={ @@ -710,6 +708,8 @@ const UnifiedTransactionsView = ({ /> } onScroll={handleScroll} + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={visibilityConfig} scrollEventThrottle={16} scrollEnabled={!isChartBeingTouched} /> diff --git a/app/components/Views/UnifiedTransactionsView/helpers/adapters.test.ts b/app/components/Views/UnifiedTransactionsView/helpers/adapters.test.ts new file mode 100644 index 00000000000..88d2f1eaed7 --- /dev/null +++ b/app/components/Views/UnifiedTransactionsView/helpers/adapters.test.ts @@ -0,0 +1,172 @@ +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import { + APPROVE_FUNCTION_SIGNATURE, + TRANSFER_FUNCTION_SIGNATURE, +} from '../../../../util/transactions'; +import { normalizeTransaction } from './adapters'; + +describe('normalizeTransaction', () => { + const address = '0x0000000000000000000000000000000000000001'; + const otherAddress = '0x0000000000000000000000000000000000000002'; + const contractAddress = '0x00000000000000000000000000000000000000aa'; + + const buildTransaction = ( + overrides: Partial = {}, + ): V1TransactionByHashResponse => + ({ + hash: '0xhash', + timestamp: '2024-01-01T00:00:00Z', + chainId: 1, + blockNumber: 100, + blockHash: '0xblock', + gas: 21000, + gasUsed: 21000, + gasPrice: '1000000000', + effectiveGasPrice: '1000000000', + nonce: 0, + cumulativeGasUsed: 21000, + value: '1000', + to: otherAddress, + from: address, + methodId: '0x', + isError: false, + ...overrides, + }) as unknown as V1TransactionByHashResponse; + + it('normalizes a simple outgoing send', () => { + const meta = normalizeTransaction(address, buildTransaction()); + + expect(meta).toEqual( + expect.objectContaining({ + hash: '0xhash', + id: '0xhash-1', + chainId: '0x1', + status: TransactionStatus.confirmed, + type: TransactionType.simpleSend, + isTransfer: false, + networkClientId: '', + toSmartContract: false, + verifiedOnBlockchain: false, + blockNumber: '100', + time: Date.parse('2024-01-01T00:00:00Z'), + error: undefined, + transferInformation: undefined, + }), + ); + expect(meta.txParams).toEqual( + expect.objectContaining({ + chainId: '0x1', + from: address, + to: otherAddress, + value: '0x3e8', + gas: '0x5208', + gasPrice: '0x3b9aca00', + gasUsed: '0x5208', + nonce: '0x0', + }), + ); + }); + + it('marks the transaction as failed when isError is true', () => { + const meta = normalizeTransaction( + address, + buildTransaction({ isError: true }), + ); + + expect(meta.status).toBe(TransactionStatus.failed); + expect(meta.error).toBeInstanceOf(Error); + expect(meta.error?.message).toBe('Transaction failed'); + }); + + it('marks an outgoing transaction with no `to` and calldata as deployContract', () => { + const meta = normalizeTransaction( + address, + buildTransaction({ + to: undefined as unknown as string, + methodId: '0xabcdef', + }), + ); + + expect(meta.type).toBe(TransactionType.deployContract); + }); + + it('classifies an incoming transaction', () => { + const meta = normalizeTransaction( + address, + buildTransaction({ from: otherAddress, to: address }), + ); + + expect(meta.type).toBe(TransactionType.incoming); + }); + + it('detects ERC20 transfer method', () => { + const meta = normalizeTransaction( + address, + buildTransaction({ + methodId: TRANSFER_FUNCTION_SIGNATURE, + value: '0', + }), + ); + + expect(meta.type).toBe(TransactionType.tokenMethodTransfer); + }); + + it('detects ERC20 approve method', () => { + const meta = normalizeTransaction( + address, + buildTransaction({ + methodId: APPROVE_FUNCTION_SIGNATURE, + value: '0', + }), + ); + + expect(meta.type).toBe(TransactionType.tokenMethodApprove); + }); + + it('classifies a contract interaction when calldata has value', () => { + const meta = normalizeTransaction( + address, + buildTransaction({ + methodId: '0xdeadbeef', + value: '1000', + }), + ); + + expect(meta.type).toBe(TransactionType.contractInteraction); + }); + + it('extracts transfer information for an incoming token transfer and rewrites txParams', () => { + const meta = normalizeTransaction( + address, + buildTransaction({ + from: otherAddress, + to: contractAddress, + value: '0', + valueTransfers: [ + { + from: otherAddress, + to: address, + amount: '5000', + contractAddress, + decimal: 6, + symbol: 'USDC', + }, + ], + } as Partial), + ); + + expect(meta.isTransfer).toBe(true); + expect(meta.transferInformation).toEqual({ + amount: '5000', + contractAddress, + decimals: 6, + symbol: 'USDC', + }); + expect(meta.txParams.to).toBe(address); + expect(meta.txParams.value).toBe('0x1388'); + }); +}); diff --git a/app/components/Views/UnifiedTransactionsView/helpers/adapters.ts b/app/components/Views/UnifiedTransactionsView/helpers/adapters.ts new file mode 100644 index 00000000000..865a58c2361 --- /dev/null +++ b/app/components/Views/UnifiedTransactionsView/helpers/adapters.ts @@ -0,0 +1,138 @@ +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import { + type TransactionMeta, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import { + APPROVE_FUNCTION_SIGNATURE, + INCREASE_ALLOWANCE_SIGNATURE, + NFT_SAFE_TRANSFER_FROM_FUNCTION_SIGNATURE, + SET_APPROVAL_FOR_ALL_SIGNATURE, + TRANSFER_FROM_FUNCTION_SIGNATURE, + TRANSFER_FUNCTION_SIGNATURE, +} from '../../../../util/transactions'; +import { Hex } from 'viem'; +import { toHex } from '@metamask/controller-utils'; + +// Ported from transaction-controller +// - AccountsApiRemoteTransactionSource +// - determineTransactionType +function resolveTransactionMetaType( + transaction: V1TransactionByHashResponse, + isOutgoing: boolean, +) { + if (!isOutgoing) { + return TransactionType.incoming; + } + + const rawData = transaction.methodId?.toLowerCase(); + // Treat '0x' (empty calldata) the same as no methodId, since the API + // returns '0x' for simple ETH sends. + const data = rawData && rawData !== '0x' ? rawData : undefined; + + if (data && !transaction.to) { + return TransactionType.deployContract; + } + + const isContractAddress = Boolean(data?.length); + + if (!isContractAddress) { + return TransactionType.simpleSend; + } + + const hasValue = BigInt(transaction.value ?? '0') !== BigInt(0); + + if (hasValue) { + return TransactionType.contractInteraction; + } + + if (!data) { + return TransactionType.contractInteraction; + } + + switch (data) { + case APPROVE_FUNCTION_SIGNATURE: + return TransactionType.tokenMethodApprove; + case SET_APPROVAL_FOR_ALL_SIGNATURE: + return TransactionType.tokenMethodSetApprovalForAll; + case TRANSFER_FUNCTION_SIGNATURE: + return TransactionType.tokenMethodTransfer; + case TRANSFER_FROM_FUNCTION_SIGNATURE: + return TransactionType.tokenMethodTransferFrom; + case NFT_SAFE_TRANSFER_FROM_FUNCTION_SIGNATURE: + return TransactionType.tokenMethodSafeTransferFrom; + case INCREASE_ALLOWANCE_SIGNATURE: + return TransactionType.tokenMethodIncreaseAllowance; + default: + return TransactionType.contractInteraction; + } +} + +// Ported from transaction-controller normalizeTransaction +export function normalizeTransaction( + address: string, + transaction: V1TransactionByHashResponse, +) { + const { from, hash, methodId } = transaction; + const normalizedAddress = address.toLowerCase(); + + const status = transaction.isError + ? TransactionStatus.failed + : TransactionStatus.confirmed; + + // Find token transfer that involves the current address + const valueTransfer = transaction.valueTransfers?.find( + (vt) => + (vt.to?.toLowerCase() === normalizedAddress || + vt.from?.toLowerCase() === normalizedAddress) && + vt.contractAddress, + ); + + const isIncomingTokenTransfer = + valueTransfer?.to?.toLowerCase() === normalizedAddress && + from.toLowerCase() !== normalizedAddress; + const isOutgoing = from.toLowerCase() === normalizedAddress; + + const transferInformation = valueTransfer + ? { + amount: valueTransfer.amount, + contractAddress: valueTransfer.contractAddress, + decimals: valueTransfer.decimal, + symbol: valueTransfer.symbol, + } + : undefined; + + const meta: TransactionMeta = { + blockNumber: String(transaction.blockNumber), + chainId: toHex(transaction.chainId), + error: transaction.isError ? new Error('Transaction failed') : undefined, + hash, + id: `${hash}-${transaction.chainId}`, + isTransfer: isIncomingTokenTransfer, + networkClientId: '', + status, + time: Date.parse(transaction.timestamp) || 0, + toSmartContract: false, + transferInformation, + txParams: { + chainId: toHex(transaction.chainId), + data: methodId as Hex, + from: from as Hex, + gas: toHex(transaction.gas), + gasPrice: toHex(transaction.gasPrice), + gasUsed: toHex(transaction.gasUsed), + nonce: toHex(transaction.nonce), + to: isIncomingTokenTransfer ? address : transaction.to, + value: toHex( + isIncomingTokenTransfer + ? (valueTransfer?.amount ?? transaction.value) + : transaction.value, + ), + }, + type: resolveTransactionMetaType(transaction, isOutgoing), + verifiedOnBlockchain: false, + }; + + return meta; +} diff --git a/app/components/Views/UnifiedTransactionsView/helpers/transformations.test.ts b/app/components/Views/UnifiedTransactionsView/helpers/transformations.test.ts new file mode 100644 index 00000000000..33169e9f302 --- /dev/null +++ b/app/components/Views/UnifiedTransactionsView/helpers/transformations.test.ts @@ -0,0 +1,255 @@ +import type { + V1TransactionByHashResponse, + V4MultiAccountTransactionsResponse, +} from '@metamask/core-backend'; +import type { InfiniteData } from '@tanstack/react-query'; +import { + isBridgeHistoryForEvmTransaction, + mergeTransactionsByTime, + selectTransactions, +} from './transformations'; +import { TransactionKind } from '../types'; + +describe('selectTransactions', () => { + const address = '0x0000000000000000000000000000000000000001'; + const otherAddress = '0x0000000000000000000000000000000000000002'; + + const buildTransaction = ( + overrides: Partial = {}, + ): V1TransactionByHashResponse => + ({ + hash: '0xhash', + timestamp: '2024-01-01T00:00:00Z', + chainId: 1, + blockNumber: 100, + blockHash: '0xblock', + gas: 21000, + gasUsed: 21000, + gasPrice: '1000000000', + effectiveGasPrice: '1000000000', + nonce: 0, + cumulativeGasUsed: 21000, + value: '1000', + to: otherAddress, + from: address, + ...overrides, + }) as unknown as V1TransactionByHashResponse; + + const buildData = ( + transactions: V1TransactionByHashResponse[], + ): InfiniteData => + ({ + pages: [{ data: transactions } as V4MultiAccountTransactionsResponse], + pageParams: [undefined], + }) as InfiniteData; + + it('transforms transactions into view models with id and transactionMeta', () => { + const tx = buildTransaction(); + const result = selectTransactions({ address })(buildData([tx])); + + expect(result.pages).toHaveLength(1); + expect(result.pages[0].data).toHaveLength(1); + const [viewModel] = result.pages[0].data; + expect(viewModel.id).toBe('0xhash-1'); + expect(viewModel.hexChainId).toBe('0x1'); + expect(viewModel.transactionMeta).toBeDefined(); + expect(viewModel.hash).toBe('0xhash'); + }); + + it('filters out spam token transfers', () => { + const spam = buildTransaction({ + hash: '0xspam', + transactionType: 'SPAM_TOKEN_TRANSFER', + } as Partial); + const normal = buildTransaction({ hash: '0xnormal' }); + + const result = selectTransactions({ address })(buildData([spam, normal])); + + expect(result.pages[0].data).toHaveLength(1); + expect(result.pages[0].data[0].hash).toBe('0xnormal'); + }); + + it('filters out transactions unrelated to the address', () => { + const unrelated = buildTransaction({ + hash: '0xunrelated', + from: '0x0000000000000000000000000000000000000003', + to: '0x0000000000000000000000000000000000000004', + }); + + const result = selectTransactions({ address })(buildData([unrelated])); + + expect(result.pages[0].data).toHaveLength(0); + }); + + it('filters out transactions with excluded hashes', () => { + const excluded = buildTransaction({ hash: '0xEXCLUDED' }); + const normal = buildTransaction({ hash: '0xnormal' }); + + const result = selectTransactions({ + address, + excludedTxHashes: new Set(['0xexcluded']), + })(buildData([excluded, normal])); + + expect(result.pages[0].data).toHaveLength(1); + expect(result.pages[0].data[0].hash).toBe('0xnormal'); + }); + + it('filters incoming token transfers', () => { + const incomingTokenTransfer = buildTransaction({ + hash: '0xincoming-token', + from: otherAddress, + to: address, + valueTransfers: [ + { + contractAddress: '0x00000000000000000000000000000000000000aa', + from: otherAddress, + to: address, + }, + ], + } as Partial); + const outgoing = buildTransaction({ hash: '0xoutgoing' }); + + const result = selectTransactions({ address })( + buildData([incomingTokenTransfer, outgoing]), + ); + + expect(result.pages[0].data).toHaveLength(1); + expect(result.pages[0].data[0].hash).toBe('0xoutgoing'); + }); + + it('filters incoming native transfers', () => { + const incomingNativeTransfer = buildTransaction({ + hash: '0xincoming-native', + from: otherAddress, + to: address, + valueTransfers: [ + { + from: otherAddress, + to: address, + }, + ], + } as Partial); + const outgoing = buildTransaction({ hash: '0xoutgoing' }); + + const result = selectTransactions({ address })( + buildData([incomingNativeTransfer, outgoing]), + ); + + expect(result.pages[0].data).toHaveLength(1); + expect(result.pages[0].data[0].hash).toBe('0xoutgoing'); + }); + + it('filters zero-value self sends without calldata or transfers', () => { + const selfSend = buildTransaction({ + from: address, + to: address, + value: '0', + methodId: '0x', + valueTransfers: [], + }); + + const result = selectTransactions({ address })(buildData([selfSend])); + + expect(result.pages[0].data).toHaveLength(0); + }); +}); + +describe('isBridgeHistoryForEvmTransaction', () => { + it('matches bridge history by original transaction id', () => { + const tx = { + id: 'tx-id', + actionId: 'action-id', + }; + const bridgeHistoryValues = [ + { + txMetaId: 'different-id', + originalTransactionId: 'action-id', + }, + ]; + + const result = isBridgeHistoryForEvmTransaction( + tx as Parameters[0], + bridgeHistoryValues as Parameters< + typeof isBridgeHistoryForEvmTransaction + >[1], + ); + + expect(result).toBe(true); + }); + + it('matches bridge history by source hash', () => { + const tx = { + id: 'tx-id', + hash: '0xABC', + }; + const bridgeHistoryValues = [ + { + txMetaId: 'different-id', + status: { + srcChain: { + txHash: '0xabc', + }, + }, + }, + ]; + + const result = isBridgeHistoryForEvmTransaction( + tx as Parameters[0], + bridgeHistoryValues as Parameters< + typeof isBridgeHistoryForEvmTransaction + >[1], + ); + + expect(result).toBe(true); + }); +}); + +describe('mergeTransactionsByTime', () => { + it('sorts unified transactions by time and removes local transactions with confirmed hashes', () => { + const localDuplicate = { + id: 'local-duplicate', + hash: '0xDUPLICATE', + time: 300, + }; + const localUnique = { + id: 'local-unique', + hash: '0xlocal', + time: 200, + }; + const confirmedDuplicate = { + id: 'confirmed-duplicate', + hash: '0xduplicate', + time: 400, + }; + const nonEvm = { + id: 'non-evm', + timestamp: 1, + }; + + const result = mergeTransactionsByTime( + [localDuplicate, localUnique] as Parameters< + typeof mergeTransactionsByTime + >[0], + [confirmedDuplicate] as Parameters[1], + [nonEvm] as Parameters[2], + ); + + expect(result).toStrictEqual([ + { + kind: TransactionKind.NonEvm, + tx: nonEvm, + time: 1000, + }, + { + kind: TransactionKind.ConfirmedEvm, + tx: confirmedDuplicate, + time: 400, + }, + { + kind: TransactionKind.Evm, + tx: localUnique, + time: 200, + }, + ]); + }); +}); diff --git a/app/components/Views/UnifiedTransactionsView/helpers/transformations.ts b/app/components/Views/UnifiedTransactionsView/helpers/transformations.ts new file mode 100644 index 00000000000..5c9d1ad958a --- /dev/null +++ b/app/components/Views/UnifiedTransactionsView/helpers/transformations.ts @@ -0,0 +1,227 @@ +import { + type V1TransactionByHashResponse, + type V4MultiAccountTransactionsResponse, +} from '@metamask/core-backend'; +import type { BridgeHistoryItem } from '@metamask/bridge-status-controller'; +import type { Transaction as NonEvmTransaction } from '@metamask/keyring-api'; +import type { InfiniteData } from '@tanstack/react-query'; +import { normalizeTransaction } from './adapters'; +import { + EvmTransaction, + TransactionKind, + TransactionViewModel, + UnifiedItem, +} from '../types'; +import { equalsIgnoreCase } from '../../../../util/string'; + +const excludedTransactionTypes = ['SPAM_TOKEN_TRANSFER']; + +const getOriginalTransactionId = (bridgeHistoryItem: BridgeHistoryItem) => + (bridgeHistoryItem as unknown as { originalTransactionId?: string }) + .originalTransactionId; + +export const isBridgeHistoryForEvmTransaction = ( + tx: EvmTransaction & { actionId?: string; hash?: string }, + bridgeHistoryValues: BridgeHistoryItem[], +) => + bridgeHistoryValues.some((bridgeHistoryItem) => { + const originalTransactionId = getOriginalTransactionId(bridgeHistoryItem); + + return ( + bridgeHistoryItem.txMetaId === tx.id || + bridgeHistoryItem.txMetaId === tx.actionId || + originalTransactionId === tx.id || + originalTransactionId === tx.actionId || + equalsIgnoreCase(bridgeHistoryItem.status?.srcChain?.txHash, tx.hash) + ); + }); + +function isIncomingTokenTransfer( + address: string, + transaction: V1TransactionByHashResponse, +) { + return ( + transaction.valueTransfers?.some( + (transfer) => + Boolean(transfer.contractAddress) && + transfer.to?.toLowerCase() === address && + transaction.from?.toLowerCase() !== address, + ) ?? false + ); +} + +function isIncomingNativeTransfer( + address: string, + transaction: V1TransactionByHashResponse, +) { + const normalizedAddress = address.toLowerCase(); + let hasOutgoingTransfer = false; + let hasIncomingNativeTransfer = false; + + for (const transfer of transaction.valueTransfers ?? []) { + if ( + !hasOutgoingTransfer && + transfer.from?.toLowerCase() === normalizedAddress + ) { + hasOutgoingTransfer = true; + } + + if ( + !hasIncomingNativeTransfer && + transfer.to?.toLowerCase() === normalizedAddress && + !transfer.contractAddress + ) { + hasIncomingNativeTransfer = true; + } + + if (hasOutgoingTransfer && hasIncomingNativeTransfer) { + break; + } + } + + return hasIncomingNativeTransfer && !hasOutgoingTransfer; +} + +function shouldSkipTransaction( + address: string, + transaction: V1TransactionByHashResponse, + excludedTxHashes?: Set, +) { + const rawFrom = transaction.from?.toLowerCase(); + const rawTo = transaction.to?.toLowerCase(); + const hash = transaction.hash?.toLowerCase(); + + if (hash && excludedTxHashes?.has(hash)) { + return true; + } + + if (rawFrom !== address && rawTo !== address) { + return true; + } + + // Filter out span token transfers + if (excludedTransactionTypes.includes(transaction.transactionType ?? '')) { + return true; + } + + // Filter out zero-value self-sends with no calldata and no transfers + if ( + rawFrom === address && + rawTo === address && + transaction.value === '0' && + !transaction.valueTransfers?.length && + (!transaction.methodId || transaction.methodId === '0x') + ) { + return true; + } + + // Filter out incoming native token transfers + if (isIncomingTokenTransfer(address, transaction)) { + return true; + } + + return rawFrom !== address && isIncomingNativeTransfer(address, transaction); +} + +function transformTransactions( + address: string, + transactions: V1TransactionByHashResponse[], + excludedTxHashes?: Set, +): TransactionViewModel[] { + const filteredTransactions = []; + + for (const tx of transactions) { + if (shouldSkipTransaction(address, tx, excludedTxHashes)) { + continue; + } + + filteredTransactions.push(tx); + } + + return filteredTransactions.map((tx) => { + const transactionMeta = normalizeTransaction(address, tx); + + return { + // Intent is to use the API response more directly + ...tx, + // But for now, we keep this until we can refactor the UI components + id: transactionMeta.id, + time: transactionMeta.time, + hexChainId: transactionMeta.chainId, + transactionMeta, + }; + }); +} + +export function selectTransactions({ + address, + excludedTxHashes, +}: { + address: string; + excludedTxHashes?: Set; +}) { + return (data: InfiniteData) => ({ + ...data, + pages: data.pages.map((page) => ({ + ...page, + data: transformTransactions(address, page.data, excludedTxHashes), + })), + }); +} + +const getEvmTime = (tx: EvmTransaction) => tx.time ?? 0; +const getNonEvmTime = (tx: NonEvmTransaction) => (tx.timestamp ?? 0) * 1000; +const getEvmHash = (tx: EvmTransaction) => + 'hash' in tx && typeof tx.hash === 'string' ? tx.hash.toLowerCase() : ''; + +// Merges local EVM, API-confirmed EVM and non-EVM transactions into one list +// sorted by time (newest first), deduplicated by hash (API-confirmed wins). +export function mergeTransactionsByTime( + evmLocalTransactions: EvmTransaction[], + evmConfirmedTransactions: TransactionViewModel[], + nonEvmTransactions: NonEvmTransaction[], +) { + const seenHashes = new Set(); + + const confirmedItems: UnifiedItem[] = []; + for (const tx of evmConfirmedTransactions) { + const hash = tx.hash?.toLowerCase(); + if (hash) { + if (seenHashes.has(hash)) { + continue; + } + seenHashes.add(hash); + } + confirmedItems.push({ + kind: TransactionKind.ConfirmedEvm, + tx, + time: tx.time ?? 0, + }); + } + + const localItems: UnifiedItem[] = []; + for (const tx of evmLocalTransactions) { + const hash = getEvmHash(tx); + if (hash) { + if (seenHashes.has(hash)) { + continue; + } + seenHashes.add(hash); + } + localItems.push({ + kind: TransactionKind.Evm, + tx, + time: getEvmTime(tx), + }); + } + + const nonEvmItems: UnifiedItem[] = nonEvmTransactions.map((tx) => ({ + kind: TransactionKind.NonEvm, + tx, + time: getNonEvmTime(tx), + })); + + return [...localItems, ...confirmedItems, ...nonEvmItems].sort( + (a, b) => b.time - a.time, + ); +} diff --git a/app/components/Views/UnifiedTransactionsView/types.ts b/app/components/Views/UnifiedTransactionsView/types.ts new file mode 100644 index 00000000000..ecaa717af7a --- /dev/null +++ b/app/components/Views/UnifiedTransactionsView/types.ts @@ -0,0 +1,32 @@ +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import { SmartTransaction } from '@metamask/smart-transactions-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Transaction as NonEvmTransaction } from '@metamask/keyring-api'; + +export type SmartTransactionWithId = SmartTransaction & { id: string }; + +export type EvmTransaction = TransactionMeta | SmartTransactionWithId; + +export type TransactionViewModel = V1TransactionByHashResponse & { + // Intent is to use the API response more directly + id: string; + time: number; + hexChainId: string; + // But for now, we keep this until we can refactor the UI components + transactionMeta: TransactionMeta; +}; + +export enum TransactionKind { + Evm = 'evm', + ConfirmedEvm = 'confirmed', + NonEvm = 'nonEvm', +} + +export type UnifiedItem = + | { kind: TransactionKind.Evm; tx: EvmTransaction; time: number } + | { + kind: TransactionKind.ConfirmedEvm; + tx: TransactionViewModel; + time: number; + } + | { kind: TransactionKind.NonEvm; tx: NonEvmTransaction; time: number }; diff --git a/app/components/Views/UnifiedTransactionsView/useTransactionsQuery.test.ts b/app/components/Views/UnifiedTransactionsView/useTransactionsQuery.test.ts new file mode 100644 index 00000000000..8e94ce33cbe --- /dev/null +++ b/app/components/Views/UnifiedTransactionsView/useTransactionsQuery.test.ts @@ -0,0 +1,198 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useSelector } from 'react-redux'; +import { apiClient } from '../../../core/apiClient'; +import { selectEvmAddress } from '../../../selectors/accountsController'; +import { selectSelectedAccountGroupEvmInternalAccount } from '../../../selectors/multichainAccounts/accountTreeController'; +import { selectEvmEnabledCaipNetworks } from '../../../selectors/networkEnablementController'; +import { useTransactionsQuery } from './useTransactionsQuery'; +import { MINUTE } from '../../../constants/time'; +import { selectRequiredTransactionHashes } from '../../../selectors/transactionController'; + +jest.mock('@tanstack/react-query', () => ({ + useInfiniteQuery: jest.fn(), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../core/apiClient', () => ({ + apiClient: { + accounts: { + getV4MultiAccountTransactionsInfiniteQueryOptions: jest.fn(), + }, + }, +})); + +jest.mock('../../../selectors/accountsController', () => ({ + selectEvmAddress: jest.fn(), +})); + +jest.mock( + '../../../selectors/multichainAccounts/accountTreeController', + () => ({ + selectSelectedAccountGroupEvmInternalAccount: jest.fn(), + }), +); + +jest.mock('../../../selectors/networkEnablementController', () => ({ + selectEvmEnabledCaipNetworks: jest.fn(), +})); + +jest.mock('../../../selectors/transactionController', () => ({ + selectRequiredTransactionHashes: jest.fn(), +})); + +const ADDRESS_MOCK = '0x1234567890123456789012345678901234567890'; +const GROUP_EVM_ADDRESS_MOCK = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; +const NETWORKS_MOCK = ['eip155:1', 'eip155:137']; +const QUERY_OPTIONS_MOCK = { + queryKey: ['transactions'], + queryFn: jest.fn(), + getNextPageParam: jest.fn(), +}; + +describe('useTransactionsQuery', () => { + const useSelectorMock = jest.mocked(useSelector); + const useInfiniteQueryMock = jest.mocked(useInfiniteQuery); + const getQueryOptionsMock = jest.mocked( + apiClient.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions, + ); + + function setupSelectors({ + evmAddress = ADDRESS_MOCK, + groupEvmAccount = null as { address: string } | null, + networks = NETWORKS_MOCK, + }: { + evmAddress?: string; + groupEvmAccount?: { address: string } | null; + networks?: string[]; + } = {}) { + useSelectorMock.mockImplementation((selector) => { + if (selector === selectSelectedAccountGroupEvmInternalAccount) { + return groupEvmAccount; + } + if (selector === selectEvmAddress) { + return evmAddress; + } + if (selector === selectEvmEnabledCaipNetworks) { + return networks; + } + if (selector === selectRequiredTransactionHashes) { + return new Set(); + } + return undefined; + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + getQueryOptionsMock.mockReturnValue( + QUERY_OPTIONS_MOCK as unknown as ReturnType, + ); + useInfiniteQueryMock.mockReturnValue({ + data: undefined, + } as unknown as ReturnType); + }); + + it('composes query options from the selected EVM account and networks', () => { + setupSelectors(); + + renderHook(() => useTransactionsQuery()); + + expect(getQueryOptionsMock).toHaveBeenCalledWith({ + accountAddresses: [`eip155:0:${ADDRESS_MOCK}`], + networks: NETWORKS_MOCK, + includeTxMetadata: true, + }); + }); + + it('delegates to useInfiniteQuery with selectFn, enabled, staleTime and retry', () => { + setupSelectors(); + + renderHook(() => useTransactionsQuery()); + + expect(useInfiniteQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + ...QUERY_OPTIONS_MOCK, + select: expect.any(Function), + enabled: true, + staleTime: 5 * MINUTE, + retry: false, + }), + ); + }); + + it('disables the query and sends no account addresses when there is no EVM address', () => { + setupSelectors({ evmAddress: '', groupEvmAccount: null }); + + renderHook(() => useTransactionsQuery()); + + expect(getQueryOptionsMock).toHaveBeenCalledWith( + expect.objectContaining({ accountAddresses: [] }), + ); + expect(useInfiniteQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + + it('uses account group EVM address when globally selected account has no EVM address', () => { + setupSelectors({ + evmAddress: '', + groupEvmAccount: { address: GROUP_EVM_ADDRESS_MOCK }, + }); + + renderHook(() => useTransactionsQuery()); + + expect(getQueryOptionsMock).toHaveBeenCalledWith({ + accountAddresses: [`eip155:0:${GROUP_EVM_ADDRESS_MOCK}`], + networks: NETWORKS_MOCK, + includeTxMetadata: true, + }); + expect(useInfiniteQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true }), + ); + }); + + it('disables the query when there are no enabled networks', () => { + setupSelectors({ networks: [] }); + + renderHook(() => useTransactionsQuery()); + + expect(useInfiniteQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + + it('prefers the account group EVM address over the global EVM address when both are set', () => { + setupSelectors({ + evmAddress: ADDRESS_MOCK, + groupEvmAccount: { address: GROUP_EVM_ADDRESS_MOCK }, + }); + + renderHook(() => useTransactionsQuery()); + + expect(getQueryOptionsMock).toHaveBeenCalledWith({ + accountAddresses: [`eip155:0:${GROUP_EVM_ADDRESS_MOCK}`], + networks: NETWORKS_MOCK, + includeTxMetadata: true, + }); + }); + + it('does not use global EVM address when group account has an empty string address', () => { + setupSelectors({ + evmAddress: ADDRESS_MOCK, + groupEvmAccount: { address: '' }, + }); + + renderHook(() => useTransactionsQuery()); + + expect(getQueryOptionsMock).toHaveBeenCalledWith( + expect.objectContaining({ accountAddresses: [] }), + ); + expect(useInfiniteQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); +}); diff --git a/app/components/Views/UnifiedTransactionsView/useTransactionsQuery.ts b/app/components/Views/UnifiedTransactionsView/useTransactionsQuery.ts new file mode 100644 index 00000000000..a48a90d0d34 --- /dev/null +++ b/app/components/Views/UnifiedTransactionsView/useTransactionsQuery.ts @@ -0,0 +1,46 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { KnownCaipNamespace, toCaipAccountId } from '@metamask/utils'; +import { apiClient } from '../../../core/apiClient'; +import { selectEvmAddress } from '../../../selectors/accountsController'; +import { selectSelectedAccountGroupEvmInternalAccount } from '../../../selectors/multichainAccounts/accountTreeController'; +import { selectEvmEnabledCaipNetworks } from '../../../selectors/networkEnablementController'; +import { selectTransactions } from './helpers/transformations'; +import { MINUTE } from '../../../constants/time'; +import { selectRequiredTransactionHashes } from '../../../selectors/transactionController'; + +export const useTransactionsQuery = () => { + const groupEvmAccount = useSelector( + selectSelectedAccountGroupEvmInternalAccount, + ); + const globalEvmAddress = useSelector(selectEvmAddress); + /** Activity must load EVM history for the group's EVM account when e.g. Bitcoin is selected. */ + const evmAddress = (groupEvmAccount?.address ?? globalEvmAddress ?? '') || ''; + const networks = useSelector(selectEvmEnabledCaipNetworks); + const excludedTxHashes = useSelector(selectRequiredTransactionHashes); + const accountAddresses = evmAddress + ? [toCaipAccountId(KnownCaipNamespace.Eip155, '0', evmAddress)] + : []; + + const queryOptions = + apiClient.accounts.getV4MultiAccountTransactionsInfiniteQueryOptions({ + accountAddresses, + networks, + includeTxMetadata: true, + }); + + const selectFn = useMemo( + () => selectTransactions({ address: evmAddress, excludedTxHashes }), + [evmAddress, excludedTxHashes], + ); + + // @ts-expect-error apiClient returns v5 types, repo still in v4 + return useInfiniteQuery({ + ...queryOptions, + select: selectFn, + enabled: accountAddresses.length > 0 && networks.length > 0, + staleTime: 5 * MINUTE, + retry: false, + }); +}; diff --git a/app/components/Views/Wallet/WalletView.testIds.ts b/app/components/Views/Wallet/WalletView.testIds.ts index 278e01c5b8e..fe2fad8b848 100644 --- a/app/components/Views/Wallet/WalletView.testIds.ts +++ b/app/components/Views/Wallet/WalletView.testIds.ts @@ -90,6 +90,7 @@ export const WalletViewSelectorsIDs = { BALANCE_EMPTY_STATE_CONTAINER: 'account-group-balance-empty-state', BALANCE_EMPTY_STATE_ACTION_BUTTON: 'account-group-balance-empty-state-action-button', + WALLET_ACTIVITY_BUTTON: 'wallet-activity-button', WALLET_HEADER_ROOT: 'wallet-header-root', WALLET_SAFE_AREA: 'wallet-safe-area', WALLET_SCROLL_VIEW: 'wallet-scroll-view', diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx index 2aad1303f3e..db08fc54a8f 100644 --- a/app/components/Views/Wallet/index.test.tsx +++ b/app/components/Views/Wallet/index.test.tsx @@ -91,6 +91,56 @@ jest.mock('../../../selectors/featureFlagController/homepage', () => ({ selectHomepageSectionsV1Enabled: jest.fn(() => mockHomepageSectionsEnabled), })); +// Control Money home screen feature flag per test (default false so existing tests are unaffected) +let mockMoneyHomeScreenEnabled = false; +jest.mock('../../UI/Money/selectors/featureFlags', () => ({ + selectMoneyHomeScreenEnabledFlag: jest.fn(() => mockMoneyHomeScreenEnabled), +})); + +// Mock MoneyBalanceCard so the integration test does not depend on its hooks/contexts. +jest.mock('../../UI/Money/components/MoneyBalanceCard', () => { + const ReactMock = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactMock.createElement(View, { + testID: 'money-balance-card-mock', + }), + }; +}); + +// Mock NetworkConnectionBanner so the Wallet view's render does not depend on +// Engine.lookupEnabledNetworks / NetworkController / controllerMessenger APIs. +// Without this, the banner hook throws during render and the ErrorBoundary +// swallows the failure, making negative-assert tests pass for the wrong reason. +jest.mock('../../UI/NetworkConnectionBanner', () => () => null); + +// Control discovery tabs AB test variant per test (default control so existing tests are unaffected) +let mockDiscoveryTabsVariantName = 'control'; +jest.mock('../../../hooks', () => ({ + ...jest.requireActual('../../../hooks'), + useABTest: jest.fn(() => ({ + variantName: mockDiscoveryTabsVariantName, + variant: { + discoveryTabsEnabled: mockDiscoveryTabsVariantName === 'treatment', + }, + })), +})); + +// Track HomepageDiscoveryTabs renders +const mockHomepageDiscoveryTabs = jest.fn(); +jest.mock('../Homepage/components/HomepageDiscoveryTabs', () => { + const React = jest.requireActual('react'); + return { + __esModule: true, + default: React.forwardRef((props: unknown, _ref: unknown) => { + mockHomepageDiscoveryTabs(props); + return null; + }), + }; +}); + // Capture the HomepageScrollContext value by rendering a context-aware mock Homepage. // The mock is only invoked when mockHomepageSectionsEnabled=true (sections flag on), // so existing tests that leave the flag false are completely unaffected. @@ -1606,6 +1656,144 @@ describe('HomepageScrollContext callbacks', () => { }); }); +describe('HomepageDiscoveryTabs AB test', () => { + let mockNavigation: NavigationProp; + + beforeEach(() => { + jest.clearAllMocks(); + mockHomepageSectionsEnabled = true; + mockDiscoveryTabsVariantName = 'control'; + mockHomepageDiscoveryTabs.mockClear(); + + mockNavigation = { + navigate: mockNavigate, + setOptions: mockSetOptions, + addListener: jest.fn(() => jest.fn()), + isFocused: jest.fn(() => false), + dangerouslyGetParent: jest.fn(() => ({ + dangerouslyGetState: jest.fn(() => ({ type: 'stack' })), + addListener: jest.fn(() => jest.fn()), + dangerouslyGetParent: jest.fn(() => ({ + dangerouslyGetState: jest.fn(() => ({ type: 'tab' })), + addListener: jest.fn(() => jest.fn()), + dangerouslyGetParent: jest.fn(() => undefined), + })), + })), + } as unknown as NavigationProp; + + jest + .mocked(useSelector) + .mockImplementation((callback: (state: unknown) => unknown) => + callback(mockInitialState), + ); + }); + + afterEach(() => { + mockHomepageSectionsEnabled = false; + mockDiscoveryTabsVariantName = 'control'; + jest.clearAllMocks(); + }); + + it('renders HomepageDiscoveryTabs when variant is treatment and sections flag is on', () => { + mockDiscoveryTabsVariantName = 'treatment'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockHomepageDiscoveryTabs).toHaveBeenCalled(); + }); + + it('does not render HomepageDiscoveryTabs when variant is control', () => { + mockDiscoveryTabsVariantName = 'control'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockHomepageDiscoveryTabs).not.toHaveBeenCalled(); + }); + + it('passes portfolioHeader, onPortfolioScroll, and refreshControl to HomepageDiscoveryTabs', () => { + mockDiscoveryTabsVariantName = 'treatment'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + const props = mockHomepageDiscoveryTabs.mock.calls.at(-1)?.[0] as Record< + string, + unknown + >; + expect(props).toBeDefined(); + expect(props.portfolioHeader).toBeDefined(); + expect(typeof props.onPortfolioScroll).toBe('function'); + expect(props.refreshControl).toBeDefined(); + }); + + it('passes walletHeaderOffset and walletHeaderHeight to HomepageDiscoveryTabs', () => { + mockDiscoveryTabsVariantName = 'treatment'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + const props = mockHomepageDiscoveryTabs.mock.calls.at(-1)?.[0] as Record< + string, + unknown + >; + expect(typeof props.walletHeaderOffset).toBe('number'); + expect(typeof props.walletHeaderHeight).toBe('number'); + }); + + it('renders Homepage scroll view (not HomepageDiscoveryTabs) when variant is control and sections flag is on', () => { + mockDiscoveryTabsVariantName = 'control'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + // HomepageDiscoveryTabs must not render; the legacy Homepage mock renders instead + expect(mockHomepageDiscoveryTabs).not.toHaveBeenCalled(); + expect(capturedContext).toBeDefined(); + }); + + it('does not render HomepageDiscoveryTabs when sections flag is off regardless of variant', () => { + mockHomepageSectionsEnabled = false; + mockDiscoveryTabsVariantName = 'treatment'; + + renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(mockHomepageDiscoveryTabs).not.toHaveBeenCalled(); + }); +}); + describe('useHomeDeepLinkEffects', () => { beforeEach(() => { jest.clearAllMocks(); @@ -1716,3 +1904,59 @@ describe('useHomeDeepLinkEffects', () => { assertCase(mocks); }); }); + +describe('MoneyBalanceCard slot', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(useSelector) + .mockImplementation((callback: (state: unknown) => unknown) => + callback(mockInitialState), + ); + }); + + afterEach(() => { + mockMoneyHomeScreenEnabled = false; + mockHomepageSectionsEnabled = false; + }); + + it('renders the MoneyBalanceCard when both feature flags are enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockHomepageSectionsEnabled = true; + + //@ts-expect-error navigation params intentionally omitted (same as render(Wallet)) + const { getByTestId } = render(Wallet); + + expect(getByTestId('money-balance-card-mock')).toBeOnTheScreen(); + }); + + it('does not render the MoneyBalanceCard when only the Money flag is enabled', () => { + mockMoneyHomeScreenEnabled = true; + mockHomepageSectionsEnabled = false; + + //@ts-expect-error navigation params intentionally omitted (same as render(Wallet)) + const { queryByTestId } = render(Wallet); + + expect(queryByTestId('money-balance-card-mock')).not.toBeOnTheScreen(); + }); + + it('does not render the MoneyBalanceCard when only the Homepage sections flag is enabled', () => { + mockMoneyHomeScreenEnabled = false; + mockHomepageSectionsEnabled = true; + + //@ts-expect-error navigation params intentionally omitted (same as render(Wallet)) + const { queryByTestId } = render(Wallet); + + expect(queryByTestId('money-balance-card-mock')).not.toBeOnTheScreen(); + }); + + it('does not render the MoneyBalanceCard when both feature flags are disabled', () => { + mockMoneyHomeScreenEnabled = false; + mockHomepageSectionsEnabled = false; + + //@ts-expect-error navigation params intentionally omitted (same as render(Wallet)) + const { queryByTestId } = render(Wallet); + + expect(queryByTestId('money-balance-card-mock')).not.toBeOnTheScreen(); + }); +}); diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 90bfe1c8c03..582e005ad6a 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -22,7 +22,14 @@ import { StyleSheet as RNStyleSheet, View, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { + SafeAreaView, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; +import Reanimated, { + useSharedValue, + useAnimatedStyle, +} from 'react-native-reanimated'; import { connect, useDispatch, useSelector } from 'react-redux'; import { strings } from '../../../../locales/i18n'; import { @@ -48,6 +55,8 @@ import HeaderRoot from '../../../component-library/components-temp/HeaderRoot'; import PickerAccount from '../../../component-library/components/Pickers/PickerAccount'; import AddressCopy from '../../UI/AddressCopy'; import CardButton from '../../UI/Card/components/CardButton'; +import { selectMoneyHomeScreenEnabledFlag } from '../../UI/Money/selectors/featureFlags'; +import MoneyBalanceCard from '../../UI/Money/components/MoneyBalanceCard'; import { createAccountSelectorNavDetails } from '../AccountSelector'; import { isNotificationsFeatureEnabled } from '../../../util/notifications'; import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; @@ -132,6 +141,13 @@ import { Hex } from '@metamask/utils'; import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; import { selectHomepageSectionsV1Enabled } from '../../../selectors/featureFlagController/homepage'; import Homepage from '../Homepage'; +import HomepageDiscoveryTabs from '../Homepage/components/HomepageDiscoveryTabs'; +import { + HUB_PAGE_DISCOVERY_TABS_AB_KEY, + HUB_PAGE_DISCOVERY_TABS_VARIANTS, + HubPageDiscoveryTabsVariant, +} from '../Homepage/abTestConfig'; +import { useABTest } from '../../../hooks'; import { SectionRefreshHandle } from '../Homepage/types'; import { HomepageScrollContext } from '../Homepage/context/HomepageScrollContext'; import type { HomeSectionName } from '../Homepage/hooks/useHomeViewedEvent'; @@ -225,6 +241,13 @@ const createStyles = ({ colors }: Theme) => }, headerAccountPickerStyle: { marginRight: 16, + backgroundColor: 'transparent', + }, + accountGroupBalanceContainer: { + marginBottom: 16, + }, + walletHeaderRoot: { + zIndex: 2, }, }); @@ -595,6 +618,10 @@ const Wallet = ({ // ─── Homepage scroll context state ─────────────────────────────────────── const [viewportHeight, setViewportHeight] = useState(0); const [containerScreenY, setContainerScreenY] = useState(0); + const [headerHeight, setHeaderHeight] = useState(0); + const sharedHeaderHeight = useSharedValue(0); + const walletHeaderTranslateY = useSharedValue(0); + const insets = useSafeAreaInsets(); const { entryPoint, visitId } = useHomepageEntryPoint(navigation); // Ref to the scroll container View — used to measure its absolute screen Y @@ -643,6 +670,10 @@ const Wallet = ({ */ const selectedInternalAccount = useSelector(selectSelectedInternalAccount); + const isMoneyHomeScreenEnabled = useSelector( + selectMoneyHomeScreenEnabledFlag, + ); + /** * Provider configuration for the current selected network */ @@ -1011,6 +1042,25 @@ const Wallet = ({ selectHomepageSectionsV1Enabled, ); + const { variantName: discoveryTabsVariantName } = useABTest( + HUB_PAGE_DISCOVERY_TABS_AB_KEY, + HUB_PAGE_DISCOVERY_TABS_VARIANTS, + ); + + const isDiscoveryTabsTreatment = + discoveryTabsVariantName === HubPageDiscoveryTabsVariant.Treatment; + + // translateY slides the header up; negative marginBottom collapses the layout + // space it occupied so the content below moves up in sync. + const animatedHeaderStyle = useAnimatedStyle(() => { + const h = sharedHeaderHeight.value; + return { + transform: [{ translateY: walletHeaderTranslateY.value }], + marginBottom: walletHeaderTranslateY.value, + opacity: h > 0 ? Math.max(0, 1 + walletHeaderTranslateY.value / h) : 1, + }; + }); + const isFocused = useIsFocused(); const homepageRef = useRef(null); @@ -1050,6 +1100,15 @@ const Wallet = ({ navigation.navigate(Routes.CARD.ROOT); }, [navigation, trackEvent]); + const handleActivityPress = useCallback(() => { + trackEvent( + AnalyticsEventBuilder.createEventBuilder( + MetaMetricsEvents.ACTIVITY_CLICKED, + ).build(), + ); + navigation.navigate(Routes.TRANSACTIONS_VIEW); + }, [navigation, trackEvent]); + const getTokenAddedAnalyticsParams = useCallback( ({ address, symbol }: { address: string; symbol: string }) => { try { @@ -1287,62 +1346,93 @@ const Wallet = ({ ], ); - const content = ( + const bannerContent = ( + + {!basicFunctionalityEnabled ? ( + + {strings('wallet.banner.link')} + + } + /> + ) : null} + + + ); + + const portfolioHeaderBase = ( <> - - {!basicFunctionalityEnabled ? ( - - {strings('wallet.banner.link')} - - } - /> - ) : null} - - - <> - + {bannerContent} + + + {isCarouselBannersEnabled && } + {isMoneyHomeScreenEnabled && } + + ); - + const portfolioHeader = ( + <> + {bannerContent} + + + + + {isCarouselBannersEnabled && } + {isMoneyHomeScreenEnabled && } + + ); - {isCarouselBannersEnabled && } - - {isHomepageSectionsV1Enabled ? ( - <> - {isFocused && } - - - - - ) : ( - <> - {isFocused && } - - - )} - + // Legacy scroll view content — used only when the sections redesign is off. + const content = ( + <> + {bannerContent} + + + {isCarouselBannersEnabled && } + {isFocused && } + ); const renderLoader = useCallback( @@ -1367,37 +1457,84 @@ const Wallet = ({ > {selectedInternalAccount ? ( <> - - - - - - - {isNotificationsFeatureEnabled() ? ( - + { + const h = e.nativeEvent.layout.height; + if (h > 0) { + setHeaderHeight(h); + sharedHeaderHeight.value = h; } - badge={ - isNotificationEnabled && - unreadNotificationCount > 0 ? ( - - ) : null + } + : undefined + } + testID={WalletViewSelectorsIDs.WALLET_HEADER_ROOT} + style={undefined} + endAccessory={ + + + {isMoneyHomeScreenEnabled && ( + + )} + + + + + {isNotificationsFeatureEnabled() ? ( + 0 ? ( + + ) : null + } + > + + + ) : ( - - ) : ( - - )} + )} + - - } - twClassName="pl-1 pr-3" - > - - navigation.navigate(...createAccountSelectorNavDetails({})) } - testID={WalletViewSelectorsIDs.ACCOUNT_ICON} - hitSlop={touchAreaSlop} - style={styles.headerAccountPickerStyle} - /> - + twClassName="pl-1 pr-3" + > + + navigation.navigate( + ...createAccountSelectorNavDetails({}), + ) + } + testID={WalletViewSelectorsIDs.ACCOUNT_ICON} + hitSlop={touchAreaSlop} + style={styles.headerAccountPickerStyle} + /> + + - - ), - }} - > - {content} - + {isHomepageSectionsV1Enabled ? ( + <> + {isFocused && ( + + )} + + {isDiscoveryTabsTreatment ? ( + + } + /> + ) : ( + + ), + }} + > + {portfolioHeaderBase} + + + )} + + + ) : ( + + ), + }} + > + {content} + + )} ) : ( diff --git a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx new file mode 100644 index 00000000000..6a4abc40527 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx @@ -0,0 +1,409 @@ +import React from 'react'; +import { Pressable, View } from 'react-native'; +import { screen, fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../util/test/renderWithProvider'; +import WhatsHappeningExpandedCard from './components/WhatsHappeningExpandedCard'; +import WhatsHappeningSourcesBottomSheet from './components/WhatsHappeningSourcesBottomSheet'; +import WhatsHappeningDetailView, { + CARD_WIDTH, +} from './WhatsHappeningDetailView'; +import { MetaMetricsEvents } from '../../../core/Analytics/MetaMetrics.events'; + +const GAP = 12; +const SNAP_INTERVAL_FOR_TEST = CARD_WIDTH + GAP; + +const mockGoBack = jest.fn(); +const mockRefresh = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn((eventName: string) => ({ + addProperties: jest.fn((properties: Record) => ({ + build: jest.fn(() => ({ category: eventName, properties })), + })), + build: jest.fn(() => ({ category: eventName })), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(), + useRoute: jest.fn(), +})); + +jest.mock('./components/WhatsHappeningExpandedCard', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('./components/WhatsHappeningSourcesBottomSheet', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +jest.mock('../Homepage/Sections/WhatsHappening/hooks', () => ({ + useWhatsHappening: jest.fn(() => ({ + items: [], + isLoading: false, + error: null, + refresh: mockRefresh, + })), +})); + +jest.mock('./utils/getRelatedAssetImageSource', () => ({ + getRelatedAssetImageSource: jest.fn(() => undefined), +})); + +jest.mock('../../UI/Ramp/hooks/useRampNavigation', () => ({ + useRampNavigation: () => ({ goToBuy: jest.fn() }), +})); + +jest.mock('../../UI/MarketInsights/utils/marketInsightsFormatting', () => ({ + formatRelativeTime: jest.fn(() => 'now'), + getUniqueSourcesByFavicon: jest.fn(() => []), +})); + +jest.mock( + '../../UI/MarketInsights/components/SourceLogoGroup', + () => 'SourceLogoGroup', +); + +const mockUseWhatsHappening = jest.requireMock( + '../Homepage/Sections/WhatsHappening/hooks', +).useWhatsHappening; + +const mockNav = jest.requireMock('@react-navigation/native'); +const mockUseRoute = mockNav.useRoute as jest.Mock; +const mockUseNavigation = mockNav.useNavigation as jest.Mock; + +const mockItem = { + id: 'trend-0', + title: 'The Federal Reserve pauses interest rates', + description: 'Reflecting the current economy.', + date: '2026-03-15T10:00:00.000Z', + category: 'macro' as const, + impact: 'positive' as const, + relatedAssets: [], + articles: [], +}; + +describe('WhatsHappeningDetailView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRoute.mockReturnValue({ params: { initialIndex: 0 } }); + mockUseNavigation.mockReturnValue({ goBack: mockGoBack }); + + (WhatsHappeningExpandedCard as unknown as jest.Mock).mockImplementation( + ({ + onSourcesPress, + }: { + onSourcesPress?: (articles: unknown[]) => void; + }) => ( + + onSourcesPress?.([ + { + title: 'Test', + source: 'coindesk.com', + url: 'https://coindesk.com/test', + date: '2026-03-15T10:00:00.000Z', + }, + ]) + } + /> + ), + ); + + ( + WhatsHappeningSourcesBottomSheet as unknown as jest.Mock + ).mockImplementation(({ onClose }: { onClose: () => void }) => ( + + + + )); + }); + + it('renders the screen title', () => { + renderWithProvider(); + expect(screen.getByText("What's happening")).toBeOnTheScreen(); + }); + + it('renders skeleton carousel while loading', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [], + isLoading: true, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + expect( + screen.getByTestId('whats-happening-detail-skeleton'), + ).toBeOnTheScreen(); + expect(screen.queryByTestId('whats-happening-detail-carousel')).toBeNull(); + }); + + it('renders error state when fetch fails', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [], + isLoading: false, + error: 'Network error', + refresh: mockRefresh, + }); + renderWithProvider(); + expect(screen.getByText(/unable to load/i)).toBeOnTheScreen(); + expect(screen.queryByTestId('whats-happening-detail-carousel')).toBeNull(); + }); + + it('calls refresh when error retry button is pressed', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [], + isLoading: false, + error: 'Network error', + refresh: mockRefresh, + }); + renderWithProvider(); + fireEvent.press(screen.getByText('Retry')); + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + + it('renders the carousel with items when data is available', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + const carousel = screen.getByTestId('whats-happening-detail-carousel'); + expect(carousel).toBeOnTheScreen(); + // Simulate the carousel measuring its height so cards become visible + fireEvent(carousel, 'layout', { + nativeEvent: { layout: { height: 600, width: 375, x: 0, y: 0 } }, + }); + expect(screen.getByTestId('mock-expanded-card')).toBeOnTheScreen(); + }); + + it('does not show the skeleton or error when items are loaded', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + expect(screen.queryByTestId('whats-happening-detail-skeleton')).toBeNull(); + expect(screen.queryByText(/unable to load/i)).toBeNull(); + }); + + it('calls navigation.goBack when back button is pressed', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + fireEvent.press(screen.getByTestId('whats-happening-detail-back-button')); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('tracks Whats Happening Viewed once for the initial card on mount', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_VIEWED, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.WHATS_HAPPENING_VIEWED, + properties: expect.objectContaining({ + event_id: mockItem.id, + card_index: 0, + category: 'macro', + impact: 'positive', + asset_symbols: [], + }), + }), + ); + }); + + it('does not fire Viewed more than once for the initial card across re-renders', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + const { rerender } = renderWithProvider(); + rerender(); + const viewedCalls = mockCreateEventBuilder.mock.calls.filter( + ([name]) => + name === + (MetaMetricsEvents.WHATS_HAPPENING_VIEWED as unknown as string), + ); + expect(viewedCalls).toHaveLength(1); + }); + + it('tracks Whats Happening Viewed when scrolling to a new card', () => { + const secondItem = { + ...mockItem, + id: 'trend-1', + title: 'Second trend', + category: 'social' as const, + impact: 'negative' as const, + }; + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem, secondItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + const carousel = screen.getByTestId('whats-happening-detail-carousel'); + fireEvent(carousel, 'momentumScrollEnd', { + nativeEvent: { contentOffset: { x: SNAP_INTERVAL_FOR_TEST, y: 0 } }, + }); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_VIEWED, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.WHATS_HAPPENING_VIEWED, + properties: expect.objectContaining({ + event_id: 'trend-1', + card_index: 1, + category: 'social', + impact: 'negative', + }), + }), + ); + }); + + it('does not track Viewed when scroll resolves to same index', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + const carousel = screen.getByTestId('whats-happening-detail-carousel'); + fireEvent(carousel, 'momentumScrollEnd', { + nativeEvent: { contentOffset: { x: 0, y: 0 } }, + }); + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + }); + + it('tracks Whats Happening Closed with the visible card when back is pressed', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + fireEvent.press(screen.getByTestId('whats-happening-detail-back-button')); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_CLOSED, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.WHATS_HAPPENING_CLOSED, + properties: expect.objectContaining({ + event_id: mockItem.id, + card_index: 0, + }), + }), + ); + }); + + it('shows the sources bottom sheet when onSourcesPress is called from a card', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + const carousel = screen.getByTestId('whats-happening-detail-carousel'); + fireEvent(carousel, 'layout', { + nativeEvent: { layout: { height: 600, width: 375, x: 0, y: 0 } }, + }); + fireEvent.press(screen.getByTestId('mock-expanded-card')); + expect(screen.getByTestId('mock-sources-bottom-sheet')).toBeOnTheScreen(); + }); + + it('hides the sources bottom sheet when onClose is called', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + const carousel = screen.getByTestId('whats-happening-detail-carousel'); + fireEvent(carousel, 'layout', { + nativeEvent: { layout: { height: 600, width: 375, x: 0, y: 0 } }, + }); + fireEvent.press(screen.getByTestId('mock-expanded-card')); + expect(screen.getByTestId('mock-sources-bottom-sheet')).toBeOnTheScreen(); + fireEvent.press(screen.getByTestId('mock-sources-close')); + expect(screen.queryByTestId('mock-sources-bottom-sheet')).toBeNull(); + }); + + it('updates the active page indicator dot when the carousel is scrolled', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [ + mockItem, + { ...mockItem, id: 'trend-1' }, + { ...mockItem, id: 'trend-2' }, + ], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + const carousel = screen.getByTestId('whats-happening-detail-carousel'); + fireEvent(carousel, 'layout', { + nativeEvent: { layout: { height: 600, width: 375, x: 0, y: 0 } }, + }); + fireEvent(carousel, 'scroll', { + nativeEvent: { contentOffset: { x: 343, y: 0 } }, + }); + expect(screen.getByTestId('page-indicator-dot-active')).toBeOnTheScreen(); + const inactiveDots = screen.getAllByTestId('page-indicator-dot'); + expect(inactiveDots.length).toBe(2); + }); + + it('scrolls to initialIndex once content is wide enough', () => { + mockUseRoute.mockReturnValue({ params: { initialIndex: 1 } }); + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem, { ...mockItem, id: 'trend-1' }], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + const carousel = screen.getByTestId('whats-happening-detail-carousel'); + fireEvent(carousel, 'layout', { + nativeEvent: { layout: { height: 600, width: 375, x: 0, y: 0 } }, + }); + expect(() => + fireEvent(carousel, 'contentSizeChange', 700, 600), + ).not.toThrow(); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx new file mode 100644 index 00000000000..da3652aaaad --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx @@ -0,0 +1,283 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + Dimensions, + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + SafeAreaView, + ScrollView, +} from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + ButtonIcon, + ButtonIconSize, + FontWeight, + IconName, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { Article } from '@metamask/ai-controllers'; +import type { WhatsHappeningItem } from '../Homepage/Sections/WhatsHappening/types'; +import { strings } from '../../../../locales/i18n'; +import { useWhatsHappening } from '../Homepage/Sections/WhatsHappening/hooks'; +import { WhatsHappeningCardSkeleton } from '../Homepage/Sections/WhatsHappening/components'; +import { MAX_ITEMS_DISPLAYED } from '../Homepage/Sections/WhatsHappening/constants'; +import { getWhatsHappeningEventProps } from '../Homepage/Sections/WhatsHappening/eventProperties'; +import ErrorState from '../Homepage/components/ErrorState/ErrorState'; +import WhatsHappeningExpandedCard from './components/WhatsHappeningExpandedCard'; +import WhatsHappeningSourcesBottomSheet from './components/WhatsHappeningSourcesBottomSheet'; +import PageIndicator from './components/PageIndicator'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); + +const HORIZONTAL_PADDING = 16; +const GAP = 12; +export const CARD_WIDTH = SCREEN_WIDTH - HORIZONTAL_PADDING * 2 - GAP; +const SNAP_INTERVAL = CARD_WIDTH + GAP; + +const SKELETON_KEYS = Array.from( + { length: MAX_ITEMS_DISPLAYED }, + (_, i) => `skeleton-${i}`, +); + +interface WhatsHappeningDetailParams { + initialIndex: number; +} + +const WhatsHappeningDetailView = () => { + const navigation = useNavigation(); + const tw = useTailwind(); + const route = + useRoute>(); + + const initialIndex = route.params?.initialIndex ?? 0; + + const { items, isLoading, error, refresh } = + useWhatsHappening(MAX_ITEMS_DISPLAYED); + + const [currentIndex, setCurrentIndex] = useState(initialIndex); + const [cardHeight, setCardHeight] = useState(0); + const [sourcesContext, setSourcesContext] = useState<{ + articles: Article[]; + item: WhatsHappeningItem; + cardIndex: number; + } | null>(null); + const scrollViewRef = useRef(null); + const hasScrolledToInitial = useRef(false); + + const handleSourcesPress = useCallback( + ( + articles: Article[], + pressedItem: WhatsHappeningItem, + pressedIndex: number, + ) => { + setSourcesContext({ + articles, + item: pressedItem, + cardIndex: pressedIndex, + }); + }, + [], + ); + + const handleSourcesClose = useCallback(() => { + setSourcesContext(null); + }, []); + const hasTrackedViewRef = useRef(false); + const previousIndexRef = useRef(initialIndex); + const { trackEvent, createEventBuilder } = useAnalytics(); + + const handleCarouselLayout = useCallback((e: LayoutChangeEvent) => { + const { height } = e.nativeEvent.layout; + if (height > 0) setCardHeight(height); + }, []); + + const handleContentSizeChange = useCallback( + (contentWidth: number) => { + if ( + !hasScrolledToInitial.current && + initialIndex > 0 && + contentWidth > initialIndex * SNAP_INTERVAL && + scrollViewRef.current + ) { + hasScrolledToInitial.current = true; + scrollViewRef.current.scrollTo({ + x: initialIndex * SNAP_INTERVAL, + animated: false, + }); + } + }, + [initialIndex], + ); + + useEffect(() => { + if ( + !isLoading && + !hasTrackedViewRef.current && + items.length > 0 && + items[initialIndex] + ) { + hasTrackedViewRef.current = true; + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_VIEWED) + .addProperties( + getWhatsHappeningEventProps(items[initialIndex], initialIndex), + ) + .build(), + ); + } + }, [isLoading, items, initialIndex, trackEvent, createEventBuilder]); + + const handleBackPress = useCallback(() => { + const visible = items[currentIndex]; + if (visible) { + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_CLOSED) + .addProperties(getWhatsHappeningEventProps(visible, currentIndex)) + .build(), + ); + } + navigation.goBack(); + }, [navigation, items, currentIndex, trackEvent, createEventBuilder]); + + // Updates the dot indicator live during the drag. + // Flips at 20% visibility (bias = 0.8) — responsive without being erratic. + // No analytics here: mid-drag index changes are not reliable view signals. + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const offsetX = event.nativeEvent.contentOffset.x; + const index = Math.max( + 0, + Math.min(Math.floor(offsetX / SNAP_INTERVAL + 0.8), items.length - 1), + ); + setCurrentIndex(index); + }, + [items.length], + ); + + // Fires analytics once the carousel has fully settled on a card. + // onMomentumScrollEnd always fires with snapToInterval, giving the true + // final position — immune to mid-drag back-and-forth inflation. + const handleScrollEnd = useCallback( + (event: NativeSyntheticEvent) => { + const offsetX = event.nativeEvent.contentOffset.x; + const index = Math.max( + 0, + Math.min(Math.round(offsetX / SNAP_INTERVAL), items.length - 1), + ); + const prev = previousIndexRef.current; + if (index !== prev) { + const newItem = items[index]; + if (newItem) { + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_VIEWED) + .addProperties(getWhatsHappeningEventProps(newItem, index)) + .build(), + ); + } + previousIndexRef.current = index; + } + }, + [items, trackEvent, createEventBuilder], + ); + + const hasError = !isLoading && items.length === 0 && !!error; + + return ( + + + + + + {strings('homepage.sections.whats_happening')} + + + + + + + {isLoading ? ( + + {SKELETON_KEYS.map((key) => ( + + ))} + + ) : hasError ? ( + + ) : ( + <> + + {cardHeight > 0 && + items.map((item, index) => ( + + handleSourcesPress(articles, item, index) + } + /> + ))} + + + + + )} + + {sourcesContext && ( + + )} + + ); +}; + +export default WhatsHappeningDetailView; diff --git a/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx new file mode 100644 index 00000000000..381404af394 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/AssetRow.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { + AvatarToken, + AvatarTokenSize, + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + Button, + ButtonSize, + ButtonVariant, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { RelatedAsset } from '@metamask/ai-controllers'; +import { getRelatedAssetImageSource } from '../utils/getRelatedAssetImageSource'; + +interface AssetRowProps { + asset: RelatedAsset; + actionLabel: string; + accessibilityLabel: string; + onAction: () => void; +} + +/** + * Shared layout for a single asset row (logo + symbol + action button). + * Used by TokenRow (Buy/Trade) and PerpsRow (Trade); each wrapper supplies its + * own hook logic and passes the resolved label and handler here. + */ +const AssetRow: React.FC = ({ + asset, + actionLabel, + accessibilityLabel, + onAction, +}) => { + const rawImageSource = getRelatedAssetImageSource(asset); + const imageSource = Array.isArray(rawImageSource) + ? (rawImageSource[0] as { uri?: string } | undefined) + : (rawImageSource as number | { uri?: string } | undefined); + + return ( + + + + + + {asset.symbol} + + + + + + ); +}; + +export default AssetRow; diff --git a/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.test.tsx new file mode 100644 index 00000000000..8c7699f17d2 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { screen } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import PageIndicator from './PageIndicator'; + +describe('PageIndicator', () => { + it('renders nothing when count is 1', () => { + const { toJSON } = renderWithProvider( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('renders nothing when count is 0', () => { + const { toJSON } = renderWithProvider( + , + ); + expect(toJSON()).toBeNull(); + }); + + it('renders count dots when count > 1', () => { + renderWithProvider(); + const activeDots = screen.queryAllByTestId('page-indicator-dot-active'); + const inactiveDots = screen.queryAllByTestId('page-indicator-dot'); + expect(activeDots.length + inactiveDots.length).toBe(3); + }); + + it('marks the correct dot as active', () => { + renderWithProvider(); + expect(screen.queryAllByTestId('page-indicator-dot-active')).toHaveLength( + 1, + ); + expect(screen.queryAllByTestId('page-indicator-dot')).toHaveLength(2); + }); + + it('marks the first dot active when activeIndex is 0', () => { + renderWithProvider(); + expect(screen.queryAllByTestId('page-indicator-dot-active')).toHaveLength( + 1, + ); + expect(screen.queryAllByTestId('page-indicator-dot')).toHaveLength(3); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.tsx b/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.tsx new file mode 100644 index 00000000000..314d0b765c3 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/PageIndicator.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, +} from '@metamask/design-system-react-native'; + +interface PageIndicatorProps { + count: number; + activeIndex: number; +} + +const PageIndicator: React.FC = ({ + count, + activeIndex, +}) => { + const tw = useTailwind(); + + if (count <= 1) return null; + + return ( + + {Array.from({ length: count }, (_, index) => ( + + ))} + + ); +}; + +export default PageIndicator; diff --git a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx new file mode 100644 index 00000000000..15504e180f3 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import PerpsRow from './PerpsRow'; +import Routes from '../../../../constants/navigation/Routes'; +import type { RelatedAsset } from '@metamask/ai-controllers'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; +import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; + +const mockNavigate = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn((eventName: string) => ({ + addProperties: jest.fn((properties: Record) => ({ + build: jest.fn(() => ({ category: eventName, properties })), + })), + build: jest.fn(() => ({ category: eventName })), +})); + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ navigate: mockNavigate }), + }; +}); + +jest.mock('../utils/getRelatedAssetImageSource', () => ({ + getRelatedAssetImageSource: jest.fn(() => undefined), +})); + +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +const perpsOnlyAsset: RelatedAsset = { + sourceAssetId: 'tsla', + symbol: 'TSLA', + name: 'Tesla', + caip19: [], + hlPerpsMarket: ['xyz:TSLA'], +}; + +const dualAsset: RelatedAsset = { + sourceAssetId: 'bitcoin', + symbol: 'BTC', + name: 'Bitcoin', + caip19: ['eip155:1/slip44:0'], + hlPerpsMarket: ['BTC'], +}; + +const mockItem: WhatsHappeningItem = { + id: 'trend-3', + title: 'TSLA earnings', + description: '...', + date: '2026-03-15T10:00:00.000Z', + category: 'macro', + impact: 'positive', + relatedAssets: [perpsOnlyAsset], + articles: [], +}; + +describe('PerpsRow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the asset symbol', () => { + renderWithProvider( + , + ); + expect(screen.getByText('TSLA')).toBeOnTheScreen(); + }); + + it('renders the Trade button', () => { + renderWithProvider( + , + ); + expect(screen.getByText('Trade')).toBeOnTheScreen(); + }); + + it('navigates to PerpsMarketDetails with minimal market payload on Trade press', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Trade')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: expect.objectContaining({ + market: { symbol: 'xyz:TSLA', name: 'Tesla' }, + }), + }); + }); + + it('uses first hlPerpsMarket entry as the market symbol', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Trade')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: expect.objectContaining({ + market: { symbol: 'BTC', name: 'Bitcoin' }, + }), + }); + }); + + it('does not navigate when hlPerpsMarket is empty', () => { + const assetNoPerps: RelatedAsset = { + ...perpsOnlyAsset, + hlPerpsMarket: [], + }; + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Trade')); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('tracks Whats Happening Interaction with interaction_type=trade_pressed and asset details on Trade press', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Trade')); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, + properties: expect.objectContaining({ + interaction_type: 'trade_pressed', + asset_symbol: 'TSLA', + perps_market: 'xyz:TSLA', + event_id: 'trend-3', + card_index: 1, + category: 'macro', + impact: 'positive', + }), + }), + ); + }); + + it('does not track Interaction when hlPerpsMarket is empty', () => { + const assetNoPerps: RelatedAsset = { + ...perpsOnlyAsset, + hlPerpsMarket: [], + }; + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Trade')); + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx new file mode 100644 index 00000000000..6996cee0154 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx @@ -0,0 +1,61 @@ +import React, { useCallback } from 'react'; +import type { RelatedAsset } from '@metamask/ai-controllers'; +import { strings } from '../../../../../locales/i18n'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { WhatsHappeningInteractionType } from '../../Homepage/Sections/WhatsHappening/constants'; +import { getWhatsHappeningEventProps } from '../../Homepage/Sections/WhatsHappening/eventProperties'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; +import AssetRow from './AssetRow'; +import useTradeNavigation from '../hooks/useTradeNavigation'; + +interface PerpsRowProps { + asset: RelatedAsset; + item: WhatsHappeningItem; + cardIndex: number; +} + +/** + * A single row in the Perps section of the expanded What's Happening card. + * Displays the asset logo and symbol with a Trade button that navigates to + * the Perps market details view. Extracted as its own component so hooks can + * be called per-asset (hooks cannot be called inside a loop). + */ +const PerpsRow: React.FC = ({ asset, item, cardIndex }) => { + const { handleTrade } = useTradeNavigation(asset); + const { trackEvent, createEventBuilder } = useAnalytics(); + + const handleTradeWithTracking = useCallback(() => { + if (!asset.hlPerpsMarket?.[0]) return; + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_INTERACTION) + .addProperties({ + ...getWhatsHappeningEventProps(item, cardIndex), + interaction_type: WhatsHappeningInteractionType.TradePressed, + asset_symbol: asset.symbol, + perps_market: asset.hlPerpsMarket?.[0], + }) + .build(), + ); + handleTrade(); + }, [ + handleTrade, + asset.symbol, + asset.hlPerpsMarket, + item, + cardIndex, + trackEvent, + createEventBuilder, + ]); + + return ( + + ); +}; + +export default PerpsRow; diff --git a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx new file mode 100644 index 00000000000..9f2ae4a4df0 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import TokenRow from './TokenRow'; +import type { RelatedAsset } from '@metamask/ai-controllers'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; +import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; +import Routes from '../../../../constants/navigation/Routes'; + +const mockGoToBuy = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn((eventName: string) => ({ + addProperties: jest.fn((properties: Record) => ({ + build: jest.fn(() => ({ category: eventName, properties })), + })), + build: jest.fn(() => ({ category: eventName })), +})); + +const mockNavigate = jest.fn(); + +jest.mock('../../../UI/Ramp/hooks/useRampNavigation', () => ({ + useRampNavigation: () => ({ goToBuy: mockGoToBuy }), +})); + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ navigate: mockNavigate }), + }; +}); + +jest.mock('../utils/getRelatedAssetImageSource', () => ({ + getRelatedAssetImageSource: jest.fn(() => undefined), +})); + +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +const btcAsset: RelatedAsset = { + sourceAssetId: 'bitcoin', + symbol: 'BTC', + name: 'Bitcoin', + caip19: ['eip155:1/slip44:0'], +}; + +const dualAsset: RelatedAsset = { + sourceAssetId: 'eth', + symbol: 'ETH', + name: 'Ethereum', + caip19: ['eip155:1/slip44:60'], + hlPerpsMarket: ['ETH'], +}; + +const perpsOnlyAsset: RelatedAsset = { + sourceAssetId: 'tsla', + symbol: 'TSLA', + name: 'Tesla', + caip19: [], +}; + +const mockItem: WhatsHappeningItem = { + id: 'trend-2', + title: 'BTC ETF inflows', + description: '...', + date: '2026-03-15T10:00:00.000Z', + category: 'macro', + impact: 'positive', + relatedAssets: [btcAsset], + articles: [], +}; + +describe('TokenRow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when asset has only caip19 (no hlPerpsMarket)', () => { + it('renders the asset symbol', () => { + renderWithProvider( + , + ); + expect(screen.getByText('BTC')).toBeOnTheScreen(); + }); + + it('renders the Buy button', () => { + renderWithProvider( + , + ); + expect(screen.getByText('Buy')).toBeOnTheScreen(); + }); + + it('calls goToBuy with the first caip19 identifier on Buy press', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Buy')); + expect(mockGoToBuy).toHaveBeenCalledWith({ + assetId: 'eip155:1/slip44:0', + }); + }); + }); + + it('calls goToBuy with assetId undefined when caip19 is empty', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Buy')); + expect(mockGoToBuy).toHaveBeenCalledWith({ assetId: undefined }); + }); + + describe('when asset has hlPerpsMarket (dual asset)', () => { + it('renders the Trade button instead of Buy', () => { + renderWithProvider( + , + ); + expect(screen.getByText('Trade')).toBeOnTheScreen(); + expect(screen.queryByText('Buy')).toBeNull(); + }); + + it('navigates to Perps market details on Trade press', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Trade')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: expect.objectContaining({ + market: { symbol: 'ETH', name: 'Ethereum' }, + }), + }); + }); + + it('does not call goToBuy when Trade is pressed', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Trade')); + expect(mockGoToBuy).not.toHaveBeenCalled(); + }); + }); + + it('tracks Whats Happening Interaction with interaction_type=buy_pressed and asset details on Buy press', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Buy')); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, + properties: expect.objectContaining({ + interaction_type: 'buy_pressed', + asset_symbol: 'BTC', + asset_caip19: 'eip155:1/slip44:0', + event_id: 'trend-2', + card_index: 2, + category: 'macro', + impact: 'positive', + }), + }), + ); + }); + + it('tracks Interaction without asset_caip19 when caip19 is empty', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Buy')); + const addPropertiesCall = mockCreateEventBuilder.mock.results[0]?.value + ?.addProperties as jest.Mock | undefined; + const builtProperties = addPropertiesCall?.mock?.calls?.[0]?.[0] as + | Record + | undefined; + expect(builtProperties?.interaction_type).toBe('buy_pressed'); + expect(builtProperties?.asset_symbol).toBe('TSLA'); + expect(builtProperties).not.toHaveProperty('asset_caip19'); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx new file mode 100644 index 00000000000..4a29ff051fc --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx @@ -0,0 +1,97 @@ +import React, { useCallback } from 'react'; +import type { RelatedAsset } from '@metamask/ai-controllers'; +import { strings } from '../../../../../locales/i18n'; +import { useRampNavigation } from '../../../UI/Ramp/hooks/useRampNavigation'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { WhatsHappeningInteractionType } from '../../Homepage/Sections/WhatsHappening/constants'; +import { getWhatsHappeningEventProps } from '../../Homepage/Sections/WhatsHappening/eventProperties'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; +import AssetRow from './AssetRow'; +import useTradeNavigation from '../hooks/useTradeNavigation'; + +interface TokenRowProps { + asset: RelatedAsset; + item: WhatsHappeningItem; + cardIndex: number; +} + +/** + * A single row in the Tokens section of the expanded What's Happening card. + * Shows a Trade button (navigating to Perps) when the asset has an + * `hlPerpsMarket` entry; otherwise falls back to a Buy button that opens the + * Ramp buy flow. Extracted as its own component so hooks can be called + * per-asset (hooks cannot be called inside a loop). + */ +const TokenRow: React.FC = ({ asset, item, cardIndex }) => { + const { goToBuy } = useRampNavigation(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const { handleTrade, canTrade } = useTradeNavigation(asset); + + const handleTradeWithTracking = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_INTERACTION) + .addProperties({ + ...getWhatsHappeningEventProps(item, cardIndex), + interaction_type: WhatsHappeningInteractionType.TradePressed, + asset_symbol: asset.symbol, + perps_market: asset.hlPerpsMarket?.[0], + }) + .build(), + ); + handleTrade(); + }, [ + handleTrade, + asset.symbol, + asset.hlPerpsMarket, + item, + cardIndex, + trackEvent, + createEventBuilder, + ]); + + const handleBuy = useCallback(() => { + const assetId = asset.caip19?.[0]; + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_INTERACTION) + .addProperties({ + ...getWhatsHappeningEventProps(item, cardIndex), + interaction_type: WhatsHappeningInteractionType.BuyPressed, + asset_symbol: asset.symbol, + ...(assetId ? { asset_caip19: assetId } : {}), + }) + .build(), + ); + goToBuy({ assetId }); + }, [ + goToBuy, + asset.caip19, + asset.symbol, + item, + cardIndex, + trackEvent, + createEventBuilder, + ]); + + if (canTrade) { + return ( + + ); + } + + return ( + + ); +}; + +export default TokenRow; diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx new file mode 100644 index 00000000000..9a687b83e32 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx @@ -0,0 +1,298 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import WhatsHappeningExpandedCard from './WhatsHappeningExpandedCard'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; +import Routes from '../../../../constants/navigation/Routes'; + +const mockNavigate = jest.fn(); +const mockGoToBuy = jest.fn(); + +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: jest.fn(), + createEventBuilder: jest.fn((eventName: string) => ({ + addProperties: jest.fn(() => ({ + build: jest.fn(() => ({ category: eventName })), + })), + build: jest.fn(() => ({ category: eventName })), + })), + }), +})); + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ navigate: mockNavigate }), + }; +}); + +jest.mock('../utils/getRelatedAssetImageSource', () => ({ + getRelatedAssetImageSource: jest.fn(() => undefined), +})); + +jest.mock('../../../UI/Ramp/hooks/useRampNavigation', () => ({ + useRampNavigation: () => ({ goToBuy: mockGoToBuy }), +})); + +jest.mock('../../../UI/MarketInsights/utils/marketInsightsFormatting', () => ({ + formatRelativeTime: jest.fn(() => 'now'), + getUniqueSourcesByFavicon: jest.fn(() => []), +})); + +jest.mock( + '../../../UI/MarketInsights/components/SourceLogoGroup', + () => 'SourceLogoGroup', +); + +const CARD_WIDTH = 320; +const CARD_HEIGHT = 600; + +const tokenAsset = { + sourceAssetId: 'bitcoin', + symbol: 'BTC', + name: 'Bitcoin', + caip19: ['eip155:1/slip44:0'], + hlPerpsMarket: undefined, +}; + +const perpsOnlyAsset = { + sourceAssetId: 'tsla', + symbol: 'TSLA', + name: 'Tesla', + caip19: [], + hlPerpsMarket: ['xyz:TSLA'], +}; + +const dualAsset = { + sourceAssetId: 'eth', + symbol: 'ETH', + name: 'Ethereum', + caip19: ['eip155:1/slip44:60'], + hlPerpsMarket: ['ETH'], +}; + +const baseItem: WhatsHappeningItem = { + id: 'trend-0', + title: 'The Federal Reserve pauses interest rates', + description: 'Reflecting the current economy.', + date: '2026-03-15T10:00:00.000Z', + category: 'macro', + impact: 'positive', + relatedAssets: [], + articles: [], +}; + +describe('WhatsHappeningExpandedCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the title and description', () => { + renderWithProvider( + , + ); + expect(screen.getByText(baseItem.title)).toBeOnTheScreen(); + expect(screen.getByText(baseItem.description)).toBeOnTheScreen(); + }); + + it('renders the impact badge for positive impact', () => { + renderWithProvider( + , + ); + expect(screen.getByText('Bullish')).toBeOnTheScreen(); + }); + + it('renders Neutral badge when impact is explicitly neutral', () => { + const item = { ...baseItem, impact: 'neutral' as const }; + renderWithProvider( + , + ); + expect(screen.getByText('Neutral')).toBeOnTheScreen(); + }); + + it('does not render an impact badge when impact is undefined', () => { + const item = { ...baseItem, impact: undefined }; + renderWithProvider( + , + ); + expect(screen.queryByText('Neutral')).toBeNull(); + expect(screen.queryByText('Bullish')).toBeNull(); + expect(screen.queryByText('Bearish')).toBeNull(); + }); + + it('renders Tokens section when assets have caip19', () => { + const item = { ...baseItem, relatedAssets: [tokenAsset] }; + renderWithProvider( + , + ); + expect(screen.getByText('Tokens')).toBeOnTheScreen(); + expect(screen.getByText('BTC')).toBeOnTheScreen(); + expect(screen.getByText('Buy')).toBeOnTheScreen(); + }); + + it('does not render Tokens section when no assets have caip19', () => { + const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; + renderWithProvider( + , + ); + expect(screen.queryByText('Tokens')).toBeNull(); + expect(screen.queryByText('Buy')).toBeNull(); + }); + + it('renders Perps section when assets have hlPerpsMarket', () => { + const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; + renderWithProvider( + , + ); + expect(screen.getByText('Perps')).toBeOnTheScreen(); + expect(screen.getByText('TSLA')).toBeOnTheScreen(); + expect(screen.getByText('Trade')).toBeOnTheScreen(); + }); + + it('does not render Perps section when no assets have hlPerpsMarket', () => { + const item = { ...baseItem, relatedAssets: [tokenAsset] }; + renderWithProvider( + , + ); + expect(screen.queryByText('Perps')).toBeNull(); + expect(screen.queryByText('Trade')).toBeNull(); + }); + + it('renders both Tokens and Perps sections when there are separate token and perps-only assets', () => { + const item = { ...baseItem, relatedAssets: [tokenAsset, perpsOnlyAsset] }; + renderWithProvider( + , + ); + expect(screen.getByText('Tokens')).toBeOnTheScreen(); + expect(screen.getByText('Perps')).toBeOnTheScreen(); + expect(screen.getByText('Buy')).toBeOnTheScreen(); + expect(screen.getByText('Trade')).toBeOnTheScreen(); + }); + + it('does not duplicate a dual asset (caip19 + hlPerpsMarket) into the Perps section, shows Trade for the token row', () => { + const item = { ...baseItem, relatedAssets: [dualAsset] }; + renderWithProvider( + , + ); + expect(screen.getByText('Tokens')).toBeOnTheScreen(); + expect(screen.getByText('Trade')).toBeOnTheScreen(); + expect(screen.queryByText('Buy')).toBeNull(); + expect(screen.queryByText('Perps')).toBeNull(); + }); + + it('renders neither section when relatedAssets is empty', () => { + renderWithProvider( + , + ); + expect(screen.queryByText('Tokens')).toBeNull(); + expect(screen.queryByText('Perps')).toBeNull(); + }); + + it('Trade button navigates to PerpsMarketDetails', () => { + const item = { ...baseItem, relatedAssets: [perpsOnlyAsset] }; + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Trade')); + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: expect.objectContaining({ + market: { symbol: 'xyz:TSLA', name: 'Tesla' }, + }), + }); + }); + + it('calls onSourcesPress with the item articles when the sources footer is pressed', () => { + const mockOnSourcesPress = jest.fn(); + const article = { + title: 'Test article', + source: 'coindesk.com', + url: 'https://coindesk.com/test', + date: '2026-03-15T10:00:00.000Z', + }; + const item = { ...baseItem, articles: [article] }; + + // Override mock so the sources footer is rendered + const { getUniqueSourcesByFavicon } = jest.requireMock( + '../../../UI/MarketInsights/utils/marketInsightsFormatting', + ); + (getUniqueSourcesByFavicon as jest.Mock).mockReturnValueOnce([ + { name: 'coindesk.com', type: 'news', url: 'https://coindesk.com' }, + ]); + + renderWithProvider( + , + ); + + fireEvent.press(screen.getByText('coindesk.com')); + expect(mockOnSourcesPress).toHaveBeenCalledWith([article]); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx new file mode 100644 index 00000000000..10fe6ebfb3b --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx @@ -0,0 +1,214 @@ +import React, { useMemo } from 'react'; +import { Pressable, ScrollView } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxAlignItems, + BoxFlexDirection, + BoxJustifyContent, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { Article, MarketInsightsSource } from '@metamask/ai-controllers'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; +import { strings } from '../../../../../locales/i18n'; +import { + getImpactLabel, + getImpactBackgroundClass, + getImpactTextColor, +} from '../../Homepage/Sections/WhatsHappening/util/impact'; +import { + formatRelativeTime, + getUniqueSourcesByFavicon, +} from '../../../UI/MarketInsights/utils/marketInsightsFormatting'; +import SourceLogoGroup from '../../../UI/MarketInsights/components/SourceLogoGroup'; +import PerpsRow from './PerpsRow'; +import TokenRow from './TokenRow'; + +interface WhatsHappeningExpandedCardProps { + item: WhatsHappeningItem; + cardIndex: number; + cardWidth: number; + /** Height of the carousel container — used to give every card the same fixed height. */ + cardHeight: number; + /** + * Called when the user taps the sources footer row. The parent is responsible + * for rendering the bottom sheet so it is anchored to the screen root rather + * than the card's positioning context. + */ + onSourcesPress?: (articles: Article[]) => void; +} + +const WhatsHappeningExpandedCard: React.FC = ({ + item, + cardIndex, + cardWidth, + cardHeight, + onSourcesPress, +}) => { + const tw = useTailwind(); + + const impactLabel = getImpactLabel(item.impact); + const impactBgClass = getImpactBackgroundClass(item.impact); + const impactTextColor = getImpactTextColor(item.impact); + + const uniqueSources = useMemo(() => { + const sources: MarketInsightsSource[] = item.articles.map((article) => ({ + name: article.source, + type: 'news' as const, + url: article.url || article.source, + })); + return getUniqueSourcesByFavicon(sources); + }, [item.articles]); + + const sourceLabel = useMemo(() => { + const first = uniqueSources[0]; + if (!first) return null; + const remaining = Math.max(0, uniqueSources.length - 1); + return remaining > 0 ? `${first.name} +${remaining}` : first.name; + }, [uniqueSources]); + + return ( + + {/* Card surface — fills the fixed height so all cards are the same size */} + + {/* Scrollable main content */} + + {/* Impact badge */} + {item.impact && ( + + + {impactLabel} + + + )} + + {/* Title */} + + {item.title} + + + {/* Description */} + {item.description && ( + + {item.description} + + )} + + {/* Tokens section */} + {item.relatedAssets.some((asset) => asset.caip19?.length) && ( + + + {strings('homepage.sections.tokens')} + + + {item.relatedAssets + .filter((asset) => asset.caip19?.length) + .map((asset) => ( + + ))} + + )} + + {/* Perps section — only assets that are perps-only (hlPerpsMarket set, no caip19 token) */} + {item.relatedAssets.some( + (asset) => asset.hlPerpsMarket?.length && !asset.caip19?.length, + ) && ( + + + {strings('homepage.sections.perps')} + + + {item.relatedAssets + .filter( + (asset) => + asset.hlPerpsMarket?.length && !asset.caip19?.length, + ) + .map((asset) => ( + + ))} + + )} + + + {/* Fixed sources footer — always pinned to the bottom of the card */} + {uniqueSources.length > 0 && ( + + + + onSourcesPress?.(item.articles)} + accessibilityRole="button" + > + {({ pressed }) => ( + + + + {sourceLabel ? ( + + {sourceLabel} + + ) : null} + + + {item.date ? ( + + {formatRelativeTime(item.date, { nowLabel: 'now' })} + + ) : null} + + )} + + + )} + + + ); +}; + +export default WhatsHappeningExpandedCard; diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.test.tsx new file mode 100644 index 00000000000..9be1cf1efa9 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.test.tsx @@ -0,0 +1,216 @@ +import React from 'react'; +import { Linking } from 'react-native'; +import { screen, fireEvent } from '@testing-library/react-native'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import WhatsHappeningSourcesBottomSheet from './WhatsHappeningSourcesBottomSheet'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; +import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn((eventName: string) => ({ + addProperties: jest.fn((properties: Record) => ({ + build: jest.fn(() => ({ category: eventName, properties })), + })), + build: jest.fn(() => ({ category: eventName })), +})); + +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +jest.mock( + '../../../../component-library/components/BottomSheets/BottomSheet', + () => { + const ReactLib = jest.requireActual('react'); + const { View: MockView } = jest.requireActual('react-native'); + + return ReactLib.forwardRef( + ( + { children }: { children: React.ReactNode }, + ref: React.Ref<{ + onOpenBottomSheet: () => void; + onCloseBottomSheet: () => void; + }>, + ) => { + ReactLib.useImperativeHandle(ref, () => ({ + onOpenBottomSheet: jest.fn(), + onCloseBottomSheet: jest.fn(), + })); + return {children}; + }, + ); + }, +); + +jest.mock( + '../../../../component-library/components/BottomSheets/BottomSheetHeader', + () => { + const { View: MockView } = jest.requireActual('react-native'); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); + }, +); + +jest.mock('../../../UI/MarketInsights/utils/marketInsightsFormatting', () => ({ + isSafeUrl: jest.fn(() => true), + formatRelativeTime: jest.fn(() => '2h ago'), + getFaviconUrl: jest.fn((url: string) => `https://favicon/${url}`), +})); + +const mockOpenURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); +const mockIsSafeUrl = jest.requireMock( + '../../../UI/MarketInsights/utils/marketInsightsFormatting', +).isSafeUrl; + +const articles = [ + { + title: 'Fed pauses rate hikes', + url: 'https://coindesk.com/fed-pauses', + source: 'coindesk.com', + date: '2026-03-15T10:00:00.000Z', + }, + { + title: 'Bitcoin ETF sees record inflows', + url: 'https://cointelegraph.com/btc-etf', + source: 'cointelegraph.com', + date: '2026-03-15T09:00:00.000Z', + }, +]; + +const mockItem: WhatsHappeningItem = { + id: 'trend-7', + title: 'Fed pauses rates', + description: '...', + date: '2026-03-15T10:00:00.000Z', + category: 'macro', + impact: 'positive', + relatedAssets: [ + { + sourceAssetId: 'btc', + symbol: 'BTC', + name: 'Bitcoin', + caip19: ['eip155:1/slip44:0'], + }, + ], + articles: articles as never, +}; + +describe('WhatsHappeningSourcesBottomSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsSafeUrl.mockReturnValue(true); + }); + + it('renders one row per article', () => { + renderWithProvider( + , + ); + expect(screen.getByText('coindesk.com')).toBeOnTheScreen(); + expect(screen.getByText('cointelegraph.com')).toBeOnTheScreen(); + }); + + it('opens the article URL when a row is pressed and URL is safe', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('coindesk.com')); + expect(mockOpenURL).toHaveBeenCalledWith('https://coindesk.com/fed-pauses'); + }); + + it('does not open the URL when isSafeUrl returns false', () => { + mockIsSafeUrl.mockReturnValue(false); + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('coindesk.com')); + expect(mockOpenURL).not.toHaveBeenCalled(); + }); + + it('renders the sheet title', () => { + renderWithProvider( + , + ); + expect(screen.getByText('News sources')).toBeOnTheScreen(); + }); + + it('renders no article rows when articles array is empty', () => { + renderWithProvider( + , + ); + expect(screen.queryByText('coindesk.com')).toBeNull(); + }); + + it('tracks Whats Happening Interaction (source_click) with the article URL on row press', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('coindesk.com')); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, + properties: expect.objectContaining({ + interaction_type: 'source_click', + source: 'https://coindesk.com/fed-pauses', + event_id: 'trend-7', + card_index: 3, + category: 'macro', + impact: 'positive', + asset_symbols: ['BTC'], + }), + }), + ); + }); + + it('still tracks the source_click interaction even when the URL is unsafe', () => { + mockIsSafeUrl.mockReturnValue(false); + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('coindesk.com')); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, + ); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.tsx new file mode 100644 index 00000000000..d9c2561cd6c --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.tsx @@ -0,0 +1,86 @@ +import React, { useCallback, useRef } from 'react'; +import { Linking } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + FontWeight, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { Article } from '@metamask/ai-controllers'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { strings } from '../../../../../locales/i18n'; +import ArticleRow from '../../../UI/MarketInsights/components/ArticleRow'; +import { isSafeUrl } from '../../../UI/MarketInsights/utils/marketInsightsFormatting'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { WhatsHappeningInteractionType } from '../../Homepage/Sections/WhatsHappening/constants'; +import { getWhatsHappeningEventProps } from '../../Homepage/Sections/WhatsHappening/eventProperties'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; + +interface WhatsHappeningSourcesBottomSheetProps { + onClose: () => void; + articles: Article[]; + item: WhatsHappeningItem; + cardIndex: number; +} + +const WhatsHappeningSourcesBottomSheet: React.FC< + WhatsHappeningSourcesBottomSheetProps +> = ({ onClose, articles, item, cardIndex }) => { + const tw = useTailwind(); + const bottomSheetRef = useRef(null); + const { trackEvent, createEventBuilder } = useAnalytics(); + + const handleSourcePress = useCallback( + (url: string) => { + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_INTERACTION) + .addProperties({ + ...getWhatsHappeningEventProps(item, cardIndex), + interaction_type: WhatsHappeningInteractionType.SourceClick, + source: url, + }) + .build(), + ); + if (isSafeUrl(url)) { + Linking.openURL(url); + } + }, + [item, cardIndex, trackEvent, createEventBuilder], + ); + + return ( + + + + {strings('market_insights.sources_title')} + + + + + {articles.map((article, index) => ( + + ))} + + + ); +}; + +export default WhatsHappeningSourcesBottomSheet; diff --git a/app/components/Views/WhatsHappeningDetailView/components/index.ts b/app/components/Views/WhatsHappeningDetailView/components/index.ts new file mode 100644 index 00000000000..fbcca56e40d --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/components/index.ts @@ -0,0 +1,2 @@ +export { default as WhatsHappeningExpandedCard } from './WhatsHappeningExpandedCard'; +export { default as PageIndicator } from './PageIndicator'; diff --git a/app/components/Views/WhatsHappeningDetailView/hooks/useTradeNavigation.ts b/app/components/Views/WhatsHappeningDetailView/hooks/useTradeNavigation.ts new file mode 100644 index 00000000000..10fb8ed034c --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/hooks/useTradeNavigation.ts @@ -0,0 +1,39 @@ +import { useCallback } from 'react'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { PERPS_EVENT_VALUE } from '@metamask/perps-controller'; +import type { RelatedAsset } from '@metamask/ai-controllers'; +import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation'; +import Routes from '../../../../constants/navigation/Routes'; + +interface UseTradeNavigationResult { + /** Navigate to the Perps market details view. No-op when `canTrade` is false. */ + handleTrade: () => void; + /** True when the asset has at least one `hlPerpsMarket` entry. */ + canTrade: boolean; +} + +/** + * Provides a stable `handleTrade` callback and a `canTrade` flag for an asset. + * `handleTrade` is always a valid function — it is a no-op when the asset has + * no `hlPerpsMarket` entry. Use `canTrade` to decide whether to show a Trade + * button at all. + */ +const useTradeNavigation = (asset: RelatedAsset): UseTradeNavigationResult => { + const navigation = useNavigation>(); + const hlPerpsMarket = asset.hlPerpsMarket?.[0]; + + const handleTrade = useCallback(() => { + if (!hlPerpsMarket) return; + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market: { symbol: hlPerpsMarket, name: asset.name }, + source: PERPS_EVENT_VALUE.SOURCE.HOME_SECTION, + }, + }); + }, [navigation, hlPerpsMarket, asset.name]); + + return { handleTrade, canTrade: Boolean(hlPerpsMarket) }; +}; + +export default useTradeNavigation; diff --git a/app/components/Views/WhatsHappeningDetailView/index.ts b/app/components/Views/WhatsHappeningDetailView/index.ts new file mode 100644 index 00000000000..b105def8161 --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/index.ts @@ -0,0 +1 @@ +export { default } from './WhatsHappeningDetailView'; diff --git a/app/components/Views/WhatsHappeningDetailView/utils/getRelatedAssetImageSource.test.ts b/app/components/Views/WhatsHappeningDetailView/utils/getRelatedAssetImageSource.test.ts new file mode 100644 index 00000000000..4a47ffb81fd --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/utils/getRelatedAssetImageSource.test.ts @@ -0,0 +1,152 @@ +import { getRelatedAssetImageSource } from './getRelatedAssetImageSource'; + +// Mock image requires as numbers (React Native bundler assigns numbers to require() results) +jest.mock('../../../../images/image-icons', () => ({ + __esModule: true, + default: { + BTC: 100, + ETH: 123, + TRX: 456, + SOL: 789, + SVG_ICON: () => null, + STRING_ICON: 'string-path', + }, +})); + +const PERPS_ICONS_BASE = + 'https://raw.githubusercontent.com/MetaMask/metamask-perps-assets/main/icons/'; + +jest.mock('../../../UI/Perps/utils/marketUtils', () => ({ + getAssetIconUrls: jest.fn((symbol: string) => { + if (!symbol) return null; + if (symbol.includes(':')) { + const [dex, assetSymbol] = symbol.split(':'); + return { + primary: `${PERPS_ICONS_BASE}hip3:${dex.toLowerCase()}_${assetSymbol.toUpperCase()}.svg`, + fallback: `${PERPS_ICONS_BASE}${dex.toLowerCase()}:${assetSymbol.toUpperCase()}.svg`, + }; + } + return { + primary: `${PERPS_ICONS_BASE}${symbol.toUpperCase()}.svg`, + fallback: `${PERPS_ICONS_BASE}${symbol.toUpperCase()}.svg`, + }; + }), +})); + +jest.mock( + '../../../UI/Perps/components/PerpsTokenLogo/PerpsAssetBgConfig', + () => ({ + K_PREFIX_ASSETS: new Set(['KPEPE', 'KBONK']), + }), +); + +describe('getRelatedAssetImageSource', () => { + describe('CAIP-19 path (highest priority — regular crypto tokens)', () => { + it('uses bundled icon when symbol matches image-icons', () => { + const result = getRelatedAssetImageSource({ + name: 'Ethereum', + symbol: 'ETH', + caip19: ['eip155:1/slip44:60'], + sourceAssetId: 'ethereum', + hlPerpsMarket: ['ETH'], // present but must NOT trigger Perps SVG path + }); + + expect(result).toBe(123); + expect(typeof result).toBe('number'); // bundled PNG, not an SVG URI object + }); + + it('ignores hlPerpsMarket when caip19 is populated', () => { + // BTC has hlPerpsMarket but also has caip19 — must use CAIP-19 path + const result = getRelatedAssetImageSource({ + name: 'Bitcoin', + symbol: 'BTC', + caip19: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], + sourceAssetId: 'bitcoin', + hlPerpsMarket: ['BTC'], + }); + + // Bundled BTC icon, not a Perps SVG URI + expect(result).toBe(100); + expect(typeof result).toBe('number'); + }); + + it('returns wallet CDN URI when symbol has no bundled icon', () => { + const result = getRelatedAssetImageSource({ + name: 'Some Token', + symbol: 'UNKNOWN', + caip19: ['eip155:1/erc20:0xABCDEF'], + sourceAssetId: 'some-token', + }); + + expect(result).toEqual({ + uri: expect.stringContaining('static.cx.metamask.io'), + }); + }); + }); + + describe('Perps path via hlPerpsMarket (only when caip19 is empty)', () => { + it('uses Perps primary SVG for a plain HL market id', () => { + // Hypothetical BTC with no caip19 (purely Perps context) + const result = getRelatedAssetImageSource({ + name: 'Bitcoin', + symbol: 'BTC', + caip19: [], + sourceAssetId: 'bitcoin', + hlPerpsMarket: ['BTC'], + }); + + expect(result).toEqual({ uri: `${PERPS_ICONS_BASE}BTC.svg` }); + }); + + it('uses Perps primary SVG for HIP-3 synthetic asset (xyz:TSLA format)', () => { + const result = getRelatedAssetImageSource({ + name: 'Tesla', + symbol: 'TSLA', + caip19: [], + sourceAssetId: 'tsla', + hlPerpsMarket: ['xyz:TSLA'], + }); + + expect(result).toEqual({ + uri: `${PERPS_ICONS_BASE}hip3:xyz_TSLA.svg`, + }); + }); + + it('skips Perps path when caip19 is populated even if hlPerpsMarket is set', () => { + const result = getRelatedAssetImageSource({ + name: 'Ethereum', + symbol: 'ETH', + caip19: ['eip155:1/slip44:60'], + sourceAssetId: 'ethereum', + hlPerpsMarket: ['ETH'], + }); + + // Must be bundled PNG, not SVG URI + expect(typeof result).toBe('number'); + }); + }); + + describe('symbol-only fallback', () => { + it('returns bundled icon by symbol when caip19 is empty and no hlPerpsMarket', () => { + const result = getRelatedAssetImageSource({ + name: 'Ethereum', + symbol: 'ETH', + caip19: [], + sourceAssetId: 'ethereum', + }); + + expect(result).toBe(123); + }); + + it('returns undefined when no caip19, no hlPerpsMarket, and unknown symbol', () => { + const result = getRelatedAssetImageSource({ + name: 'Some Token', + symbol: 'UNKNOWN', + caip19: [], + sourceAssetId: 'some-token', + }); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/utils/getRelatedAssetImageSource.ts b/app/components/Views/WhatsHappeningDetailView/utils/getRelatedAssetImageSource.ts new file mode 100644 index 00000000000..ff652ffd33b --- /dev/null +++ b/app/components/Views/WhatsHappeningDetailView/utils/getRelatedAssetImageSource.ts @@ -0,0 +1,49 @@ +import type { RelatedAsset } from '@metamask/ai-controllers'; +import { isNonEvmChainId } from '@metamask/bridge-controller'; +import { CaipAssetType, parseCaipAssetType } from '@metamask/utils'; +import type { ImageSourcePropType } from 'react-native'; +import { getTokenIconUrl, getTokenImageSource } from '../../../UI/Bridge/utils'; +import { getAssetIconUrls } from '../../../UI/Perps/utils/marketUtils'; +import { K_PREFIX_ASSETS } from '../../../UI/Perps/components/PerpsTokenLogo/PerpsAssetBgConfig'; + +/** + * Image source for a market-overview `RelatedAsset`. + * + * Resolution order: CAIP-19 wallet CDN + bundled PNG (regular crypto tokens) → + * Perps SVG via `hlPerpsMarket` when `caip19` is empty (synthetic-only assets + * like `xyz:TSLA`) → bundled icon by symbol. + * + * `hlPerpsMarket` is NOT consulted when `caip19` is populated because regular + * crypto tokens (BTC, ETH) carry it too, and Perps CDN only serves SVGs that + * `AvatarToken` cannot render remotely. + */ +export const getRelatedAssetImageSource = ( + asset: RelatedAsset, +): ImageSourcePropType | undefined => { + // 1. Wallet CDN via CAIP-19 (PNG — works with AvatarToken) + const firstCaip = asset.caip19?.[0]; + if (firstCaip) { + try { + const { chainId } = parseCaipAssetType(firstCaip as CaipAssetType); + const cdnUrl = getTokenIconUrl( + firstCaip as CaipAssetType, + isNonEvmChainId(chainId), + ); + return getTokenImageSource(asset.symbol, cdnUrl); + } catch { + // Invalid or unsupported CAIP-19 string — fall through + } + } + + // 2. Perps SVG for assets with no CAIP-19 (e.g. xyz:TSLA via hlPerpsMarket) + const firstHlPerpsMarket = asset.hlPerpsMarket?.[0]; + if (firstHlPerpsMarket && !asset.caip19?.length) { + const urls = getAssetIconUrls(firstHlPerpsMarket, K_PREFIX_ASSETS); + if (urls) { + return { uri: urls.primary }; + } + } + + // 3. Bundled icons only (symbol lookup) + return getTokenImageSource(asset.symbol, undefined); +}; diff --git a/app/components/Views/confirmations/ConfirmationView.testIds.ts b/app/components/Views/confirmations/ConfirmationView.testIds.ts index 2b147feb4bc..eedd5f5a585 100644 --- a/app/components/Views/confirmations/ConfirmationView.testIds.ts +++ b/app/components/Views/confirmations/ConfirmationView.testIds.ts @@ -55,6 +55,10 @@ export const ConfirmationFooterSelectorIDs = { CONFIRM_BUTTON: 'confirm-button', } as const; +export const ConfirmationLoaderSelectorIDs = { + TRANSFER: 'confirm-loader-transfer', +} as const; + export const ConfirmAlertModalSelectorsIDs = { CONFIRM_ALERT_CHECKBOX: 'confirm-alert-checkbox', CONFIRM_ALERT_BUTTON: 'confirm-alert-confirm-button', diff --git a/app/components/Views/confirmations/__mocks__/controllers/other-controllers-mock.ts b/app/components/Views/confirmations/__mocks__/controllers/other-controllers-mock.ts index b70878397ea..a420d225105 100644 --- a/app/components/Views/confirmations/__mocks__/controllers/other-controllers-mock.ts +++ b/app/components/Views/confirmations/__mocks__/controllers/other-controllers-mock.ts @@ -234,6 +234,16 @@ export const gasFeeControllerMock = { }, }; +export const moneyAccountControllerMock = { + engine: { + backgroundState: { + MoneyAccountController: { + moneyAccounts: {}, + }, + }, + }, +}; + export const predictControllerMock = { engine: { backgroundState: { @@ -400,5 +410,6 @@ export const otherControllersMock = merge( tokenRatesControllerMock, tokensControllerMock, gasFeeControllerMock, + moneyAccountControllerMock, predictControllerMock, ); diff --git a/app/components/Views/confirmations/components/AccountSelector/AccountSelector.styles.ts b/app/components/Views/confirmations/components/AccountSelector/AccountSelector.styles.ts index 8ead9380d40..cd8bedab1ee 100644 --- a/app/components/Views/confirmations/components/AccountSelector/AccountSelector.styles.ts +++ b/app/components/Views/confirmations/components/AccountSelector/AccountSelector.styles.ts @@ -8,8 +8,6 @@ const stylesheet = (params: { theme: Theme }) => { container: { paddingVertical: 12, paddingHorizontal: 8, - borderBottomWidth: 1, - borderBottomColor: theme.colors.border.muted, }, row: { flexDirection: 'row', @@ -22,18 +20,14 @@ const stylesheet = (params: { theme: Theme }) => { gap: 8, flexShrink: 1, }, - modalContainer: { + /** Full-screen wrapper for transparent Modal so BottomSheet can fill the window. */ + modalRoot: { flex: 1, - backgroundColor: theme.colors.background.default, }, - modalHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: theme.colors.border.muted, + /** Lets the account list consume remaining height under HeaderCompactStandard inside BottomSheet. */ + modalSheetBody: { + flex: 1, + minHeight: 0, }, accountText: { flexShrink: 1, diff --git a/app/components/Views/confirmations/components/AccountSelector/AccountSelector.test.tsx b/app/components/Views/confirmations/components/AccountSelector/AccountSelector.test.tsx index 3461f6548d1..43581356c67 100644 --- a/app/components/Views/confirmations/components/AccountSelector/AccountSelector.test.tsx +++ b/app/components/Views/confirmations/components/AccountSelector/AccountSelector.test.tsx @@ -22,7 +22,9 @@ jest.mock('../../../../../component-library/hooks', () => ({ })); jest.mock('@metamask/design-system-react-native', () => { - const { Text: RNText } = jest.requireActual('react-native'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const ReactActual = require('react'); + const { Text: RNText, View: RNView } = jest.requireActual('react-native'); const MockText = ({ children, ...props @@ -39,7 +41,34 @@ jest.mock('@metamask/design-system-react-native', () => { width?: number; twClassName?: string; }) => {`${props.height}x${props.width}`}; + const MockBottomSheet = ReactActual.forwardRef( + ( + { + children, + onClose, + testID, + }: { + children: React.ReactNode; + onClose?: (hasPendingAction?: boolean) => void; + testID?: string; + }, + ref: React.Ref<{ + onCloseBottomSheet: (cb?: () => void) => void; + onOpenBottomSheet: (cb?: () => void) => void; + }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: (cb?: () => void) => { + onClose?.(false); + cb?.(); + }, + onOpenBottomSheet: jest.fn(), + })); + return {children}; + }, + ); return { + BottomSheet: MockBottomSheet, Text: MockText, Skeleton: MockSkeleton, TextVariant: { BodyMd: 'BodyMd', HeadingMd: 'HeadingMd' }, @@ -70,6 +99,19 @@ jest.mock('../../../../../component-library/components/Icons/Icon', () => { }; }); +jest.mock( + '../../../../../component-library/components-temp/HeaderCompactStandard', + () => { + const { View, Text, Pressable } = jest.requireActual('react-native'); + return ({ title, onClose }: { title: string; onClose?: () => void }) => ( + + {title} + + + ); + }, +); + jest.mock('react-native', () => { const RN = jest.requireActual('react-native'); const { View } = RN; @@ -85,14 +127,12 @@ jest.mock('react-native', () => { testID?: string; animationType?: string; presentationStyle?: string; + transparent?: boolean; onRequestClose?: () => void; }) => { if (!visible) return null; return {children}; }, - SafeAreaView: ({ children }: { children: React.ReactNode }) => ( - {children} - ), }; }); @@ -243,6 +283,9 @@ describe('AccountSelector', () => { fireEvent.press(getByTestId(ACCOUNT_SELECTOR_TEST_IDS.PILL)); expect(getByTestId(ACCOUNT_SELECTOR_TEST_IDS.MODAL)).toBeOnTheScreen(); + expect( + getByTestId(ACCOUNT_SELECTOR_TEST_IDS.BOTTOM_SHEET), + ).toBeOnTheScreen(); }); it('calls onAccountSelected with correct address when account is selected', () => { @@ -278,6 +321,19 @@ describe('AccountSelector', () => { expect(getByText('From')).toBeOnTheScreen(); expect(queryByText('confirm.label.to')).toBeNull(); }); + + it('uses custom selector title in the sheet header when provided', () => { + const { getByTestId, getByText } = render( + , + ); + + fireEvent.press(getByTestId(ACCOUNT_SELECTOR_TEST_IDS.PILL)); + + expect(getByText('Custom sheet title')).toBeOnTheScreen(); + }); }); describe('AccountSelectorSkeleton', () => { diff --git a/app/components/Views/confirmations/components/AccountSelector/AccountSelector.tsx b/app/components/Views/confirmations/components/AccountSelector/AccountSelector.tsx index 45da93b1a28..019b347e374 100644 --- a/app/components/Views/confirmations/components/AccountSelector/AccountSelector.tsx +++ b/app/components/Views/confirmations/components/AccountSelector/AccountSelector.tsx @@ -1,6 +1,11 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { Modal, TouchableOpacity, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { + Modal, + StyleProp, + TouchableOpacity, + View, + ViewStyle, +} from 'react-native'; import { AccountGroupObject } from '@metamask/account-tree-controller'; import { AccountId } from '@metamask/accounts-controller'; import { EthScope } from '@metamask/keyring-api'; @@ -15,6 +20,8 @@ import Icon, { IconSize, } from '../../../../../component-library/components/Icons/Icon'; import { + BottomSheet, + BottomSheetRef, Skeleton, Text, TextColor, @@ -22,6 +29,7 @@ import { } from '@metamask/design-system-react-native'; import MultichainAccountSelectorList from '../../../../../component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList'; import { AccountSection } from '../../../../../component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.types'; +import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard'; import { useStyles } from '../../../../../component-library/hooks/useStyles'; import { strings } from '../../../../../../locales/i18n'; import { selectInternalAccountsById } from '../../../../../selectors/accountsController'; @@ -35,6 +43,8 @@ import stylesheet from './AccountSelector.styles'; export const ACCOUNT_SELECTOR_TEST_IDS = { PILL: 'account-selector-pill', MODAL: 'account-selector-modal', + /** Used when `BottomSheet` is mocked in unit tests (production sheet has no wrapper testID). */ + BOTTOM_SHEET: 'account-selector-bottom-sheet', }; export interface AccountSelectorProps { @@ -42,16 +52,22 @@ export interface AccountSelectorProps { onAccountSelected: (address: string) => void; /** Label shown on the left side of the row. Defaults to the "To" i18n string. */ label?: string; + /** Title in the account selection bottom sheet (header). */ + selectorTitle?: string; + style?: StyleProp; } const AccountSelector: React.FC = ({ selectedAddress, onAccountSelected, label = strings('confirm.label.to'), + selectorTitle = strings('bridge.select_recipient'), + style, }) => { const [isModalVisible, setIsModalVisible] = useState(false); + const bottomSheetRef = useRef(null); - const { styles, theme } = useStyles(stylesheet, {}); + const { styles } = useStyles(stylesheet, {}); const internalAccountsById = useSelector(selectInternalAccountsById); const accountToGroupMap = useSelector(selectAccountToGroupMap); @@ -64,6 +80,20 @@ const AccountSelector: React.FC = ({ [internalAccountsById], ); + const openModal = useCallback(() => setIsModalVisible(true), []); + + const closeAccountSheet = useCallback(() => { + bottomSheetRef.current?.onCloseBottomSheet(); + }, []); + + const handleSheetClosed = useCallback(() => { + setIsModalVisible(false); + }, []); + + const handleModalRequestClose = useCallback(() => { + closeAccountSheet(); + }, [closeAccountSheet]); + const handleSelectAccount = useCallback( (accountGroup: AccountGroupObject) => { const internalAccountId = accountGroup.accounts.find((accountId) => @@ -72,10 +102,15 @@ const AccountSelector: React.FC = ({ if (internalAccountId) { const internalAccount = internalAccountsById[internalAccountId]; onAccountSelected(internalAccount.address); - setIsModalVisible(false); + closeAccountSheet(); } }, - [getIsAccountSupported, internalAccountsById, onAccountSelected], + [ + closeAccountSheet, + getIsAccountSupported, + internalAccountsById, + onAccountSelected, + ], ); const filteredAccountSections = useMemo(() => { @@ -116,11 +151,8 @@ const AccountSelector: React.FC = ({ const accountName = selectedAccountGroup?.metadata?.name; - const openModal = useCallback(() => setIsModalVisible(true), []); - const closeModal = useCallback(() => setIsModalVisible(false), []); - return ( - + = ({ - - - - {strings('bridge.select_recipient')} - - - + + closeAccountSheet()} + /> + + - - - - + + + ); diff --git a/app/components/Views/confirmations/components/PayAccountSelector/PayAccountSelector.tsx b/app/components/Views/confirmations/components/PayAccountSelector/PayAccountSelector.tsx index 28e85610229..7c5327ce551 100644 --- a/app/components/Views/confirmations/components/PayAccountSelector/PayAccountSelector.tsx +++ b/app/components/Views/confirmations/components/PayAccountSelector/PayAccountSelector.tsx @@ -1,4 +1,5 @@ import React, { useCallback } from 'react'; +import { StyleProp, ViewStyle } from 'react-native'; import { TransactionType } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; @@ -9,7 +10,9 @@ import { useTransactionAccountOverride } from '../../hooks/transactions/useTrans import { hasTransactionType } from '../../utils/transaction'; import AccountSelector from '../AccountSelector'; -const PayAccountSelector: React.FC = () => { +const PayAccountSelector: React.FC<{ style?: StyleProp }> = ({ + style, +}) => { const transactionMeta = useTransactionMetadataRequest(); const transactionId = transactionMeta?.id; const accountOverride = useTransactionAccountOverride(); @@ -43,11 +46,17 @@ const PayAccountSelector: React.FC = () => { ? strings('confirm.label.from') : undefined; + const selectorTitle = isMoneyAccountDeposit + ? strings('bridge.select_account') + : strings('bridge.select_recipient'); + return ( ); }; diff --git a/app/components/Views/confirmations/components/UI/token/token.test.tsx b/app/components/Views/confirmations/components/UI/token/token.test.tsx index 1af2ee58d74..5ac09719001 100644 --- a/app/components/Views/confirmations/components/UI/token/token.test.tsx +++ b/app/components/Views/confirmations/components/UI/token/token.test.tsx @@ -6,6 +6,11 @@ import { AssetType } from '../../../types/token'; import { Token } from './token'; import { act, fireEvent } from '@testing-library/react-native'; +jest.mock( + '../../../../../UI/Assets/components/AssetLogo/AssetLogo', + () => () => null, +); + describe('Token', () => { const createMockToken = (overrides: Partial = {}): AssetType => ({ address: '0x1234567890123456789012345678901234567890', diff --git a/app/components/Views/confirmations/components/UI/token/token.tsx b/app/components/Views/confirmations/components/UI/token/token.tsx index e3c76b69be0..cba9e1d4d76 100644 --- a/app/components/Views/confirmations/components/UI/token/token.tsx +++ b/app/components/Views/confirmations/components/UI/token/token.tsx @@ -4,7 +4,6 @@ import { Box, Text, TextVariant, - AvatarToken, FontWeight, TextColor, } from '@metamask/design-system-react-native'; @@ -23,6 +22,7 @@ import { AccountTypeLabel } from '../account-type-label'; import { AssetType } from '../../../types/token'; import { formatAmount } from '../../../../../../components/UI/SimulationDetails/formatAmount'; import { ACCOUNT_TYPE_LABELS } from '../../../../../../constants/account-type-labels'; +import AssetLogo from '../../../../../UI/Assets/components/AssetLogo/AssetLogo'; interface TokenProps { asset: AssetType; @@ -75,11 +75,7 @@ export function Token({ asset, onPress }: TokenProps) { ticker={asset.symbol as string} /> ) : ( - + )} diff --git a/app/components/Views/confirmations/components/activity/eip-7702-sponsored-relay-api-failure.view.test.tsx b/app/components/Views/confirmations/components/activity/eip-7702-sponsored-relay-api-failure.view.test.tsx new file mode 100644 index 00000000000..d8e9986e54c --- /dev/null +++ b/app/components/Views/confirmations/components/activity/eip-7702-sponsored-relay-api-failure.view.test.tsx @@ -0,0 +1,107 @@ +/** + * Component-view coverage for smoke `gas-fee-tokens-eip-7702-sponsored`: + * (1) Activity / transaction details show Failed from `TransactionMeta.status` (not a hardcoded StatusText prop); + * (2) Review-step "Paid by MetaMask" gas row when sponsorship is allowed. + */ +import '../../../../../../tests/component-view/mocks'; +import React from 'react'; +import { cloneDeep } from 'lodash'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import { TransactionStatus } from '@metamask/transaction-controller'; +import { ConfirmationRowComponentIDs } from '../../ConfirmationView.testIds'; +import { ConfirmationContextProvider } from '../../context/confirmation-context'; +import GasFeesDetailsRow from '../rows/transactions/gas-fee-details-row/gas-fee-details-row'; +import { stakingDepositConfirmationState } from '../../../../../util/test/confirm-data-helpers'; +import { renderComponentViewScreen } from '../../../../../../tests/component-view/render'; +import { describeForPlatforms } from '../../../../../../tests/component-view/platform'; +import { + clearSentinelNetworksMocks, + setupSentinelNetworksRelayEnabledMock, +} from '../../../../../../tests/component-view/api-mocking/sentinel-networks'; +import { TransactionDetailsStatusRow } from './transaction-details-status-row/transaction-details-status-row'; +import { STATUS_ICON_TOOLTIP_OPEN_BUTTON_TEST_ID } from '../status-icon/status-icon.testIds'; +import { strings } from '../../../../../../locales/i18n'; + +const STAKING_TX_ID = '699ca2f0-e459-11ef-b6f6-d182277cf5e1'; + +function relayFailedActivityState() { + const state = cloneDeep(stakingDepositConfirmationState); + const tx = state.engine.backgroundState.TransactionController.transactions[0]; + tx.status = TransactionStatus.failed; + tx.error = { + name: 'JsonRpcError', + message: 'Relay submission failed', + }; + return state; +} + +/** + * Review step from the same smoke spec: simulation marks sponsorship; network fee row + * shows "Paid by MetaMask" (matches `RowComponents.NetworkFeePaidByMetaMask` / E2E wait + * before Confirm). + */ +function SponsoredGasFeeRowHarness() { + return ( + + + + ); +} + +describeForPlatforms( + 'EIP-7702 sponsored send — relay API failure (activity / details status)', + () => { + it('maps failed transaction in state to Failed label and error tooltip (transaction details)', async () => { + const { getByText, getByTestId } = renderComponentViewScreen( + TransactionDetailsStatusRow, + { name: 'Eip7702RelayApiFailureActivity' }, + { state: relayFailedActivityState() }, + { transactionId: STAKING_TX_ID }, + ); + + await waitFor(() => + expect(getByText(strings('transaction.failed'))).toBeOnTheScreen(), + ); + + fireEvent.press(getByTestId(STATUS_ICON_TOOLTIP_OPEN_BUTTON_TEST_ID)); + + expect(getByText('Relay submission failed')).toBeOnTheScreen(); + }); + }, +); + +describeForPlatforms('EIP-7702 sponsored send — review (network fee)', () => { + const sponsoredGasFeeState = () => { + const state = cloneDeep(stakingDepositConfirmationState); + const tx = state.engine.backgroundState.TransactionController + .transactions[0] as { isGasFeeSponsored?: boolean }; + tx.isGasFeeSponsored = true; + return state; + }; + + beforeEach(() => { + setupSentinelNetworksRelayEnabledMock(); + }); + + afterEach(() => { + clearSentinelNetworksMocks(); + }); + + it('shows Paid by MetaMask on the gas row when sponsorship is allowed (smoke review step)', async () => { + const { getByTestId, getByText } = renderComponentViewScreen( + SponsoredGasFeeRowHarness, + { name: 'Eip7702SponsoredGasFee' }, + { state: sponsoredGasFeeState() }, + ); + + await waitFor( + () => { + expect( + getByTestId(ConfirmationRowComponentIDs.PAID_BY_METAMASK), + ).toBeOnTheScreen(); + }, + { timeout: 8000 }, + ); + expect(getByText('Paid by MetaMask')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/Views/confirmations/components/activity/transaction-details-account-row/transaction-details-account-row.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details-account-row/transaction-details-account-row.test.tsx index 4c31c805545..80d23ceee4e 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-account-row/transaction-details-account-row.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-account-row/transaction-details-account-row.test.tsx @@ -65,4 +65,18 @@ describe('TransactionDetailsAccountRow', () => { const { toJSON } = render(); expect(toJSON()).toBeNull(); }); + + it.each([ + TransactionType.moneyAccountWithdraw, + TransactionType.perpsWithdraw, + TransactionType.predictClaim, + TransactionType.predictWithdraw, + ])('renders account row for %s', (type) => { + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { ...TRANSACTION_META_MOCK, type }, + }); + + const { getByText } = render(); + expect(getByText(ACCOUNT_NAME_MOCK)).toBeDefined(); + }); }); diff --git a/app/components/Views/confirmations/components/activity/transaction-details-account-row/transaction-details-account-row.tsx b/app/components/Views/confirmations/components/activity/transaction-details-account-row/transaction-details-account-row.tsx index a0560a6a071..8836e92612e 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-account-row/transaction-details-account-row.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-account-row/transaction-details-account-row.tsx @@ -11,6 +11,7 @@ import { TransactionType } from '@metamask/transaction-controller'; import { hasTransactionType } from '../../../utils/transaction'; const TRANSACTION_TYPES = [ + TransactionType.moneyAccountWithdraw, TransactionType.perpsWithdraw, TransactionType.predictClaim, TransactionType.predictWithdraw, diff --git a/app/components/Views/confirmations/components/activity/transaction-details-bridge-fee-row/transaction-details-bridge-fee-row.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details-bridge-fee-row/transaction-details-bridge-fee-row.test.tsx index 187af717dd1..b2485455db1 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-bridge-fee-row/transaction-details-bridge-fee-row.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-bridge-fee-row/transaction-details-bridge-fee-row.test.tsx @@ -68,6 +68,20 @@ describe('TransactionDetailsBridgeFeeRow', () => { expect(getByText('Provider fee')).toBeOnTheScreen(); }); + it('renders "Provider fee" label for money account withdrawals', () => { + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { + type: TransactionType.moneyAccountWithdraw, + metamaskPay: { + bridgeFeeFiat: BRIDGE_FEE_FIAT_MOCK, + }, + } as unknown as TransactionMeta, + }); + + const { getByText } = render(); + expect(getByText('Provider fee')).toBeOnTheScreen(); + }); + it('renders nothing if no bridge fee fiat', () => { useTransactionDetailsMock.mockReturnValue({ transactionMeta: { diff --git a/app/components/Views/confirmations/components/activity/transaction-details-bridge-fee-row/transaction-details-bridge-fee-row.tsx b/app/components/Views/confirmations/components/activity/transaction-details-bridge-fee-row/transaction-details-bridge-fee-row.tsx index 7c0832d5f1e..3f9ba65eb5d 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-bridge-fee-row/transaction-details-bridge-fee-row.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-bridge-fee-row/transaction-details-bridge-fee-row.tsx @@ -16,8 +16,9 @@ export function TransactionDetailsBridgeFeeRow() { const { bridgeFeeFiat } = metamaskPay || {}; const isWithdraw = hasTransactionType(transactionMeta, [ - TransactionType.predictWithdraw, + TransactionType.moneyAccountWithdraw, TransactionType.perpsWithdraw, + TransactionType.predictWithdraw, ]); const label = isWithdraw diff --git a/app/components/Views/confirmations/components/activity/transaction-details-hero/transaction-details-hero.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details-hero/transaction-details-hero.test.tsx index a1e8beaa100..71b9e5216b4 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-hero/transaction-details-hero.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-hero/transaction-details-hero.test.tsx @@ -218,4 +218,19 @@ describe('TransactionDetailsHero', () => { const { queryByTestId } = render(); expect(queryByTestId('transaction-details-hero')).toBeNull(); }); + + it.each([ + TransactionType.moneyAccountDeposit, + TransactionType.moneyAccountWithdraw, + ])('renders hero amount for %s', (type) => { + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { + ...TRANSACTION_META_MOCK, + type, + } as unknown as TransactionMeta, + }); + + const { getByText } = render(); + expect(getByText('$123.46')).toBeDefined(); + }); }); diff --git a/app/components/Views/confirmations/components/activity/transaction-details-hero/transaction-details-hero.tsx b/app/components/Views/confirmations/components/activity/transaction-details-hero/transaction-details-hero.tsx index 9b50ec7f9a6..eed7b6300e3 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-hero/transaction-details-hero.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-hero/transaction-details-hero.tsx @@ -32,6 +32,8 @@ import { RootState } from '../../../../../../reducers'; import useNetworkInfo from '../../../hooks/useNetworkInfo'; const SUPPORTED_TYPES = [ + TransactionType.moneyAccountDeposit, + TransactionType.moneyAccountWithdraw, TransactionType.musdClaim, TransactionType.musdConversion, TransactionType.perpsDeposit, diff --git a/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.test.tsx index 6f124188940..9e6054ed30a 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.test.tsx @@ -65,4 +65,15 @@ describe('TransactionDetailsNetworkFeeRow', () => { expect(toJSON()).toBeNull(); }); + + it('renders calculated network fee for moneyAccountWithdraw fallback', () => { + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { + type: TransactionType.moneyAccountWithdraw, + } as unknown as TransactionMeta, + }); + + const { getByText } = render(); + expect(getByText(`$${CALCULATED_FEE_MOCK}`)).toBeDefined(); + }); }); diff --git a/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.tsx b/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.tsx index 1995466889b..0dda62e9c26 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-network-fee-row/transaction-details-network-fee-row.tsx @@ -11,6 +11,7 @@ import { TransactionDetailsSelectorIDs } from '../TransactionDetailsModal.testId import { usePayFiatFormatter } from '../../../hooks/pay/usePayFiatFormatter'; const FALLBACK_TYPES = [ + TransactionType.moneyAccountWithdraw, TransactionType.perpsWithdraw, TransactionType.predictClaim, TransactionType.predictWithdraw, diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.test.tsx index 0ddbfb64d03..c29196a6e3c 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.test.tsx @@ -128,6 +128,20 @@ describe('TransactionDetailsSummary', () => { expect(getByText('ReceiveSummaryLine')).toBeDefined(); }); + it('routes moneyAccountDeposit to ReceiveSummaryLine', () => { + const { getByText } = render({ + transactions: [ + { + id: transactionIdMock, + chainId: '0x1', + type: TransactionType.moneyAccountDeposit, + }, + ], + }); + + expect(getByText('ReceiveSummaryLine')).toBeDefined(); + }); + it('routes unsupported types to DefaultSummaryLine', () => { const { getByText } = render({ transactions: [ diff --git a/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx b/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx index 0fc9b203a0b..9903df6fac3 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx @@ -114,6 +114,7 @@ function SummaryLine({ if ( hasTransactionType(transactionMeta, [ + TransactionType.moneyAccountDeposit, TransactionType.perpsDeposit, TransactionType.predictDeposit, TransactionType.musdConversion, diff --git a/app/components/Views/confirmations/components/activity/transaction-details-total-row/transaction-details-total-row.test.tsx b/app/components/Views/confirmations/components/activity/transaction-details-total-row/transaction-details-total-row.test.tsx index a36064e3d33..87b703b8a80 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-total-row/transaction-details-total-row.test.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-total-row/transaction-details-total-row.test.tsx @@ -130,4 +130,31 @@ describe('TransactionDetailsTotalRow', () => { // Uses fiatUnformatted and user's currency formatter expect(getByText('$123.45')).toBeOnTheScreen(); }); + + it('falls back to token amount for moneyAccountWithdraw without pay totals', () => { + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { + metamaskPay: {}, + type: TransactionType.moneyAccountWithdraw, + } as unknown as TransactionMeta, + }); + + const { getByText } = render(); + expect(getByText(`$${TOKEN_TOTAL}`)).toBeOnTheScreen(); + }); + + it('renders targetFiat for moneyAccountWithdraw as a receive-type', () => { + useTransactionDetailsMock.mockReturnValue({ + transactionMeta: { + type: TransactionType.moneyAccountWithdraw, + metamaskPay: { + totalFiat: PAY_TOTAL, + targetFiat: '88.88', + }, + } as unknown as TransactionMeta, + }); + + const { getByText } = render(); + expect(getByText('$88.88')).toBeOnTheScreen(); + }); }); diff --git a/app/components/Views/confirmations/components/activity/transaction-details-total-row/transaction-details-total-row.tsx b/app/components/Views/confirmations/components/activity/transaction-details-total-row/transaction-details-total-row.tsx index cd7c818b35b..8651ae0f574 100644 --- a/app/components/Views/confirmations/components/activity/transaction-details-total-row/transaction-details-total-row.tsx +++ b/app/components/Views/confirmations/components/activity/transaction-details-total-row/transaction-details-total-row.tsx @@ -12,12 +12,14 @@ import { usePayFiatFormatter } from '../../../hooks/pay/usePayFiatFormatter'; import { USER_CURRENCY_TYPES } from '../../../constants/confirmations'; const FALLBACK_TYPES = [ + TransactionType.moneyAccountWithdraw, TransactionType.musdClaim, TransactionType.perpsWithdraw, TransactionType.predictWithdraw, ]; const RECEIVE_TYPES = [ + TransactionType.moneyAccountWithdraw, TransactionType.musdClaim, TransactionType.perpsWithdraw, TransactionType.predictClaim, diff --git a/app/components/Views/confirmations/components/alert-banner/alert-system-security-failed.view.test.tsx b/app/components/Views/confirmations/components/alert-banner/alert-system-security-failed.view.test.tsx new file mode 100644 index 00000000000..0fa96b13b6a --- /dev/null +++ b/app/components/Views/confirmations/components/alert-banner/alert-system-security-failed.view.test.tsx @@ -0,0 +1,88 @@ +import '../../../../../../tests/component-view/mocks'; +import React from 'react'; +import { merge } from 'lodash'; +import { fireEvent, waitFor } from '@testing-library/react-native'; + +import { renderComponentViewScreen } from '../../../../../../tests/component-view/render'; +import { describeForPlatforms } from '../../../../../../tests/component-view/platform'; +import { typedSignV1ConfirmationState } from '../../../../../util/test/confirm-data-helpers'; +import { + ConfirmationTopSheetSelectorsIDs, + ConfirmationTopSheetSelectorsText, +} from '../../ConfirmationView.testIds'; +import { AlertsContextProvider } from '../../context/alert-system-context'; +import useBlockaidAlerts from '../../hooks/alerts/useBlockaidAlerts'; +import AlertBanner from './alert-banner'; +import { Reason, ResultType } from '../blockaid-banner/BlockaidBanner.types'; + +/** Matches `messageParams.requestId` on the typed-sign fixture (ppom / security alert key). */ +const TYPED_SIGN_SECURITY_ALERT_KEY = '2453610887'; + +/** No `req`/`chainId` so `BlockaidAlertContent` skips gzip (view env has no native gzip). */ +const securityValidationFailedResponse = { + result_type: ResultType.Failed, + reason: Reason.failed, + features: [] as string[], +}; + +/** + * Blockaid alerts only (same source as the first slice of `useConfirmationAlerts` for this state). + * Skips transaction-alert hooks that need QueryClient, hardware wallet, etc. + */ +function BlockaidAlertBannerHarness() { + const alerts = useBlockaidAlerts(); + return ( + + + + ); +} + +describeForPlatforms('Alert system (signatures)', () => { + /** + * Smoke `alert-system`: security validation API error shows the redesigned banner + * and failed-state copy (no signing success path). + */ + it('shows security alert when validation request fails', async () => { + const state = merge({}, typedSignV1ConfirmationState, { + securityAlerts: { + alerts: { + [TYPED_SIGN_SECURITY_ALERT_KEY]: securityValidationFailedResponse, + }, + }, + engine: { + backgroundState: { + PreferencesController: { + securityAlertsEnabled: true, + }, + }, + }, + }); + + const { getByTestId, getByText } = renderComponentViewScreen( + BlockaidAlertBannerHarness, + { name: 'SecurityValidationFailed' }, + { state }, + ); + + const bannerTestId = + ConfirmationTopSheetSelectorsIDs.SECURITY_ALERT_BANNER_REDESIGNED; + + await waitFor(() => { + expect(getByTestId(bannerTestId)).toBeOnTheScreen(); + }); + + expect( + getByText(ConfirmationTopSheetSelectorsText.BANNER_FAILED_TITLE), + ).toBeOnTheScreen(); + expect( + getByText(ConfirmationTopSheetSelectorsText.BANNER_FAILED_DESCRIPTION), + ).toBeOnTheScreen(); + + fireEvent.press(getByTestId(bannerTestId)); + + await waitFor(() => { + expect(getByTestId(bannerTestId)).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/Views/confirmations/components/alert-banner/alert-system-siwe-inline-mismatch.view.test.tsx b/app/components/Views/confirmations/components/alert-banner/alert-system-siwe-inline-mismatch.view.test.tsx new file mode 100644 index 00000000000..3d4523ab038 --- /dev/null +++ b/app/components/Views/confirmations/components/alert-banner/alert-system-siwe-inline-mismatch.view.test.tsx @@ -0,0 +1,133 @@ +import '../../../../../../tests/component-view/mocks'; +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import { merge } from 'lodash'; + +import ExtendedKeyringTypes from '../../../../../constants/keyringTypes'; +import { HardwareWalletProvider } from '../../../../../core/HardwareWallet/HardwareWalletProvider'; +import { renderComponentViewScreen } from '../../../../../../tests/component-view/render'; +import { describeForPlatforms } from '../../../../../../tests/component-view/platform'; +import { siweSignatureConfirmationState } from '../../../../../util/test/confirm-data-helpers'; +import { + AlertModalSelectorsIDs, + AlertModalSelectorsText, + AlertTypeIDs, + ConfirmationFooterSelectorIDs, + ConfirmAlertModalSelectorsIDs, +} from '../../ConfirmationView.testIds'; +import { AlertsContextProvider } from '../../context/alert-system-context'; +import { ConfirmationContextProvider } from '../../context/confirmation-context'; +import { QRHardwareContextProvider } from '../../context/qr-hardware-context'; +import useDomainMismatchAlerts from '../../hooks/alerts/useDomainMismatchAlerts'; +import { NetworkAndOriginRow } from '../rows/transactions/network-and-origin-row/network-and-origin-row'; +import { Footer } from '../footer/footer'; + +/** + * `meta.url` origin must differ from the SIWE message domain so + * `isValidSIWEOrigin` fails (same idea as SIWE “bad domain” E2E). + */ +const SIWE_BAD_DOMAIN_STATE = merge({}, siweSignatureConfirmationState, { + engine: { + backgroundState: { + PreferencesController: { + securityAlertsEnabled: true, + }, + ApprovalController: { + pendingApprovals: { + '72424261-e22f-11ef-8e59-bf627a5d8354': { + requestData: { + meta: { + url: 'https://malicious.example.test/fake-dapp/', + }, + }, + }, + }, + }, + }, + }, +}); + +const SIWE_SIGNER_ADDRESS = + '0x8eeee1781fd885ff5ddef7789486676961873d12' as const; + +function seedEngineKeyringWithSiweSigner(): void { + const engineMock = jest.requireMock( + '../../../../../../app/core/Engine', + ) as unknown as { + default: { + context: { + KeyringController: { state: { keyrings: unknown[] } }; + }; + }; + }; + engineMock.default.context.KeyringController.state.keyrings = [ + { + type: ExtendedKeyringTypes.hd, + accounts: [SIWE_SIGNER_ADDRESS], + }, + ]; +} + +function SiweDomainMismatchInlineFlowHarness() { + const alerts = useDomainMismatchAlerts(); + return ( + + + + + +